@jossuealcala/madre 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -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 +268 -48
- package/public/es.js +40 -3
- package/public/index.html +2 -0
- package/public/styles.css +106 -11
- package/public/syntax.js +175 -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/control.mjs +10 -3
- package/src/room.mjs +15 -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,22 @@ 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
|
+
// Every other vendor is a company and a company is called the same everywhere. @madre's is a
|
|
145
|
+
// sentence, so it is said in the room's language — declared, not computed, so both guards see it.
|
|
146
|
+
const VENDOR_WORDS = { 'the room · local': t('the room · local'), local: t('local') };
|
|
147
|
+
const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDOR_WORDS[said] ?? said; };
|
|
148
|
+
|
|
149
|
+
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
150
|
+
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
151
|
+
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
152
|
+
const touched = new Map();
|
|
153
|
+
function markTouched(agent, files) {
|
|
154
|
+
for (const file of files ?? []) {
|
|
155
|
+
const path = typeof file === 'string' ? file : file?.path;
|
|
156
|
+
if (path && agent) touched.set(String(path).replace(/^\.?\//, ''), agent);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
|
|
142
161
|
|
|
143
162
|
/* ---------- helpers ---------- */
|
|
@@ -297,6 +316,8 @@ const viewer = {
|
|
|
297
316
|
const viewerUI = {
|
|
298
317
|
selection: document.querySelector('#viewer-selection'),
|
|
299
318
|
review: document.querySelector('#viewer-review'),
|
|
319
|
+
lang: document.querySelector('#viewer-lang'),
|
|
320
|
+
hintSelect: document.querySelector('#viewer-hint-select'),
|
|
300
321
|
menu: document.querySelector('#viewer-menu'),
|
|
301
322
|
path: null, // project-relative path of the open text file, if any
|
|
302
323
|
from: null, to: null,
|
|
@@ -312,6 +333,9 @@ function setSelection(from, to) {
|
|
|
312
333
|
const has = from !== null;
|
|
313
334
|
viewerUI.selection.hidden = !has;
|
|
314
335
|
viewerUI.review.hidden = !has;
|
|
336
|
+
// The button only exists once something is selected, and nobody guesses that a line number is
|
|
337
|
+
// clickable. While there is nothing selected and the file is readable as text, say how.
|
|
338
|
+
if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
|
|
315
339
|
if (has) viewerUI.selection.textContent = lo === hi ? `L${lo}` : `L${lo}-${hi}`;
|
|
316
340
|
}
|
|
317
341
|
function referenceForSelection() {
|
|
@@ -323,7 +347,11 @@ function referenceForSelection() {
|
|
|
323
347
|
function reviewWith(agentId) {
|
|
324
348
|
const reference = referenceForSelection();
|
|
325
349
|
const current = els.input.value.trim();
|
|
326
|
-
|
|
350
|
+
// The reference and the cursor, nothing else. This used to seed "review this: " — an English
|
|
351
|
+
// phrase in a Spanish room, and worse, a sentence MADRE put in the human's mouth: whatever they
|
|
352
|
+
// typed next landed after it and read like somebody else's words. They just chose an agent and
|
|
353
|
+
// a range; they know what they want to ask.
|
|
354
|
+
els.input.value = current ? `${reference} ${current}` : `${reference} `;
|
|
327
355
|
if (agentId && state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
|
|
328
356
|
hideViewerMenu();
|
|
329
357
|
viewer.dialog.close();
|
|
@@ -333,17 +361,27 @@ function reviewWith(agentId) {
|
|
|
333
361
|
}
|
|
334
362
|
function showViewerMenu(x, y) {
|
|
335
363
|
const menuNode = viewerUI.menu;
|
|
336
|
-
|
|
364
|
+
// Two lines: what is being sent, and the question. One line put the verb at one end and its
|
|
365
|
+
// preposition at the other, and in Spanish the sentence ended on a dangling «CON» that the
|
|
366
|
+
// menu then cut off.
|
|
367
|
+
const head = el('div', 'hint');
|
|
368
|
+
head.append(el('b', null, referenceForSelection()));
|
|
369
|
+
head.append(el('span', null, t('review it with')));
|
|
370
|
+
menuNode.replaceChildren(head);
|
|
337
371
|
for (const agent of state.agents.values()) {
|
|
338
372
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
339
373
|
item.type = 'button';
|
|
340
|
-
|
|
374
|
+
// `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
|
|
375
|
+
// handle does not already tell you — the vendor, or why it cannot take this.
|
|
376
|
+
const aside = agent.ready ? vendorWord(agent.id) : t('no session');
|
|
377
|
+
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
341
378
|
item.disabled = !agent.ready;
|
|
342
379
|
item.addEventListener('click', () => reviewWith(agent.id));
|
|
343
380
|
menuNode.append(item);
|
|
344
381
|
}
|
|
345
382
|
menuNode.hidden = false;
|
|
346
|
-
|
|
383
|
+
// The header is two lines now and the reference can wrap; leave room rather than clip it.
|
|
384
|
+
const width = 260, height = 62 + 36 * state.agents.size;
|
|
347
385
|
menuNode.style.left = `${Math.min(x, window.innerWidth - width - 12)}px`;
|
|
348
386
|
menuNode.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
|
|
349
387
|
}
|
|
@@ -355,14 +393,26 @@ viewerUI.review?.addEventListener('click', (event) => {
|
|
|
355
393
|
viewer.dialog.addEventListener('click', (event) => { if (!viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && event.target !== viewerUI.review) hideViewerMenu(); });
|
|
356
394
|
viewer.dialog.addEventListener('close', () => { hideViewerMenu(); setSelection(null, null); ripley.frame = null; ripley.history = []; ripleyClearErrors(); if (ripley.nav) ripley.nav.hidden = true; });
|
|
357
395
|
|
|
358
|
-
function codeView(text, { line = null, lines = null } = {}) {
|
|
396
|
+
function codeView(text, { line = null, lines = null, name = null } = {}) {
|
|
359
397
|
const pre = el('pre', 'code');
|
|
360
398
|
viewerUI.lineNodes = [];
|
|
361
399
|
const rows = text.replace(/\n$/, '').split('\n');
|
|
400
|
+
// Read by what the file is. The highlighter never rewrites a line — it hands back spans over
|
|
401
|
+
// the same characters — so what is on screen and what is on disk stay the same bytes, and a
|
|
402
|
+
// selection sent to an agent is the file, not a rendering of it.
|
|
403
|
+
const family = familyOf(name ?? '');
|
|
404
|
+
if (viewerUI.lang) {
|
|
405
|
+
const label = languageLabel(name ?? '');
|
|
406
|
+
viewerUI.lang.textContent = label ?? '';
|
|
407
|
+
viewerUI.lang.hidden = !label;
|
|
408
|
+
}
|
|
409
|
+
const carry = { inBlock: false };
|
|
362
410
|
rows.forEach((content, index) => {
|
|
363
411
|
const n = index + 1;
|
|
364
412
|
const ln = el('span', 'ln', String(n));
|
|
365
|
-
const tx = el('span', 'tx'
|
|
413
|
+
const tx = el('span', 'tx');
|
|
414
|
+
if (!content) tx.append(' ');
|
|
415
|
+
else for (const piece of tokenize(content, family, carry)) tx.append(piece.kind ? el('span', `t-${piece.kind}`, piece.text) : piece.text);
|
|
366
416
|
ln.dataset.n = n; tx.dataset.n = n;
|
|
367
417
|
const marked = lines ? n >= lines.from && n <= lines.to : n === line;
|
|
368
418
|
if (marked) { ln.classList.add('hl'); tx.classList.add('hl'); }
|
|
@@ -392,7 +442,7 @@ function showViewerSource() {
|
|
|
392
442
|
if (ripley.nav) ripley.nav.hidden = true;
|
|
393
443
|
ripleyClearErrors();
|
|
394
444
|
ripley.frame = null;
|
|
395
|
-
const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines });
|
|
445
|
+
const pre = codeView(viewerMode.text, { line: viewerMode.line, lines: viewerMode.lines, name: viewerUI.path ?? viewer.path.textContent });
|
|
396
446
|
viewer.body.replaceChildren(pre);
|
|
397
447
|
if (!state.ripley && viewerMode.kind) {
|
|
398
448
|
const hint = el('div', 'viewer-hint');
|
|
@@ -729,7 +779,7 @@ function renderAgents() {
|
|
|
729
779
|
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
730
780
|
});
|
|
731
781
|
node.title = [
|
|
732
|
-
`${agent.label}${
|
|
782
|
+
`${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
|
|
733
783
|
agent.ready ? t('Ready') : agent.detected ? t('Detected, adapter pending') : t('Not installed'),
|
|
734
784
|
agent.version ? agent.version : null,
|
|
735
785
|
official === null
|
|
@@ -772,7 +822,7 @@ function renderPicker() {
|
|
|
772
822
|
text.append(t('to '));
|
|
773
823
|
text.append(el('b', null, `@${current.id}`));
|
|
774
824
|
if (current.local) {
|
|
775
|
-
const note = el('span', 'pick-note', t('memory ·
|
|
825
|
+
const note = el('span', 'pick-note', t('memory · never writes'));
|
|
776
826
|
const measured = state.localModel?.checked;
|
|
777
827
|
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
828
|
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 +834,18 @@ function renderPicker() {
|
|
|
784
834
|
modeChip.title = t('Permission mode for this message · {hint}', { hint: MODES[state.mode].hint });
|
|
785
835
|
modeChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModeMenu(current.id, modeChip); });
|
|
786
836
|
text.append(modeChip);
|
|
837
|
+
// An agent with no models to choose from gets no chooser. @madre answers on whatever chat
|
|
838
|
+
// model Ollama holds, which is decided on Ollama's card and not here, so the chip only opened
|
|
839
|
+
// an empty menu — and it was the piece that pushed this row onto a second line.
|
|
787
840
|
const chosen = state.chosenModel[current.id];
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
841
|
+
const choices = state.models[current.id]?.models ?? null;
|
|
842
|
+
if (!current.local && (choices === null || choices.length)) {
|
|
843
|
+
const modelChip = el('button', 'model-chip', chosen ? `${chosen} ▾` : t('default model ▾'));
|
|
844
|
+
modelChip.type = 'button';
|
|
845
|
+
modelChip.title = t('Choose the model for this agent');
|
|
846
|
+
modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
|
|
847
|
+
text.append(modelChip);
|
|
848
|
+
}
|
|
793
849
|
els.picker.append(text);
|
|
794
850
|
}
|
|
795
851
|
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
@@ -881,7 +937,11 @@ function toggleModeMenu(id, anchor) {
|
|
|
881
937
|
const scopes = state.capabilities[id]?.scopes;
|
|
882
938
|
const canRaise = Boolean(scopes?.write?.capable);
|
|
883
939
|
const title = el('div', 'model-menu-title');
|
|
884
|
-
|
|
940
|
+
// Short enough to sit on one line at any width the menu takes. That it applies to this message
|
|
941
|
+
// and not to every one after it is the mode chip's own tooltip, and the list below is literally
|
|
942
|
+
// the modes — so the header carries the two facts nothing else here does: who, and how far.
|
|
943
|
+
title.append(el('b', null, `@${id}`), t(' · ceiling #{cap}', { cap }));
|
|
944
|
+
title.title = t('Permission mode for this message · {hint}', { hint: MODES[cap].hint });
|
|
885
945
|
modeMenu.append(title);
|
|
886
946
|
const ladder = el('div', 'mode-ladder');
|
|
887
947
|
for (const n of [0, 1, 2, 3, 4]) {
|
|
@@ -1083,19 +1143,20 @@ function bridgeCard(agent) {
|
|
|
1083
1143
|
card.id = `bridge-${agent.id}`;
|
|
1084
1144
|
const session = state.sessions?.[agent.id];
|
|
1085
1145
|
const signedIn = session?.state === 'signed-in';
|
|
1086
|
-
const
|
|
1146
|
+
const routeBlocked = Boolean(agent.route?.custom && !agent.route?.allowed);
|
|
1147
|
+
const stage = !agent.detected ? 'missing' : !agent.ready ? 'inert' : routeBlocked ? 'route-blocked' : signedIn ? 'ready' : 'signed-out';
|
|
1087
1148
|
const head = el('div', 'head');
|
|
1088
1149
|
head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
1089
1150
|
const title = el('div', 'title');
|
|
1090
1151
|
title.append(el('b', null, agent.label));
|
|
1091
|
-
title.append(el('span', 'vendor',
|
|
1152
|
+
title.append(el('span', 'vendor', vendorWord(agent.id)));
|
|
1092
1153
|
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')));
|
|
1154
|
+
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
1155
|
card.append(head);
|
|
1095
1156
|
const detail = el('div', 'detail');
|
|
1096
1157
|
if (agent.id === 'madre') detail.append(ollamaDetail(agent));
|
|
1097
1158
|
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}` : ''}`);
|
|
1159
|
+
else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
|
|
1099
1160
|
card.append(detail);
|
|
1100
1161
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
1101
1162
|
if (agent.account) {
|
|
@@ -1445,7 +1506,10 @@ function showReplyMenu(source, x, y) {
|
|
|
1445
1506
|
for (const agent of state.agents.values()) {
|
|
1446
1507
|
const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
|
|
1447
1508
|
item.type = 'button';
|
|
1448
|
-
|
|
1509
|
+
// The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
|
|
1510
|
+
// what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
|
|
1511
|
+
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : vendorWord(agent.id);
|
|
1512
|
+
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
1449
1513
|
item.disabled = !agent.ready;
|
|
1450
1514
|
item.addEventListener('click', () => replyWith(agent.id, source));
|
|
1451
1515
|
replyMenu.append(item);
|
|
@@ -1637,6 +1701,11 @@ function renderThinking(event) {
|
|
|
1637
1701
|
return node;
|
|
1638
1702
|
}
|
|
1639
1703
|
|
|
1704
|
+
function warnUnknownModuleFields(installed) {
|
|
1705
|
+
const unknown = installed?.unknown ?? [];
|
|
1706
|
+
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);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1640
1709
|
// The archivist reports: which agent read which stretch of the room and how many notes it kept.
|
|
1641
1710
|
// module · @codex wrote read-mail.module.mjs · INSTALL FOR EVERY ROOM · INSTALL FOR THIS PROJECT · LATER
|
|
1642
1711
|
function renderModuleProposed(event) {
|
|
@@ -1652,6 +1721,7 @@ function renderModuleProposed(event) {
|
|
|
1652
1721
|
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
1722
|
if (payload.error) throw new Error(payload.error);
|
|
1654
1723
|
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') }));
|
|
1724
|
+
warnUnknownModuleFields(payload.installed);
|
|
1655
1725
|
modules.items = payload.extensions ?? modules.items; if (modules.dialog.open) renderModules();
|
|
1656
1726
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); button.disabled = false; }
|
|
1657
1727
|
};
|
|
@@ -2091,7 +2161,10 @@ function renderLease(event) {
|
|
|
2091
2161
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2092
2162
|
const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
|
|
2093
2163
|
node.append(el('b', null, standing ? t('default #2 · ') : escalated ? `${t('#2 granted on request')}${escalated === 'plan' ? t(' · whole plan') : ''} · ` : delegated ? t('#2 by @{who} · ', { who: grantedBy }) : t('create · ')));
|
|
2094
|
-
|
|
2164
|
+
// The verbs were translated and the sentence holding them was not: «@CODEX MAY CREA, GENERA
|
|
2165
|
+
// IMÁGENES». A sentence is translated whole or it is not translated.
|
|
2166
|
+
const can = scopes.map((scope) => CAP_LABELS[scope] ?? scope).join(', ');
|
|
2167
|
+
node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') }) : ''} `);
|
|
2095
2168
|
if (outDir === '.' || !outDir) {
|
|
2096
2169
|
node.append(t('anywhere in the project · existing files stay untouched'));
|
|
2097
2170
|
if (scratchDir) { node.append(t(' · scratch ')); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
|
|
@@ -2394,12 +2467,12 @@ function renderEventNode(event) {
|
|
|
2394
2467
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
2395
2468
|
case 'control.started': node = renderControlStarted(event); break;
|
|
2396
2469
|
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;
|
|
2470
|
+
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
2471
|
case 'control.reverted': node = renderControlReverted(event); break;
|
|
2399
2472
|
case 'mode.granted':
|
|
2400
2473
|
case 'mode.denied': settleModeRequest(event); return;
|
|
2401
2474
|
case 'plan.ignored': node = renderPlanIgnored(event); break;
|
|
2402
|
-
case 'artifacts.created': attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
|
|
2475
|
+
case 'artifacts.created': markTouched(event.payload.agent, event.payload.files); attachArtifacts(event); if (!replaying) ripleyMaybeReload((event.payload.files ?? []).map((file) => file.path)); return;
|
|
2403
2476
|
case 'command.output': node = renderCommandCard(event); break;
|
|
2404
2477
|
case 'eyecat.flagged': node = renderEyecat(event); break;
|
|
2405
2478
|
case 'eyecat.settled': settleEyecat(event); return;
|
|
@@ -2563,6 +2636,13 @@ function treeNode(path, entry) {
|
|
|
2563
2636
|
item.append(button, children);
|
|
2564
2637
|
} else {
|
|
2565
2638
|
button.append(el('span', 'caret', ''), el('span', 'name', entry.name), el('span', 'size', formatSize(entry.size)));
|
|
2639
|
+
const by = touched.get(path);
|
|
2640
|
+
if (by) {
|
|
2641
|
+
// Its colour is the agent's own, so the tree reads the same way the thread does.
|
|
2642
|
+
const dot = paint(el('span', 'touched'), by);
|
|
2643
|
+
dot.title = t('{agent} wrote this file in this room', { agent: `@${by}` });
|
|
2644
|
+
button.append(dot);
|
|
2645
|
+
}
|
|
2566
2646
|
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
2567
2647
|
item.append(button);
|
|
2568
2648
|
}
|
|
@@ -2650,10 +2730,14 @@ async function loadChats() {
|
|
|
2650
2730
|
}
|
|
2651
2731
|
|
|
2652
2732
|
async function openChat(id) {
|
|
2733
|
+
if (id === chats.active) { setChats(false); return; }
|
|
2653
2734
|
const response = await fetch(`/api/chats/${id}`, { method: 'POST' });
|
|
2654
2735
|
const result = await response.json().catch(() => ({}));
|
|
2655
2736
|
if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'that conversation could not be opened.'}`); return; }
|
|
2656
|
-
|
|
2737
|
+
const payload = await fetch('/api/state').then((one) => one.json()).catch(() => null);
|
|
2738
|
+
if (!payload) { window.location.reload(); return; }
|
|
2739
|
+
await paintConversation(payload);
|
|
2740
|
+
setChats(false);
|
|
2657
2741
|
}
|
|
2658
2742
|
|
|
2659
2743
|
function setChats(open) {
|
|
@@ -2750,7 +2834,7 @@ document.querySelector('#mother-button')?.addEventListener('mouseleave', endHove
|
|
|
2750
2834
|
document.querySelector('#mother-button')?.addEventListener('click', endHover);
|
|
2751
2835
|
|
|
2752
2836
|
// 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; } };
|
|
2837
|
+
globalThis.__pulse = { trackHold, armExpendable, disarmExpendable, beginHover, endHover, state, moduleCard, runSlashCommand, syncModuleComposer, openCore: (...args) => openCore(...args), get core() { return core; } };
|
|
2754
2838
|
|
|
2755
2839
|
els.thread.addEventListener('wheel', (event) => trackHold(event.deltaY > 0), { passive: true });
|
|
2756
2840
|
let touchY = null;
|
|
@@ -2825,10 +2909,59 @@ function setConnection(value) {
|
|
|
2825
2909
|
}
|
|
2826
2910
|
if (typeof ResizeObserver === 'function' && els.composer) new ResizeObserver(placeToast).observe(els.composer);
|
|
2827
2911
|
window.addEventListener?.('resize', placeToast);
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
stream
|
|
2831
|
-
stream
|
|
2912
|
+
let stream = null;
|
|
2913
|
+
function connectStream() {
|
|
2914
|
+
stream?.close?.();
|
|
2915
|
+
stream = new EventSource(`/api/events?since=${state.lastSequence}`);
|
|
2916
|
+
stream.onopen = () => { setConnection('live'); replaying = false; };
|
|
2917
|
+
stream.onerror = () => setConnection('reconnecting');
|
|
2918
|
+
stream.onmessage = ({ data }) => renderEvent(JSON.parse(data));
|
|
2919
|
+
}
|
|
2920
|
+
connectStream();
|
|
2921
|
+
|
|
2922
|
+
// Everything the thread on screen knows that belongs to ONE conversation. Switching used to
|
|
2923
|
+
// reload the page, which is the brute way to be sure none of this was left over — and it cost a
|
|
2924
|
+
// whole boot: every fetch again, every panel again, the stream again. Repainting in place is
|
|
2925
|
+
// only safe while this list is complete, so it is written out rather than guessed at.
|
|
2926
|
+
function forgetConversation() {
|
|
2927
|
+
state.seen.clear();
|
|
2928
|
+
state.userMessages.clear();
|
|
2929
|
+
state.ratings.clear();
|
|
2930
|
+
state.ratingNodes.clear();
|
|
2931
|
+
state.running.clear();
|
|
2932
|
+
state.plansRunning.clear();
|
|
2933
|
+
state.agentStats.clear();
|
|
2934
|
+
touched.clear();
|
|
2935
|
+
state.failures.length = 0;
|
|
2936
|
+
state.pending.length = 0;
|
|
2937
|
+
state.replyTo = null;
|
|
2938
|
+
state.lastSender = null;
|
|
2939
|
+
state.lastSequence = 0;
|
|
2940
|
+
state.brakeArmed = false;
|
|
2941
|
+
els.column.replaceChildren();
|
|
2942
|
+
renderReplyQuote();
|
|
2943
|
+
paintMotherBadge();
|
|
2944
|
+
updateStopAll();
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// The new conversation, painted where the old one was. The room, the crew and the archive do not
|
|
2948
|
+
// change between conversations — only the transcript does.
|
|
2949
|
+
async function paintConversation(payload) {
|
|
2950
|
+
replaying = true;
|
|
2951
|
+
forgetConversation();
|
|
2952
|
+
chats.list = payload.chats?.chats ?? chats.list;
|
|
2953
|
+
chats.active = payload.chats?.active ?? chats.active;
|
|
2954
|
+
renderChats();
|
|
2955
|
+
const here = chats.list.find((chat) => chat.id === chats.active);
|
|
2956
|
+
const mark = document.querySelector('.project.chat-here');
|
|
2957
|
+
if (mark) mark.textContent = here && chats.list.length > 1 ? here.title : '';
|
|
2958
|
+
for (const event of payload.events ?? []) renderEvent(event);
|
|
2959
|
+
renderAgents();
|
|
2960
|
+
renderPicker();
|
|
2961
|
+
renderOnboarding();
|
|
2962
|
+
scrollToEnd();
|
|
2963
|
+
connectStream();
|
|
2964
|
+
}
|
|
2832
2965
|
|
|
2833
2966
|
/* ---------- composer ---------- */
|
|
2834
2967
|
|
|
@@ -2850,12 +2983,52 @@ els.input.addEventListener('input', autosize);
|
|
|
2850
2983
|
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
2851
2984
|
autosize();
|
|
2852
2985
|
|
|
2986
|
+
// /module has its own visual language before it ever reaches an agent. The class is also the
|
|
2987
|
+
// animation trigger: it is added once when the prefix becomes a command, and removed when the
|
|
2988
|
+
// human edits away from it or the message leaves the composer.
|
|
2989
|
+
const MODULE_COMMAND = /^\/module(?:\s|$)/i;
|
|
2990
|
+
// A refusal that only flashes is a refusal the room forgets. `/module` needs #2 because writing a
|
|
2991
|
+
// module is writing a file, so the answer lands in the thread where every other decision of the
|
|
2992
|
+
// room lands — and it carries the way out: the button that arms the mode, without arming it.
|
|
2993
|
+
function moduleModeNotice() {
|
|
2994
|
+
const node = el('div', 'system alert module-notice');
|
|
2995
|
+
node.append(el('b', null, t('MU/TH/UR › ')));
|
|
2996
|
+
node.append(t('/module writes a file, so it needs #2 CREATE. Your request is still in the composer.'));
|
|
2997
|
+
const arm = el('button', 'cmd', t('ARM #2 CREATE'));
|
|
2998
|
+
arm.type = 'button';
|
|
2999
|
+
// The human arms it, here as everywhere: the command asked, the click grants.
|
|
3000
|
+
arm.addEventListener('click', () => { setMode(2, { wink: true }); els.input.focus(); });
|
|
3001
|
+
node.append(arm);
|
|
3002
|
+
removeEmpty();
|
|
3003
|
+
els.column.append(node);
|
|
3004
|
+
node.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function askForModuleMode() {
|
|
3008
|
+
toast(t('MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.'));
|
|
3009
|
+
}
|
|
3010
|
+
function syncModuleComposer() {
|
|
3011
|
+
const active = MODULE_COMMAND.test(els.input.value.trimStart());
|
|
3012
|
+
if (state.moduleCommand === active) return;
|
|
3013
|
+
state.moduleCommand = active;
|
|
3014
|
+
els.composer.classList.toggle('module-command', active);
|
|
3015
|
+
updateCrewLabel();
|
|
3016
|
+
updatePlaceholder();
|
|
3017
|
+
if (active && !state.create) askForModuleMode();
|
|
3018
|
+
}
|
|
3019
|
+
els.input.addEventListener('input', syncModuleComposer);
|
|
3020
|
+
syncModuleComposer();
|
|
3021
|
+
|
|
2853
3022
|
/* ---------- labels inside the field: @agent mentions and /commands ---------- */
|
|
2854
3023
|
|
|
2855
3024
|
function allCommands() { return [...CLIENT_COMMANDS, ...(state.commands ?? [])]; }
|
|
2856
3025
|
function knownAgentIds() { return [...state.agents.keys()]; }
|
|
2857
3026
|
function escapeHtml(text) { return text.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); }
|
|
2858
3027
|
|
|
3028
|
+
// The layer that draws over the field. Its one law: it decorates the characters that are there
|
|
3029
|
+
// and never adds, removes or re-typesets one. The caret belongs to the textarea underneath and is
|
|
3030
|
+
// placed by the textarea's own metrics — so the instant this layer writes a word the human did
|
|
3031
|
+
// not type, or draws it in another font, the two disagree and the caret lands inside a pill.
|
|
2859
3032
|
function renderHighlight() {
|
|
2860
3033
|
const text = els.input.value;
|
|
2861
3034
|
const agents = knownAgentIds();
|
|
@@ -2864,7 +3037,11 @@ function renderHighlight() {
|
|
|
2864
3037
|
const key = name.toLowerCase();
|
|
2865
3038
|
if (sigil === '#') {
|
|
2866
3039
|
if (!/^[0-4]$/.test(name)) return whole;
|
|
2867
|
-
|
|
3040
|
+
// Only what was typed. This used to spell the mode out — `#2` drawn as `#2 CREATE` — and
|
|
3041
|
+
// every character after it sat seven places to the right of its own caret. The name of the
|
|
3042
|
+
// mode is already on the chip above the field; here the colour is enough.
|
|
3043
|
+
const chip = `<span class="chip mode m${name}" title="${escapeHtml(MODES[Number(name)].label)}">#${name}</span>`;
|
|
3044
|
+
return `${lead}${chip}`;
|
|
2868
3045
|
}
|
|
2869
3046
|
if (sigil === '!') {
|
|
2870
3047
|
if (!/\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?)?$/.test(name)) return whole;
|
|
@@ -2961,6 +3138,7 @@ function pickMenu(index = menu.index) {
|
|
|
2961
3138
|
els.input.setSelectionRange(caret, caret);
|
|
2962
3139
|
closeMenu();
|
|
2963
3140
|
autosize();
|
|
3141
|
+
syncModuleComposer();
|
|
2964
3142
|
els.input.focus();
|
|
2965
3143
|
}
|
|
2966
3144
|
els.input.addEventListener('input', () => { menu.index = 0; renderMenu(); });
|
|
@@ -2982,8 +3160,17 @@ async function runSlashCommand(text) {
|
|
|
2982
3160
|
const name = match[1].toLowerCase();
|
|
2983
3161
|
const rest = (match[2] ?? '').trim();
|
|
2984
3162
|
if (name === 'stopall') { await stopAll(); return { handled: true }; }
|
|
2985
|
-
if (name === 'create' || name === 'image') {
|
|
2986
|
-
if (!rest) {
|
|
3163
|
+
if (name === 'create' || name === 'image' || name === 'module') {
|
|
3164
|
+
if (!rest) {
|
|
3165
|
+
toast(name === 'module'
|
|
3166
|
+
? t('MU/TH/UR › /module needs a job, e.g. "/module summarize today’s commits".')
|
|
3167
|
+
: t('MU/TH/UR › /{name} needs a request after it, e.g. "/{name} a poster for the launch".', { name }));
|
|
3168
|
+
return { handled: true };
|
|
3169
|
+
}
|
|
3170
|
+
if (name === 'module' && !state.create) {
|
|
3171
|
+
moduleModeNotice();
|
|
3172
|
+
return { handled: true, preserve: true };
|
|
3173
|
+
}
|
|
2987
3174
|
let target = els.target.value;
|
|
2988
3175
|
if (name === 'image') {
|
|
2989
3176
|
const capable = [...state.agents.values()].filter((agent) => agent.ready && state.capabilities[agent.id]?.scopes?.imageGen?.enabled).map((agent) => agent.id);
|
|
@@ -2996,6 +3183,13 @@ async function runSlashCommand(text) {
|
|
|
2996
3183
|
}
|
|
2997
3184
|
}
|
|
2998
3185
|
if (!state.create) setMode(2);
|
|
3186
|
+
if (name === 'module') {
|
|
3187
|
+
return {
|
|
3188
|
+
handled: false,
|
|
3189
|
+
target,
|
|
3190
|
+
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 }),
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
2999
3193
|
return { handled: false, text: rest, target };
|
|
3000
3194
|
}
|
|
3001
3195
|
const known = allCommands().find((item) => item.name === name);
|
|
@@ -3093,6 +3287,7 @@ function updateCrewLabel() {
|
|
|
3093
3287
|
const order = state.ash && state.ashInstalled;
|
|
3094
3288
|
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
3289
|
: state.mode === 0 ? t('HUMAN · GHOST ›')
|
|
3290
|
+
: state.moduleCommand ? (state.create ? 'MU/TH/UR · MODULE · CREATE ›' : t('MU/TH/UR · MODULE · NEEDS #2 ›'))
|
|
3096
3291
|
: order ? (state.create ? 'MU/TH/UR · ASH · CREATE ›' : 'MU/TH/UR · ASH ›')
|
|
3097
3292
|
: state.create ? t('HUMAN · CREATE ›')
|
|
3098
3293
|
: state.expendable ? t('CREW · EXPENDABLE ›') : t('HUMAN ›');
|
|
@@ -3206,6 +3401,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3206
3401
|
els.input.value = '';
|
|
3207
3402
|
els.composer.classList.remove('stopall');
|
|
3208
3403
|
autosize();
|
|
3404
|
+
syncModuleComposer();
|
|
3209
3405
|
await stopAll();
|
|
3210
3406
|
return;
|
|
3211
3407
|
}
|
|
@@ -3235,7 +3431,13 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3235
3431
|
els.input.disabled = true;
|
|
3236
3432
|
const result = await runSlashCommand(text).catch((error) => { toast(t('The command failed: {error}', { error: error.message })); return { handled: true }; });
|
|
3237
3433
|
els.input.disabled = false;
|
|
3238
|
-
if (result.handled) {
|
|
3434
|
+
if (result.handled) {
|
|
3435
|
+
if (!result.preserve) els.input.value = '';
|
|
3436
|
+
autosize();
|
|
3437
|
+
syncModuleComposer();
|
|
3438
|
+
els.input.focus();
|
|
3439
|
+
return;
|
|
3440
|
+
}
|
|
3239
3441
|
outgoing = result.text ?? text;
|
|
3240
3442
|
target = result.target ?? target;
|
|
3241
3443
|
}
|
|
@@ -3259,6 +3461,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3259
3461
|
renderPendingAttachments();
|
|
3260
3462
|
resetModeAfterSend(); // one mode per message; standing leases fall back to #2, everyone else to #1
|
|
3261
3463
|
autosize();
|
|
3464
|
+
syncModuleComposer();
|
|
3262
3465
|
}
|
|
3263
3466
|
} catch (error) {
|
|
3264
3467
|
toast(t('Could not reach MADRE: {error}', { error: error.message }));
|
|
@@ -3844,8 +4047,30 @@ function metrics(box, rows) {
|
|
|
3844
4047
|
}
|
|
3845
4048
|
|
|
3846
4049
|
// 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
|
-
//
|
|
4050
|
+
// does, what it touches, how it is doing — and then its actions, with the main switch last. A DEV
|
|
4051
|
+
// module also offers uninstall there; a module MADRE ships never does.
|
|
4052
|
+
function removeModuleButton(item, { className = 'remove-module', text = t('UNINSTALL') } = {}) {
|
|
4053
|
+
const remove = el('button', className, text);
|
|
4054
|
+
remove.type = 'button';
|
|
4055
|
+
remove.title = t('Delete module file {file}', { file: item.file });
|
|
4056
|
+
remove.addEventListener('click', async () => {
|
|
4057
|
+
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;
|
|
4058
|
+
remove.disabled = true;
|
|
4059
|
+
try {
|
|
4060
|
+
const response = await fetch(`/api/extensions/${item.id}`, { method: 'DELETE' });
|
|
4061
|
+
const payload = await response.json().catch(() => ({}));
|
|
4062
|
+
if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`);
|
|
4063
|
+
modules.items = payload.extensions ?? modules.items;
|
|
4064
|
+
toast(t('MU/TH/UR › {name} removed.', { name: item.name }));
|
|
4065
|
+
renderModules();
|
|
4066
|
+
} catch (error) {
|
|
4067
|
+
toast(t('It was not removed: {error}', { error: error.message }));
|
|
4068
|
+
remove.disabled = false;
|
|
4069
|
+
}
|
|
4070
|
+
});
|
|
4071
|
+
return remove;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
3849
4074
|
function cardShell(item, { on, state: stateText }) {
|
|
3850
4075
|
const card = el('article', `module-card${on ? '' : ' off'}`);
|
|
3851
4076
|
card.id = `module-${item.id}`;
|
|
@@ -3933,9 +4158,10 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
3933
4158
|
}
|
|
3934
4159
|
|
|
3935
4160
|
// FLOOR 4 · the module's own: what it reads, what it lets you set.
|
|
3936
|
-
// FLOOR 5 ·
|
|
4161
|
+
// FLOOR 5 · actions at the bottom: DEV uninstall first, the main switch last.
|
|
3937
4162
|
const panel = el('div', 'card-panel');
|
|
3938
4163
|
const actions = el('div', 'actions');
|
|
4164
|
+
if (item.external) actions.append(removeModuleButton(item));
|
|
3939
4165
|
card.append(panel, actions);
|
|
3940
4166
|
return { card, panel, actions };
|
|
3941
4167
|
}
|
|
@@ -4012,6 +4238,10 @@ modules.file?.addEventListener('change', async () => {
|
|
|
4012
4238
|
modules.items = result.extensions ?? modules.items;
|
|
4013
4239
|
renderModules();
|
|
4014
4240
|
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.')}`);
|
|
4241
|
+
// A key the contract does not answer to. Not fatal — a module written for a newer MADRE may
|
|
4242
|
+
// carry fields this one has not learned — but a misspelling is far likelier, and it used to
|
|
4243
|
+
// install in silence and leave the author wondering why their card came out empty.
|
|
4244
|
+
warnUnknownModuleFields(result.installed);
|
|
4015
4245
|
} catch (error) { toast(t('The module was not installed: {error}', { error: error.message })); }
|
|
4016
4246
|
finally { modules.add.disabled = false; }
|
|
4017
4247
|
});
|
|
@@ -4403,17 +4633,7 @@ function renderModulesDev() {
|
|
|
4403
4633
|
for (const item of yours) {
|
|
4404
4634
|
const chip = el('span', 'dev-chip');
|
|
4405
4635
|
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);
|
|
4636
|
+
chip.append(removeModuleButton(item, { className: 'act-link', text: t('REMOVE') }));
|
|
4417
4637
|
list.append(chip);
|
|
4418
4638
|
}
|
|
4419
4639
|
body.append(list);
|
|
@@ -4487,7 +4707,7 @@ function openAgentPop(id, anchor) {
|
|
|
4487
4707
|
const head = el('div', 'head');
|
|
4488
4708
|
head.append(avatar(id, { size: 24 }));
|
|
4489
4709
|
head.append(el('b', null, agent.label));
|
|
4490
|
-
head.append(el('span', 'vendor',
|
|
4710
|
+
head.append(el('span', 'vendor', vendorWord(id)));
|
|
4491
4711
|
pop.append(head);
|
|
4492
4712
|
const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
|
|
4493
4713
|
big.append(el('small', null, official === null ? t('budget tokens · local 5h window') : t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) }) + (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset'))));
|