@jossuealcala/madre 0.5.1 → 0.6.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 +95 -0
- package/package.json +1 -1
- package/public/abduction.js +384 -0
- package/public/app.js +1174 -78
- package/public/assets/abduction-cat-blink.png +0 -0
- package/public/assets/abduction-cat-control-left.png +0 -0
- package/public/assets/abduction-cat-control-right.png +0 -0
- package/public/assets/abduction-cat.png +0 -0
- package/public/es.js +182 -16
- package/public/index.html +48 -3
- package/public/routing.js +74 -0
- package/public/styles.css +390 -22
- package/src/choices.mjs +64 -0
- package/src/file-ops.mjs +45 -0
- package/src/modules/ash-policy.mjs +67 -0
- package/src/modules/ash.mjs +1 -1
- package/src/modules/choices.mjs +34 -0
- package/src/modules/index.mjs +2 -1
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/ports.mjs +71 -0
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +13 -1
- package/src/room.mjs +91 -9
- package/src/server.mjs +127 -7
package/public/app.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { archiveQuestion, modeAsked } from './routing.js';
|
|
2
|
+
import { calledByName, abduct } from './abduction.js';
|
|
1
3
|
import { brandOf } from './brands.js';
|
|
2
4
|
import { CONDITIONS, allConditions, detectPlatform, diagnose, fixesFor, PLATFORMS, searchConditions } from './troubleshooting.js';
|
|
3
5
|
import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
|
|
@@ -37,6 +39,9 @@ const els = {
|
|
|
37
39
|
replyQuote: document.querySelector('#reply-quote'),
|
|
38
40
|
field: document.querySelector('.field'),
|
|
39
41
|
slashMenu: document.querySelector('#slash-menu'),
|
|
42
|
+
freeOffer: document.querySelector('#free-offer'),
|
|
43
|
+
settled: document.querySelector('#settled'),
|
|
44
|
+
openq: document.querySelector('#openq'),
|
|
40
45
|
attach: document.querySelector('#attach'),
|
|
41
46
|
createToggle: document.querySelector('#create-toggle'),
|
|
42
47
|
ashToggle: document.querySelector('#ash-toggle'),
|
|
@@ -146,6 +151,35 @@ const openFailures = () => state.failures.filter((failure) => !failure.recovered
|
|
|
146
151
|
const VENDOR_WORDS = { 'the room · local': t('the room · local'), local: t('local') };
|
|
147
152
|
const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDOR_WORDS[said] ?? said; };
|
|
148
153
|
|
|
154
|
+
// What an agent can do, said two ways. On its card each one is a tag — a word standing alone —
|
|
155
|
+
// and inside a sentence it has to conjugate: «puede crea» was the button's imperative dropped
|
|
156
|
+
// into a phrase that needed an infinitive. Declared up here because the transcript is replayed at
|
|
157
|
+
// the top of this file, long before half of it has run: `renderLease` reached for these while
|
|
158
|
+
// they were still in their dead zone, the try around it swallowed the error, and every CREATE
|
|
159
|
+
// strip in the history simply never appeared.
|
|
160
|
+
const CAP_LABELS = { read: t('read'), imageIn: t('image in'), write: t('create'), imageGen: t('image gen'), web: t('web') };
|
|
161
|
+
const CAP_VERBS = { read: t('read the project'), imageIn: t('take images'), write: t('create files'), imageGen: t('generate images'), web: t('reach the web') };
|
|
162
|
+
const capVerb = (scope) => CAP_VERBS[scope] ?? CAP_LABELS[scope] ?? scope;
|
|
163
|
+
|
|
164
|
+
// MODULES' own handles. Up here with the rest of the module's state because the transcript is
|
|
165
|
+
// replayed near the top of this file and an installation line in the history writes to it: four
|
|
166
|
+
// renderers reached for this while it was still in its dead zone, and every install and removal
|
|
167
|
+
// ever recorded vanished from the history without a word.
|
|
168
|
+
const modules = {
|
|
169
|
+
dialog: document.querySelector('#modules'),
|
|
170
|
+
button: document.querySelector('#modules-button'),
|
|
171
|
+
count: document.querySelector('#modules-count'),
|
|
172
|
+
close: document.querySelector('#modules-close'),
|
|
173
|
+
list: document.querySelector('#modules-list'),
|
|
174
|
+
add: document.querySelector('#modules-add'),
|
|
175
|
+
file: document.querySelector('#modules-file'),
|
|
176
|
+
note: document.querySelector('#modules-note'),
|
|
177
|
+
items: [],
|
|
178
|
+
installing: null,
|
|
179
|
+
logs: new Map(), // id -> array of lines
|
|
180
|
+
confirming: null,
|
|
181
|
+
};
|
|
182
|
+
|
|
149
183
|
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
150
184
|
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
151
185
|
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
@@ -175,6 +209,31 @@ const formatTokens = (value) => {
|
|
|
175
209
|
return String(value);
|
|
176
210
|
};
|
|
177
211
|
|
|
212
|
+
// Characters to tokens, next to the other formatters. The same estimate the room uses for text
|
|
213
|
+
// nobody was charged for, kept here because the page cannot import from the server:
|
|
214
|
+
// src/room/economy.mjs holds the other copy and a test keeps the two equal.
|
|
215
|
+
//
|
|
216
|
+
// It is an estimate and it is labelled as one. What this room MEASURES — what MADRE wrote
|
|
217
|
+
// against what the CLI was charged for reading — is a different quantity: the CLIs bill their
|
|
218
|
+
// own system prompt, their own tools and every file they open, so in this room that ratio was
|
|
219
|
+
// 0.62 and called a 14,356-character briefing 23,155 tokens.
|
|
220
|
+
const CH_PER_TOKEN = 3.5;
|
|
221
|
+
const tokensFor = (chars) => Math.round((Number(chars) || 0) / CH_PER_TOKEN);
|
|
222
|
+
|
|
223
|
+
// Memories burn as stars burn, and a star's colour is its class. A decision is a yellow dwarf,
|
|
224
|
+
// the steady kind a system is built around. A fact is a white dwarf: cold, dense, settled. A
|
|
225
|
+
// preference is a red dwarf, dim and personal and very long lived. A question is a blue dwarf,
|
|
226
|
+
// the hottest thing in the sky and the least settled. Declared here and not down in NOSTROMO:
|
|
227
|
+
// the first replay paints memory chips before that section of the file has been evaluated.
|
|
228
|
+
const MEMORY_COLORS = { decision: '#ffdc3c', fact: '#dfeeff', preference: '#5cf07a', question: '#3dc6ff', aberration: '#b14cff' };
|
|
229
|
+
|
|
230
|
+
// A memory's kind is an id in the ledger and a word on a screen. Only the word is said in the
|
|
231
|
+
// room's language: what was written down never moves. Declared one by one, so the catalogue's
|
|
232
|
+
// guard sees all five and an id nobody translated cannot slip past as itself. Up here with the
|
|
233
|
+
// colours and for the same reason: the first replay prints the word before NOSTROMO is evaluated.
|
|
234
|
+
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
|
|
235
|
+
const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
|
|
236
|
+
|
|
178
237
|
const formatTime = (iso) => {
|
|
179
238
|
const date = iso ? new Date(iso) : new Date();
|
|
180
239
|
return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
@@ -222,6 +281,38 @@ function scrollToEnd() {
|
|
|
222
281
|
els.thread.scrollTop = els.thread.scrollHeight;
|
|
223
282
|
}
|
|
224
283
|
|
|
284
|
+
// The thread is pinned to its own end, and anything that changes how tall it is has to put it
|
|
285
|
+
// back there. Opening the browser beside it, dragging the divider, closing it again: each one
|
|
286
|
+
// re-lays out the column and leaves the last message hanging below the fold, which reads as the
|
|
287
|
+
// conversation having been lost. The observer is the honest fix — it does not matter what moved,
|
|
288
|
+
// only that the height is no longer the one the scroll position was computed against.
|
|
289
|
+
// Whether the human is reading the end or has gone back for something. Only THEIR scrolling
|
|
290
|
+
// decides it: a programmatic scroll fires the same event, so reading the position back during a
|
|
291
|
+
// relayout turned every adjustment into "they scrolled away" and the thread stopped following.
|
|
292
|
+
let pinnedToEnd = true;
|
|
293
|
+
const measurePin = () => {
|
|
294
|
+
pinnedToEnd = els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 120;
|
|
295
|
+
};
|
|
296
|
+
for (const how of ['wheel', 'touchmove', 'keydown']) els.thread?.addEventListener(how, () => setTimeout(measurePin, 90), { passive: true });
|
|
297
|
+
|
|
298
|
+
// The thread is pinned to its own end, and anything that changes how tall it is has to put it
|
|
299
|
+
// back there. Opening the browser beside it, dragging the divider, closing it again: each one
|
|
300
|
+
// re-lays out the column and leaves the last message hanging below the fold, which reads as the
|
|
301
|
+
// conversation having been lost.
|
|
302
|
+
//
|
|
303
|
+
// Instant, not smooth: the column is mid-relayout and an animated scroll is interrupted by the
|
|
304
|
+
// next frame of it, which is how the thread ended up parked hundreds of pixels short of its end.
|
|
305
|
+
function keepPinned() {
|
|
306
|
+
if (!pinnedToEnd || !els.thread) return;
|
|
307
|
+
els.thread.scrollTo({ top: els.thread.scrollHeight, behavior: 'instant' });
|
|
308
|
+
}
|
|
309
|
+
if (typeof ResizeObserver === 'function' && els.thread) {
|
|
310
|
+
const watch = new ResizeObserver(() => { keepPinned(); requestAnimationFrame(keepPinned); });
|
|
311
|
+
watch.observe(els.thread);
|
|
312
|
+
if (els.column) watch.observe(els.column);
|
|
313
|
+
if (els.composer) watch.observe(els.composer);
|
|
314
|
+
}
|
|
315
|
+
|
|
225
316
|
/* ---------- safe Markdown (DOM only, no HTML injection) ---------- */
|
|
226
317
|
|
|
227
318
|
const SAFE_URL = /^https?:\/\//i;
|
|
@@ -466,6 +557,361 @@ const ripley = {
|
|
|
466
557
|
current: null, // project-relative path of the page on screen
|
|
467
558
|
root: 'project',
|
|
468
559
|
};
|
|
560
|
+
// The servers answering on this computer, and the frame that shows one.
|
|
561
|
+
//
|
|
562
|
+
// A project file and a running server are different things to put behind glass, and RIPLEY
|
|
563
|
+
// treats them differently on purpose:
|
|
564
|
+
//
|
|
565
|
+
// · a file is served from MADRE's own origin, so a script inside it would be same-origin with
|
|
566
|
+
// the room. That frame keeps `allow-scripts` alone and no origin of its own, as it always has.
|
|
567
|
+
// · a server answers on another port, which is another origin. The same-origin policy already
|
|
568
|
+
// separates it from the room — and MADRE sets no cookies, so there is nothing of the room's
|
|
569
|
+
// for it to reach. Denying it its own origin would only break it: no storage, no session,
|
|
570
|
+
// a blank page where the human expected their app. So it keeps its origin and its forms,
|
|
571
|
+
// and still cannot navigate the room away or open the ship.
|
|
572
|
+
//
|
|
573
|
+
// What is lost cross-origin is the bridge: MADRE cannot inject its error reporter into somebody
|
|
574
|
+
// else's page, so the error strip stays quiet here. The viewer says so rather than looking broken.
|
|
575
|
+
function ripleyGripOn(grip, side, owner) {
|
|
576
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
577
|
+
event.preventDefault();
|
|
578
|
+
const stage = owner.stage;
|
|
579
|
+
const frame = owner.frame;
|
|
580
|
+
if (!stage || !frame) return;
|
|
581
|
+
const startX = event.clientX;
|
|
582
|
+
const startWidth = frame.getBoundingClientRect().width;
|
|
583
|
+
grip.setPointerCapture(event.pointerId);
|
|
584
|
+
stage.classList.add('dragging');
|
|
585
|
+
const move = (moving) => {
|
|
586
|
+
const delta = (moving.clientX - startX) * (side === 'right' ? 2 : -2);
|
|
587
|
+
const width = Math.max(280, Math.min(stage.clientWidth, Math.round(startWidth + delta)));
|
|
588
|
+
stage.style.setProperty('--stage', `${width}px`);
|
|
589
|
+
stage.dataset.sized = 'true';
|
|
590
|
+
for (const button of owner.sizes?.children ?? []) button.setAttribute('aria-pressed', 'false');
|
|
591
|
+
};
|
|
592
|
+
grip.addEventListener('pointermove', move);
|
|
593
|
+
grip.addEventListener('pointerup', () => {
|
|
594
|
+
grip.removeEventListener('pointermove', move);
|
|
595
|
+
stage.classList.remove('dragging');
|
|
596
|
+
}, { once: true });
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/* ---------- the browser: RIPLEY's other surface ---------- */
|
|
601
|
+
//
|
|
602
|
+
// A file and a running server are different enough to deserve different windows. The viewer reads
|
|
603
|
+
// what is written down; this looks at what is answering. Both belong to RIPLEY, and each has its
|
|
604
|
+
// own way in — a button on the rail beside the conversations, the way the files panel does.
|
|
605
|
+
//
|
|
606
|
+
// ONE FRAME, MANY TABS. This is the whole memory design and it is deliberate: a browser engine
|
|
607
|
+
// gives every live iframe its own renderer process, so ten tabs would be ten of them sitting in
|
|
608
|
+
// the human's RAM while they look at one. Here a tab is an address and a title, nothing more, and
|
|
609
|
+
// exactly one of them is ever alive. Switching re-navigates the single frame. The cost is that a
|
|
610
|
+
// background tab does not keep its page state; the gain is that twenty tabs weigh what one does,
|
|
611
|
+
// which on a machine already running four coding agents is not a small thing.
|
|
612
|
+
const browser = {
|
|
613
|
+
dialog: document.querySelector('#browser'),
|
|
614
|
+
button: document.querySelector('#browser-button'),
|
|
615
|
+
close: document.querySelector('#browser-close'),
|
|
616
|
+
body: document.querySelector('#browser-body'),
|
|
617
|
+
tabsBar: document.querySelector('#browser-tabs'),
|
|
618
|
+
back: document.querySelector('#browser-back'),
|
|
619
|
+
reload: document.querySelector('#browser-reload'),
|
|
620
|
+
servers: document.querySelector('#browser-servers'),
|
|
621
|
+
portsMenu: document.querySelector('#browser-ports-menu'),
|
|
622
|
+
where: document.querySelector('#browser-where'),
|
|
623
|
+
sizes: document.querySelector('#browser-sizes'),
|
|
624
|
+
tabs: [],
|
|
625
|
+
active: null,
|
|
626
|
+
frame: null,
|
|
627
|
+
stage: null,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const BROWSER_WIDTHS = [['AUTO', null], ['390', 390], ['768', 768], ['1280', 1280]];
|
|
631
|
+
const browserTab = () => browser.tabs.find((tab) => tab.id === browser.active) ?? null;
|
|
632
|
+
|
|
633
|
+
// Loopback is always allowed. Anywhere else is the web, and the web is only reachable when the
|
|
634
|
+
// human turned it on for RIPLEY — the room says what a module may reach before it reaches it.
|
|
635
|
+
// Asked again for every address that would leave this computer, never remembered. The switch
|
|
636
|
+
// lives in MODULES and the human can move it with this panel open; a permission cached when the
|
|
637
|
+
// panel opened would keep refusing after they turned it on, and a switch that is not believed is
|
|
638
|
+
// worse than no switch. Loopback needs no question and gets none.
|
|
639
|
+
async function browserAllows(url) {
|
|
640
|
+
let parsed = null;
|
|
641
|
+
try { parsed = new URL(url); } catch { return { ok: false, why: t('That is not an address.') }; }
|
|
642
|
+
if (!/^https?:$/.test(parsed.protocol)) return { ok: false, why: t('Only http and https.') };
|
|
643
|
+
if (['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) return { ok: true, local: true };
|
|
644
|
+
try {
|
|
645
|
+
const live = await fetch('/api/state').then((response) => response.json());
|
|
646
|
+
state.ripleyWeb = Boolean(live?.ripleyWeb);
|
|
647
|
+
} catch { /* keep whatever the page already knew */ }
|
|
648
|
+
if (!state.ripleyWeb) return { ok: false, why: t('RIPLEY reaches this computer only · turn the web on in MODULES to open an address outside it') };
|
|
649
|
+
return { ok: true, local: false };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function browserTitleFor(url) {
|
|
653
|
+
try {
|
|
654
|
+
const parsed = new URL(url);
|
|
655
|
+
if (!/^https?:$/.test(parsed.protocol)) return t('new tab');
|
|
656
|
+
return ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname) ? `:${parsed.port || 80}` : parsed.hostname.replace(/^www\./, '');
|
|
657
|
+
} catch { return t('new tab'); }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function browserRenderTabs() {
|
|
661
|
+
if (!browser.tabsBar) return;
|
|
662
|
+
browser.tabsBar.replaceChildren();
|
|
663
|
+
for (const tab of browser.tabs) {
|
|
664
|
+
const node = el('button', `browser-tab${tab.id === browser.active ? ' on' : ''}`);
|
|
665
|
+
node.type = 'button';
|
|
666
|
+
node.setAttribute('role', 'tab');
|
|
667
|
+
node.setAttribute('aria-selected', String(tab.id === browser.active));
|
|
668
|
+
node.title = tab.url === 'about:blank' ? t('new tab') : tab.url;
|
|
669
|
+
node.append(el('span', 'tab-title', tab.title));
|
|
670
|
+
const shut = el('span', 'tab-close', '×');
|
|
671
|
+
shut.addEventListener('click', (event) => { event.stopPropagation(); browserCloseTab(tab.id); });
|
|
672
|
+
node.append(shut);
|
|
673
|
+
node.addEventListener('click', () => browserSelect(tab.id));
|
|
674
|
+
browser.tabsBar.append(node);
|
|
675
|
+
}
|
|
676
|
+
const add = el('button', 'browser-newtab', '+');
|
|
677
|
+
add.type = 'button';
|
|
678
|
+
add.title = t('New tab');
|
|
679
|
+
add.addEventListener('click', () => { browserNewTab(); void browserServers(); });
|
|
680
|
+
browser.tabsBar.append(add);
|
|
681
|
+
browser.tabsBar.hidden = false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function browserOpen(url, { tab = null } = {}) {
|
|
685
|
+
const allowed = await browserAllows(url);
|
|
686
|
+
if (!allowed.ok) { toast(allowed.why); return; }
|
|
687
|
+
const target = tab ?? browserTab();
|
|
688
|
+
if (!target) { browserNewTab(url); return; }
|
|
689
|
+
// A page is entitled to refuse the glass, and most serious ones do. Asking first turns the
|
|
690
|
+
// browser's bare "refused to connect" into MADRE's own sentence and a real tab — the same
|
|
691
|
+
// answer the servers menu already gives for a local port that refuses.
|
|
692
|
+
if (!allowed.local) {
|
|
693
|
+
const verdict = await fetch(`/api/can-frame?url=${encodeURIComponent(url)}`).then((r) => r.json()).catch(() => null);
|
|
694
|
+
if (verdict && verdict.framable === false) {
|
|
695
|
+
target.url = url;
|
|
696
|
+
target.title = browserTitleFor(url);
|
|
697
|
+
if (browser.where) browser.where.textContent = url.replace(/^https?:\/\//, '');
|
|
698
|
+
browserRenderTabs();
|
|
699
|
+
browserRefused(url);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
target.url = url;
|
|
704
|
+
target.title = browserTitleFor(url);
|
|
705
|
+
if (target.history.at(-1) !== url) target.history.push(url);
|
|
706
|
+
if (browser.where) browser.where.textContent = url.replace(/^https?:\/\//, '');
|
|
707
|
+
if (browser.back) browser.back.disabled = target.history.length < 2;
|
|
708
|
+
if (browser.frame) browser.frame.src = url;
|
|
709
|
+
browserRenderTabs();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function browserNewTab(url = 'about:blank') {
|
|
713
|
+
const tab = { id: `t${Date.now()}${browser.tabs.length}`, title: browserTitleFor(url), url, history: [] };
|
|
714
|
+
browser.tabs.push(tab);
|
|
715
|
+
browser.active = tab.id;
|
|
716
|
+
browserMountFrame();
|
|
717
|
+
if (url !== 'about:blank') { void browserOpen(url, { tab }); return; }
|
|
718
|
+
if (browser.frame) browser.frame.src = 'about:blank';
|
|
719
|
+
if (browser.where) browser.where.textContent = '';
|
|
720
|
+
browserRenderTabs();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function browserSelect(id) {
|
|
724
|
+
if (browser.active === id) return;
|
|
725
|
+
browser.active = id;
|
|
726
|
+
const tab = browserTab();
|
|
727
|
+
browserMountFrame();
|
|
728
|
+
if (browser.where) browser.where.textContent = tab?.url && tab.url !== 'about:blank' ? tab.url.replace(/^https?:\/\//, '') : '';
|
|
729
|
+
if (browser.back) browser.back.disabled = (tab?.history.length ?? 0) < 2;
|
|
730
|
+
// The one frame is re-navigated rather than a second one being born: a tab that is not on
|
|
731
|
+
// screen costs an address and a title, and nothing else.
|
|
732
|
+
if (browser.frame) browser.frame.src = tab?.url ?? 'about:blank';
|
|
733
|
+
browserRenderTabs();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function browserCloseTab(id) {
|
|
737
|
+
const index = browser.tabs.findIndex((tab) => tab.id === id);
|
|
738
|
+
if (index < 0) return;
|
|
739
|
+
browser.tabs.splice(index, 1);
|
|
740
|
+
if (!browser.tabs.length) { browserHide(); return; }
|
|
741
|
+
if (browser.active === id) { browser.active = null; browserSelect(browser.tabs[Math.max(0, index - 1)].id); }
|
|
742
|
+
else browserRenderTabs();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// A page that refuses the glass, said inside the panel instead of by throwing a window at the
|
|
746
|
+
// human. The room's habit everywhere else is to offer and let them choose; opening a window on
|
|
747
|
+
// its own is the room choosing, and it drops them out of what they were doing.
|
|
748
|
+
function browserRefused(url, { why } = {}) {
|
|
749
|
+
if (!browser.body) return;
|
|
750
|
+
const box = el('div', 'browser-refused');
|
|
751
|
+
box.append(el('h4', null, t('{host} will not be framed', { host: browserTitleFor(url) })));
|
|
752
|
+
box.append(el('p', null, why ?? t('The site answers with a rule that forbids any page from putting it inside a frame. It is the standard defence against clickjacking, and RIPLEY neither can nor should override it.')));
|
|
753
|
+
const open = el('button', 'browser-go', t('OPEN IN A TAB'));
|
|
754
|
+
open.type = 'button';
|
|
755
|
+
open.addEventListener('click', () => window.open(url, '_blank', 'noopener'));
|
|
756
|
+
const copy = el('button', 'browser-copy', t('COPY THE ADDRESS'));
|
|
757
|
+
copy.type = 'button';
|
|
758
|
+
copy.addEventListener('click', () => { void navigator.clipboard?.writeText(url); toast(t('Address copied.')); });
|
|
759
|
+
const row = el('div', 'browser-refused-row');
|
|
760
|
+
row.append(open, copy);
|
|
761
|
+
box.append(row);
|
|
762
|
+
browser.body.replaceChildren(box);
|
|
763
|
+
browser.frame = null;
|
|
764
|
+
browser.stage = null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function browserSetStage(width) {
|
|
768
|
+
if (!browser.stage) return;
|
|
769
|
+
browser.stage.style.setProperty('--stage', width ? `${width}px` : '100%');
|
|
770
|
+
browser.stage.dataset.sized = String(Boolean(width));
|
|
771
|
+
for (const button of browser.sizes?.children ?? []) button.setAttribute('aria-pressed', String(Number(button.dataset.width || 0) === Number(width || 0)));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// The single frame, built when the window opens. A page keeps its own origin, which is what makes
|
|
775
|
+
// an app work, and still cannot steer the room or reach anything of the room's — MADRE sets no
|
|
776
|
+
// cookies, and every address here is a different origin from the room's own.
|
|
777
|
+
function browserMountFrame() {
|
|
778
|
+
if (!browser.body || browser.frame) return;
|
|
779
|
+
const stage = el('div', 'viewer-stage');
|
|
780
|
+
const frame = el('iframe', 'ripley');
|
|
781
|
+
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
|
|
782
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
783
|
+
frame.title = t('RIPLEY browser');
|
|
784
|
+
const left = el('div', 'stage-grip');
|
|
785
|
+
const right = el('div', 'stage-grip');
|
|
786
|
+
left.style.left = '0'; right.style.right = '0';
|
|
787
|
+
stage.append(left, frame, right);
|
|
788
|
+
browser.frame = frame;
|
|
789
|
+
browser.stage = stage;
|
|
790
|
+
ripleyGripOn(left, 'left', browser);
|
|
791
|
+
ripleyGripOn(right, 'right', browser);
|
|
792
|
+
browser.body.replaceChildren(stage);
|
|
793
|
+
if (browser.sizes && !browser.sizes.children.length) {
|
|
794
|
+
for (const [label, width] of BROWSER_WIDTHS) {
|
|
795
|
+
const button = el('button', null, label);
|
|
796
|
+
button.type = 'button';
|
|
797
|
+
button.dataset.width = String(width ?? 0);
|
|
798
|
+
button.addEventListener('click', () => browserSetStage(width));
|
|
799
|
+
browser.sizes.append(button);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
browserSetStage(null);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function browserShow() {
|
|
806
|
+
if (!browser.dialog) return;
|
|
807
|
+
browser.dialog.hidden = false;
|
|
808
|
+
document.querySelector('.shell')?.classList.add('with-browser');
|
|
809
|
+
browser.button?.setAttribute('aria-pressed', 'true');
|
|
810
|
+
if (!browser.tabs.length) browserNewTab();
|
|
811
|
+
else { browserMountFrame(); const tab = browserTab(); if (browser.frame && tab) browser.frame.src = tab.url; browserRenderTabs(); }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Closing drops the frame, and with it the renderer the page was using. The tabs survive as
|
|
815
|
+
// addresses — reopening puts the active one back without having kept a process alive meanwhile.
|
|
816
|
+
function browserHide() {
|
|
817
|
+
if (browser.dialog) browser.dialog.hidden = true;
|
|
818
|
+
document.querySelector('.shell')?.classList.remove('with-browser');
|
|
819
|
+
browser.button?.setAttribute('aria-pressed', 'false');
|
|
820
|
+
browser.body?.replaceChildren();
|
|
821
|
+
browser.frame = null;
|
|
822
|
+
browser.stage = null;
|
|
823
|
+
portsMenuShown(false);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Opening and closing the list in one place: the button carries the state, so it has to be told
|
|
827
|
+
// every time, and it is told nowhere else.
|
|
828
|
+
function portsMenuShown(open) {
|
|
829
|
+
if (browser.portsMenu) browser.portsMenu.hidden = !open;
|
|
830
|
+
browser.servers?.setAttribute('aria-expanded', String(open));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function browserServers() {
|
|
834
|
+
const menu = browser.portsMenu;
|
|
835
|
+
if (!menu) return;
|
|
836
|
+
if (!menu.hidden) { portsMenuShown(false); return; }
|
|
837
|
+
menu.replaceChildren(el('div', 'ports-note', t('READING…')));
|
|
838
|
+
portsMenuShown(true);
|
|
839
|
+
let ports = [];
|
|
840
|
+
try { ports = (await fetch('/api/ports').then((response) => response.json()))?.ports ?? []; }
|
|
841
|
+
catch { menu.replaceChildren(el('div', 'ports-note', t('COULD NOT READ THE PORTS'))); return; }
|
|
842
|
+
menu.replaceChildren();
|
|
843
|
+
// The lamp on the button keeps what the reading found, so the state survives the menu closing.
|
|
844
|
+
browser.servers?.setAttribute('data-live', ports.length ? 'yes' : 'no');
|
|
845
|
+
if (!ports.length) { menu.append(el('div', 'ports-note', t('NOTHING IS ANSWERING ON THIS COMPUTER · START YOUR SERVER AND OPEN THIS AGAIN'))); return; }
|
|
846
|
+
for (const found of ports) {
|
|
847
|
+
const row = el('button', 'ports-row');
|
|
848
|
+
row.type = 'button';
|
|
849
|
+
const what = `${found.command} · ${found.status}${found.html ? '' : ` · ${found.contentType ?? ''}`}`;
|
|
850
|
+
row.append(el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · refuses to be framed', { what }) : what));
|
|
851
|
+
if (found.framable === false) row.classList.add('refuses');
|
|
852
|
+
row.addEventListener('click', () => {
|
|
853
|
+
portsMenuShown(false);
|
|
854
|
+
// A server that sets frame-ancestors is right to, and RIPLEY cannot and should not override
|
|
855
|
+
// it. The honest move is a real tab, not a grey rectangle with a broken-document icon.
|
|
856
|
+
if (found.framable === false) {
|
|
857
|
+
const url = `http://127.0.0.1:${found.port}/`;
|
|
858
|
+
const current = browserTab();
|
|
859
|
+
if (!current || current.url !== 'about:blank') browserNewTab('about:blank');
|
|
860
|
+
const tab = browserTab();
|
|
861
|
+
if (tab) { tab.url = url; tab.title = `:${found.port}`; }
|
|
862
|
+
if (browser.where) browser.where.textContent = url.replace(/^https?:\/\//, '');
|
|
863
|
+
browserRenderTabs();
|
|
864
|
+
browserRefused(url, { why: t('This server answers with a rule that forbids any page from putting it inside a frame — your own dev server may be setting it without you noticing. RIPLEY neither can nor should override it.') });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const url = `http://127.0.0.1:${found.port}/`;
|
|
868
|
+
const current = browserTab();
|
|
869
|
+
if (current && current.url !== 'about:blank') browserNewTab(url); else void browserOpen(url);
|
|
870
|
+
});
|
|
871
|
+
menu.append(row);
|
|
872
|
+
}
|
|
873
|
+
menu.append(el('div', 'ports-note', t('ONLY WHAT LISTENS ON THIS COMPUTER AND ANSWERS HTTP · MADRE RUNS NOTHING TO FIND THEM')));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// The edge between the conversation and the browser. The dock is a grid column, so a drag just
|
|
877
|
+
// writes a width; the frame stops taking pointer events while it lasts, or the iframe swallows
|
|
878
|
+
// the drag the moment the cursor crosses into it.
|
|
879
|
+
const browserGrip = document.querySelector('#browser-grip');
|
|
880
|
+
browserGrip?.addEventListener('pointerdown', (event) => {
|
|
881
|
+
event.preventDefault();
|
|
882
|
+
const shell = document.querySelector('.shell');
|
|
883
|
+
if (!shell) return;
|
|
884
|
+
browserGrip.setPointerCapture(event.pointerId);
|
|
885
|
+
shell.classList.add('dragging-dock');
|
|
886
|
+
const move = (moving) => {
|
|
887
|
+
const width = Math.max(320, Math.min(window.innerWidth - 360, window.innerWidth - moving.clientX));
|
|
888
|
+
shell.style.setProperty('--dock', `${Math.round(width)}px`);
|
|
889
|
+
};
|
|
890
|
+
browserGrip.addEventListener('pointermove', move);
|
|
891
|
+
browserGrip.addEventListener('pointerup', () => {
|
|
892
|
+
browserGrip.removeEventListener('pointermove', move);
|
|
893
|
+
shell.classList.remove('dragging-dock');
|
|
894
|
+
}, { once: true });
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
browser.button?.addEventListener('click', () => { if (browser.dialog && !browser.dialog.hidden) browserHide(); else browserShow(); });
|
|
898
|
+
browser.close?.addEventListener('click', () => browserHide());
|
|
899
|
+
browser.servers?.addEventListener('click', () => { void browserServers(); });
|
|
900
|
+
// A list left hanging over the page is the kind of thing a human closes by clicking away from it.
|
|
901
|
+
document.addEventListener('click', (event) => {
|
|
902
|
+
if (!browser.portsMenu || browser.portsMenu.hidden) return;
|
|
903
|
+
if (browser.portsMenu.contains(event.target) || browser.servers?.contains(event.target)) return;
|
|
904
|
+
portsMenuShown(false);
|
|
905
|
+
});
|
|
906
|
+
browser.reload?.addEventListener('click', () => { const tab = browserTab(); if (browser.frame && tab) browser.frame.src = tab.url; });
|
|
907
|
+
browser.back?.addEventListener('click', () => {
|
|
908
|
+
const tab = browserTab();
|
|
909
|
+
if (!tab || tab.history.length < 2) return;
|
|
910
|
+
tab.history.pop();
|
|
911
|
+
const previous = tab.history.pop();
|
|
912
|
+
if (previous) void browserOpen(previous, { tab });
|
|
913
|
+
});
|
|
914
|
+
|
|
469
915
|
function ripleyPathFromPreview(pathname) {
|
|
470
916
|
const match = String(pathname ?? '').match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
471
917
|
return match ? { root: match[1], path: decodeURIComponent(match[2]) } : null;
|
|
@@ -476,6 +922,7 @@ function ripleyNavigate(url, { push = true } = {}) {
|
|
|
476
922
|
ripley.frame.src = url;
|
|
477
923
|
ripley.back.disabled = ripley.history.length < 2;
|
|
478
924
|
}
|
|
925
|
+
|
|
479
926
|
function ripleyClearErrors() { if (ripley.errors) { ripley.errors.hidden = true; ripley.errors.replaceChildren(); } }
|
|
480
927
|
function ripleyShowError({ message, source, line }) {
|
|
481
928
|
if (!ripley.errors || !viewerMode.preview) return;
|
|
@@ -582,6 +1029,7 @@ async function openViewer({ root = 'project', path = null, url = null, label = n
|
|
|
582
1029
|
viewerMode.kind = null;
|
|
583
1030
|
syncViewerMode();
|
|
584
1031
|
if (!viewer.dialog.open) viewer.dialog.showModal();
|
|
1032
|
+
|
|
585
1033
|
try {
|
|
586
1034
|
const response = await fetch(src);
|
|
587
1035
|
if (!response.ok) { const err = await response.json().catch(() => ({})); viewer.body.replaceChildren(el('div', 'err', err.error ?? `HTTP ${response.status}`)); return; }
|
|
@@ -767,7 +1215,27 @@ function ringPercent(agent) {
|
|
|
767
1215
|
return official === null ? localPercent(agent) : official;
|
|
768
1216
|
}
|
|
769
1217
|
|
|
1218
|
+
// The crew in one button, for when the bar has no room for five spheres. It says how many are
|
|
1219
|
+
// ready, names them all in its title, and opens CONNECTIONS — the place that already holds the
|
|
1220
|
+
// detail each sphere stands for.
|
|
1221
|
+
const agentsCompact = document.querySelector('#agents-compact');
|
|
1222
|
+
const agentsCount = document.querySelector('#agents-count');
|
|
1223
|
+
function renderAgentsCompact() {
|
|
1224
|
+
if (!agentsCompact) return;
|
|
1225
|
+
const all = [...state.agents.values()];
|
|
1226
|
+
const ready = all.filter((agent) => agent.ready).length;
|
|
1227
|
+
if (agentsCount) agentsCount.textContent = String(ready);
|
|
1228
|
+
agentsCompact.title = all.length
|
|
1229
|
+
? t('Crew · {ready} of {total} ready · {who}', { ready, total: all.length, who: all.map((agent) => `@${agent.id}`).join(' ') })
|
|
1230
|
+
: t('Crew');
|
|
1231
|
+
}
|
|
1232
|
+
agentsCompact?.addEventListener('click', () => {
|
|
1233
|
+
document.querySelector('#mother-button')?.click();
|
|
1234
|
+
setTimeout(() => document.querySelector('#mother-settings-button')?.click(), 60);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
770
1237
|
function renderAgents() {
|
|
1238
|
+
renderAgentsCompact();
|
|
771
1239
|
els.agents.replaceChildren();
|
|
772
1240
|
for (const agent of state.agents.values()) {
|
|
773
1241
|
const percent = ringPercent(agent);
|
|
@@ -1356,7 +1824,7 @@ function row(kind, agentId, { compact = false } = {}) {
|
|
|
1356
1824
|
|
|
1357
1825
|
function renderUserMessage(event) {
|
|
1358
1826
|
const { messageId, target, text, ash, model, attachments = [], create, mode } = event.payload;
|
|
1359
|
-
state.userMessages.set(messageId, { text, target, model });
|
|
1827
|
+
state.userMessages.set(messageId, { text, target, model, mode: event.payload.mode ?? 1 });
|
|
1360
1828
|
const reviewed = state.expendable;
|
|
1361
1829
|
const node = row('user');
|
|
1362
1830
|
if (event.ghost || mode === 0) node.classList.add('ghost');
|
|
@@ -1423,6 +1891,8 @@ function renderAssistantMessage(event) {
|
|
|
1423
1891
|
col.append(bubble);
|
|
1424
1892
|
const used = attachMemoryUsed(event.payload);
|
|
1425
1893
|
if (used) col.append(used);
|
|
1894
|
+
const needs = attachModeAsk(event.payload);
|
|
1895
|
+
if (needs) col.append(needs);
|
|
1426
1896
|
const stamp = el('div', 'stamp');
|
|
1427
1897
|
stamp.id = `usage-${messageId}`;
|
|
1428
1898
|
stamp.append(el('span', null, formatTime(event.timestamp)));
|
|
@@ -1638,6 +2108,31 @@ function showReading(event) {
|
|
|
1638
2108
|
|
|
1639
2109
|
// The bill arrived: the estimate is replaced by what was actually charged, and stays on the
|
|
1640
2110
|
// exchange so the guess can be checked against the truth.
|
|
2111
|
+
// What this room has seen Ash do, kept as the turns arrive so a reply can be read the moment it
|
|
2112
|
+
// settles without asking the server again. Same rule as /api/economy, and it has to stay the
|
|
2113
|
+
// same: median output per agent, Ash on against Ash off, nothing said until both sides have
|
|
2114
|
+
// enough turns to mean something. Turns weighed before the room recorded the switch carry no
|
|
2115
|
+
// boolean and are left out — guessing a side for them would poison both.
|
|
2116
|
+
const ASH_MIN_TURNS = 5;
|
|
2117
|
+
const ashSeen = new Map(); // agent -> { on: [], off: [] }
|
|
2118
|
+
const ashMedian = (values) => {
|
|
2119
|
+
if (!values.length) return null;
|
|
2120
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2121
|
+
const middle = Math.floor(sorted.length / 2);
|
|
2122
|
+
return sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
|
2123
|
+
};
|
|
2124
|
+
function rememberAsh(cost) {
|
|
2125
|
+
if (cost.failed || typeof cost.ash !== 'boolean') return null;
|
|
2126
|
+
const output = Number(cost.output ?? 0) || 0;
|
|
2127
|
+
if (output <= 0) return null;
|
|
2128
|
+
const who = cost.agent ?? 'unknown';
|
|
2129
|
+
const mine = ashSeen.get(who) ?? { on: [], off: [] };
|
|
2130
|
+
mine[cost.ash ? 'on' : 'off'].push(output);
|
|
2131
|
+
ashSeen.set(who, mine);
|
|
2132
|
+
if (mine.on.length < ASH_MIN_TURNS || mine.off.length < ASH_MIN_TURNS) return null;
|
|
2133
|
+
return { on: ashMedian(mine.on), off: ashMedian(mine.off), onTurns: mine.on.length, offTurns: mine.off.length };
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1641
2136
|
function settleReading(event) {
|
|
1642
2137
|
const cost = event.payload ?? {};
|
|
1643
2138
|
if (!cost.responseMessageId) return;
|
|
@@ -1649,10 +2144,46 @@ function settleReading(event) {
|
|
|
1649
2144
|
// Short enough to sit on the same line as everything else above the bubble. What was read back
|
|
1650
2145
|
// from the cache is money not spent, so it is the part worth naming.
|
|
1651
2146
|
const brief = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
|
|
1652
|
-
|
|
1653
|
-
badge.textContent = `${brief(cost.input)}↓ ${brief(cost.output)}↑${saved}`;
|
|
2147
|
+
badge.textContent = `${brief(cost.input)}↓ ${brief(cost.output)}↑`;
|
|
1654
2148
|
badge.title = t("Charged by this agent's own CLI: {input} input tokens, {output} output{cached}.", { input: cost.input.toLocaleString(), output: cost.output.toLocaleString(), cached: cost.cached ? t('. Another {n} were read back from its own cache instead of being charged again', { n: cost.cached.toLocaleString() }) : '' });
|
|
2149
|
+
// Ash is a property of the turn, not of the room right now: the mark belongs on the reply it
|
|
2150
|
+
// applied to, and stays true when the switch changes later.
|
|
2151
|
+
badge.classList.toggle('ash', cost.ash === true);
|
|
2152
|
+
const seen = rememberAsh(cost);
|
|
2153
|
+
if (seen) {
|
|
2154
|
+
badge.title += ` ${t('In this room: {on} output tokens is the middle turn with ASH ({onTurns} turns), {off} without it ({offTurns}). You choose when to ask for compact prose, so these are different tasks, not the same task twice.', { on: seen.on.toLocaleString(), onTurns: seen.onTurns, off: seen.off.toLocaleString(), offTurns: seen.offTurns })}`;
|
|
2155
|
+
}
|
|
1655
2156
|
if (!badge.isConnected) who.append(badge);
|
|
2157
|
+
paintUncharged(cost);
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// The one number on a message that is money not spent, where the human already reads the bill.
|
|
2161
|
+
//
|
|
2162
|
+
// Two halves, and only one of them is a measurement: the CLI SAID it read those tokens back from
|
|
2163
|
+
// its own cache instead of charging them again, while what the room never sent is counted in the
|
|
2164
|
+
// characters the room controls and turned into tokens at an estimate. The title says which is
|
|
2165
|
+
// which, because a reading nobody can take apart is a reading nobody can check.
|
|
2166
|
+
//
|
|
2167
|
+
// None of this is Ash. Ash shortens the answer, and the answer is the badge above the bubble.
|
|
2168
|
+
function paintUncharged(cost) {
|
|
2169
|
+
if (!cost.responseMessageId) return;
|
|
2170
|
+
const stamp = document.getElementById(`usage-${cost.responseMessageId}`);
|
|
2171
|
+
if (!stamp) return;
|
|
2172
|
+
const cached = Number(cost.cached ?? 0) || 0;
|
|
2173
|
+
const unsent = tokensFor(cost.spared ?? 0);
|
|
2174
|
+
const total = cached + unsent;
|
|
2175
|
+
if (total <= 0) return;
|
|
2176
|
+
const span = stamp.querySelector('.uncharged') ?? el('span', 'uncharged');
|
|
2177
|
+
span.textContent = t('{n} uncharged', { n: formatTokens(total) });
|
|
2178
|
+
span.title = t('{cached} tokens this CLI read back from its own cache instead of charging them again (measured). {unsent} tokens of briefing the room never sent (estimated at {rate} characters per token). Neither is Ash: Ash shortens the answer, and that is the figure above the bubble.', {
|
|
2179
|
+
cached: cached.toLocaleString(), unsent: unsent.toLocaleString(), rate: CH_PER_TOKEN,
|
|
2180
|
+
});
|
|
2181
|
+
// Before the running total, after the turn's own bill: read left to right it is what this turn
|
|
2182
|
+
// cost, what it did not, and where the room stands.
|
|
2183
|
+
if (!span.isConnected) {
|
|
2184
|
+
const sum = [...stamp.children].find((child) => child.textContent.startsWith('Σ'));
|
|
2185
|
+
if (sum) stamp.insertBefore(span, sum); else stamp.append(span);
|
|
2186
|
+
}
|
|
1656
2187
|
}
|
|
1657
2188
|
|
|
1658
2189
|
function renderThinking(event) {
|
|
@@ -1735,8 +2266,8 @@ function renderModuleInstalledOrRemoved(event) {
|
|
|
1735
2266
|
const { name, origin, by } = event.payload;
|
|
1736
2267
|
const node = el('div', 'system module-proposed');
|
|
1737
2268
|
node.append(t('module · '), el('b', 'who', name), event.type === 'extension.installed'
|
|
1738
|
-
? t(' installed for {where} by {who} · switch it on in MODULES', { where: origin === 'project' ? t('this project') : t('every room'), who: by
|
|
1739
|
-
: t(' removed by {who}', { who: by
|
|
2269
|
+
? t(' installed for {where} by {who} · switch it on in MODULES', { where: origin === 'project' ? t('this project') : t('every room'), who: !by || by === 'you' ? t('you') : `@${by}` })
|
|
2270
|
+
: t(' removed by {who}', { who: !by || by === 'you' ? t('you') : `@${by}` }));
|
|
1740
2271
|
return node;
|
|
1741
2272
|
}
|
|
1742
2273
|
|
|
@@ -1835,6 +2366,37 @@ function renderDistilled(event) {
|
|
|
1835
2366
|
// briefing and nobody ever saw it. Same shape as a memory saved, because the human should have to
|
|
1836
2367
|
// learn one thing, not two — and a note that came along by association says so, since something
|
|
1837
2368
|
// that changes an answer must never be invisible.
|
|
2369
|
+
// An agent that could not do the thing at the mode it was given, with the way up beside it.
|
|
2370
|
+
//
|
|
2371
|
+
// Until now this only ever existed as a sentence in an answer. The human had to read it, leave
|
|
2372
|
+
// the message, open the mode menu and pick the right rung — and in a real session they picked
|
|
2373
|
+
// the wrong one twice while the turn went nowhere. The ladder belongs next to the agent asking
|
|
2374
|
+
// to climb it.
|
|
2375
|
+
//
|
|
2376
|
+
// It offers; it never grants. #2 arms the composer, and #3 and #4 open the same ceremony as
|
|
2377
|
+
// always, where the project designation is typed by hand. Nothing here shortens that.
|
|
2378
|
+
function attachModeAsk(payload) {
|
|
2379
|
+
if (!payload || payload.role !== 'assistant' || payload.sender === 'you') return null;
|
|
2380
|
+
const ran = state.userMessages.get(payload.messageId)?.mode ?? payload.mode ?? 1;
|
|
2381
|
+
const wanted = modeAsked(payload.text, { ran });
|
|
2382
|
+
if (!wanted || wanted <= state.mode) return null;
|
|
2383
|
+
const agent = payload.sender;
|
|
2384
|
+
if (!state.agents.get(agent)?.ready) return null;
|
|
2385
|
+
const row = el('div', 'mode-ask');
|
|
2386
|
+
row.append(el('span', 'mode-what', t('@{agent} says this needs #{n} {label}', { agent, n: wanted, label: MODES[wanted]?.label ?? '' })));
|
|
2387
|
+
const go = el('button', 'mode-go', wanted >= 3 ? t('ARM #{n}', { n: wanted }) : t('SET #{n}', { n: wanted }));
|
|
2388
|
+
go.type = 'button';
|
|
2389
|
+
go.title = wanted >= 3
|
|
2390
|
+
? t('Opens the override: you type the project designation, as always.')
|
|
2391
|
+
: t('Arms the composer for your next message. Nothing is sent.');
|
|
2392
|
+
go.addEventListener('click', () => {
|
|
2393
|
+
if (wanted >= 3) openOverride(agent, { mode: wanted });
|
|
2394
|
+
else { setMode(wanted, { wink: true }); els.input.focus(); }
|
|
2395
|
+
});
|
|
2396
|
+
row.append(go);
|
|
2397
|
+
return row;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
1838
2400
|
function attachMemoryUsed(payload) {
|
|
1839
2401
|
const used = payload.recalled ?? [];
|
|
1840
2402
|
if (!used.length) return null;
|
|
@@ -1842,18 +2404,28 @@ function attachMemoryUsed(payload) {
|
|
|
1842
2404
|
hint.style.setProperty('--agent', agentColor(payload.sender));
|
|
1843
2405
|
const carried = used.filter((note) => note.via === 'cascade').length;
|
|
1844
2406
|
hint.append(el('span', 'lead', t('◉ memory used · {n}', { n: used.length }) + (carried ? t(' · {n} by association', { n: carried }) : '')));
|
|
1845
|
-
|
|
2407
|
+
const visible = used.slice(0, 3);
|
|
2408
|
+
for (const [index, note] of visible.entries()) {
|
|
2409
|
+
// The kind travels as a dot, not as a word. Those five colours were chosen for NOSTROMO's
|
|
2410
|
+
// dark star map — `fact` is #dfeeff, a near-white blue — so printing the word in them left
|
|
2411
|
+
// "FACT" invisible on a light background. A dot with a hairline ring reads on either, the
|
|
2412
|
+
// word moves into the tooltip, and the chip gets its width back for the memory itself.
|
|
1846
2413
|
const pill = el('button', `pill ${note.kind}${note.via === 'cascade' ? ' carried' : ''}`);
|
|
1847
2414
|
pill.type = 'button';
|
|
1848
2415
|
pill.style.setProperty('--kind', MEMORY_COLORS[note.kind] ?? MEMORY_COLORS.fact);
|
|
1849
|
-
pill.append(el('
|
|
1850
|
-
pill.title = `${note.text}\n${note.via === 'cascade'
|
|
2416
|
+
pill.append(el('i', 'dot'), el('span', 'what', note.text.length > 52 ? `${note.text.slice(0, 51)}…` : note.text));
|
|
2417
|
+
pill.title = `${kindWord(note.kind)} · ${note.text}\n${note.via === 'cascade'
|
|
1851
2418
|
? t('Came along because this room keeps carrying it with one of the others.')
|
|
1852
2419
|
: t('The archive matched this to what you asked.')} ${t('Click to see it in NOSTROMO.')}`;
|
|
1853
2420
|
pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
|
|
1854
|
-
|
|
2421
|
+
if (used.length > 3 && index === visible.length - 1) {
|
|
2422
|
+
const last = el('span', 'last');
|
|
2423
|
+
last.append(pill, el('span', 'more', `+${used.length - visible.length}`));
|
|
2424
|
+
hint.append(last);
|
|
2425
|
+
} else {
|
|
2426
|
+
hint.append(pill);
|
|
2427
|
+
}
|
|
1855
2428
|
}
|
|
1856
|
-
if (used.length > 3) hint.append(el('span', 'more', `+${used.length - 3}`));
|
|
1857
2429
|
return hint;
|
|
1858
2430
|
}
|
|
1859
2431
|
|
|
@@ -1995,7 +2567,7 @@ function renderControlStarted(event) {
|
|
|
1995
2567
|
const { agent, commit, message } = event.payload;
|
|
1996
2568
|
const node = el('div', 'system control');
|
|
1997
2569
|
node.style.setProperty('--agent', agentColor(agent));
|
|
1998
|
-
node.append(el('b', null, t('CONTROL
|
|
2570
|
+
node.append(el('b', null, t('CONTROL')), el('b', 'who', `@${agent}`), t(' holds the project · checkpoint '), el('code', null, (commit ?? '').slice(0, 7)));
|
|
1999
2571
|
node.title = message;
|
|
2000
2572
|
if (!replaying) toast(t('MU/TH/UR › @{agent} holds CONTROL. Checkpoint taken; UNDO will be one click.', { agent }));
|
|
2001
2573
|
state.lastSender = null;
|
|
@@ -2007,7 +2579,7 @@ function renderControlChanged(event) {
|
|
|
2007
2579
|
node.id = `control-${checkpointId}`;
|
|
2008
2580
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2009
2581
|
const head = el('div', 'head');
|
|
2010
|
-
head.append(el('b', null, t('CONTROL
|
|
2582
|
+
head.append(el('b', null, t('CONTROL')), el('b', 'who', `@${agent}`), files.length ? t(' changed {n} files', { n: files.length }) : t(' changed nothing'));
|
|
2011
2583
|
node.append(head);
|
|
2012
2584
|
if (files.length) {
|
|
2013
2585
|
const list = el('ul', 'files');
|
|
@@ -2049,7 +2621,7 @@ function renderControlReverted(event) {
|
|
|
2049
2621
|
const { agent, checkpointId, restored = [], removed = [], message } = event.payload;
|
|
2050
2622
|
const node = el('div', 'system control reverted');
|
|
2051
2623
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2052
|
-
node.append(el('b', null, t('CONTROL
|
|
2624
|
+
node.append(el('b', null, t('CONTROL')), t("project restored to the checkpoint before @{agent}'s turn · {restored} restored · {removed} removed", { agent, restored: restored.length, removed: removed.length }));
|
|
2053
2625
|
node.title = message;
|
|
2054
2626
|
const card = document.getElementById(`control-${checkpointId}`);
|
|
2055
2627
|
card?.querySelector('.undo')?.replaceWith(el('span', 'outcome', t('RESTORED')));
|
|
@@ -2163,8 +2735,8 @@ function renderLease(event) {
|
|
|
2163
2735
|
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 · ')));
|
|
2164
2736
|
// The verbs were translated and the sentence holding them was not: «@CODEX MAY CREA, GENERA
|
|
2165
2737
|
// IMÁGENES». A sentence is translated whole or it is not translated.
|
|
2166
|
-
const can = scopes.map(
|
|
2167
|
-
node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map(
|
|
2738
|
+
const can = scopes.map(capVerb).join(', ');
|
|
2739
|
+
node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map(capVerb).join(', ') }) : ''} `);
|
|
2168
2740
|
if (outDir === '.' || !outDir) {
|
|
2169
2741
|
node.append(t('anywhere in the project · existing files stay untouched'));
|
|
2170
2742
|
if (scratchDir) { node.append(t(' · scratch ')); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
|
|
@@ -2211,6 +2783,103 @@ function artifactTiles(files) {
|
|
|
2211
2783
|
return wrap;
|
|
2212
2784
|
}
|
|
2213
2785
|
|
|
2786
|
+
// An agent handing a decision back. The options are buttons because a decision between three
|
|
2787
|
+
// things is a decision, not a paragraph to answer in prose — and because the room can then show
|
|
2788
|
+
// exactly what was offered, in the ledger, instead of a sentence somebody has to re-read.
|
|
2789
|
+
//
|
|
2790
|
+
// Pressing one writes it into the composer and stops. It does not send: the human may want to
|
|
2791
|
+
// add a reason, pick none of them, or ask something else entirely, and a button that sends is a
|
|
2792
|
+
// button that decides.
|
|
2793
|
+
// What this project already settled, at the head of the conversation.
|
|
2794
|
+
//
|
|
2795
|
+
// A room outlives the window it is read in: a conversation opened a week later, or by an agent
|
|
2796
|
+
// who was not there, starts by relitigating what was decided. This is the archive saying it
|
|
2797
|
+
// first — three decisions, newest first, read-only, costing nothing in any prompt. A refuted
|
|
2798
|
+
// decision never appears: the archive keeps it, but a strip titled "what we decided" cannot
|
|
2799
|
+
// carry something the room has since established is false.
|
|
2800
|
+
// What the room still has no answer to, offered while nobody is typing.
|
|
2801
|
+
//
|
|
2802
|
+
// These are not MADRE's ideas: `questionsFor` writes them from the archive itself, word for
|
|
2803
|
+
// word, and never invents a subject the room has not raised. Pressing one writes it into the
|
|
2804
|
+
// composer — the same contract as a choice bubble, and for the same reason: a suggestion that
|
|
2805
|
+
// sends itself is a suggestion that decided.
|
|
2806
|
+
//
|
|
2807
|
+
// They cost nothing in any prompt, and they leave the moment the human starts typing. A room at
|
|
2808
|
+
// rest can afford to wonder out loud; a room being written to cannot.
|
|
2809
|
+
async function renderOpenQuestions() {
|
|
2810
|
+
const box = els.openq;
|
|
2811
|
+
if (!box) return;
|
|
2812
|
+
if (els.input.value.trim() || state.mode >= 2) { box.hidden = true; return; }
|
|
2813
|
+
let questions = [];
|
|
2814
|
+
try { questions = (await fetch('/api/questions?limit=3').then((response) => response.json()))?.questions ?? []; }
|
|
2815
|
+
catch { return; }
|
|
2816
|
+
if (!questions.length) { box.hidden = true; return; }
|
|
2817
|
+
box.replaceChildren();
|
|
2818
|
+
box.append(el('span', 'openq-lead', t('◉ still open here')));
|
|
2819
|
+
// The pills live in a track of their own so the row can scroll sideways instead of stacking:
|
|
2820
|
+
// these are an aside under the thread, and an aside that grows downwards pushes the composer.
|
|
2821
|
+
const rail = el('div', 'openq-rail');
|
|
2822
|
+
for (const question of questions) {
|
|
2823
|
+
const pill = el('button', 'openq-ask');
|
|
2824
|
+
pill.type = 'button';
|
|
2825
|
+
pill.append(el('span', 'what', question.text.length > 64 ? `${question.text.slice(0, 63)}…` : question.text));
|
|
2826
|
+
pill.title = question.carried
|
|
2827
|
+
? `${question.text}\n${t('The room has carried this into {n} turn(s) and still has no answer.', { n: question.carried })}`
|
|
2828
|
+
: `${question.text}\n${t('The archivist recorded this as open and nothing has answered it.')}`;
|
|
2829
|
+
pill.addEventListener('click', () => {
|
|
2830
|
+
els.input.value = question.text;
|
|
2831
|
+
els.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2832
|
+
els.input.focus();
|
|
2833
|
+
});
|
|
2834
|
+
rail.append(pill);
|
|
2835
|
+
}
|
|
2836
|
+
box.append(rail);
|
|
2837
|
+
box.hidden = false;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
async function renderSettled() {
|
|
2841
|
+
const box = els.settled;
|
|
2842
|
+
if (!box) return;
|
|
2843
|
+
let decisions = [];
|
|
2844
|
+
try { decisions = (await fetch('/api/decisions?limit=3').then((response) => response.json()))?.decisions ?? []; }
|
|
2845
|
+
catch { return; }
|
|
2846
|
+
if (!decisions.length) { box.hidden = true; return; }
|
|
2847
|
+
box.replaceChildren();
|
|
2848
|
+
box.append(el('span', 'settled-lead', t('◉ decided here · {n}', { n: decisions.length })));
|
|
2849
|
+
for (const note of decisions) {
|
|
2850
|
+
const pill = el('button', 'settled-note');
|
|
2851
|
+
pill.type = 'button';
|
|
2852
|
+
pill.append(el('i', 'dot'), el('span', 'what', note.text.length > 72 ? `${note.text.slice(0, 71)}…` : note.text));
|
|
2853
|
+
pill.title = `${note.text}\n${t('Click to see it in NOSTROMO.')}`;
|
|
2854
|
+
pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
|
|
2855
|
+
box.append(pill);
|
|
2856
|
+
}
|
|
2857
|
+
box.hidden = false;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function renderChoice(event) {
|
|
2861
|
+
const { question, options = [], agent } = event.payload;
|
|
2862
|
+
if (!options.length) return null;
|
|
2863
|
+
const node = el('div', 'choice');
|
|
2864
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
2865
|
+
node.append(el('p', 'choice-q', question));
|
|
2866
|
+
const row = el('div', 'choice-row');
|
|
2867
|
+
for (const option of options) {
|
|
2868
|
+
const button = el('button', 'choice-option', option);
|
|
2869
|
+
button.type = 'button';
|
|
2870
|
+
button.title = t('Writes it into the composer · nothing is sent until you press send');
|
|
2871
|
+
button.addEventListener('click', () => {
|
|
2872
|
+
els.input.value = option;
|
|
2873
|
+
els.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2874
|
+
els.input.focus();
|
|
2875
|
+
});
|
|
2876
|
+
row.append(button);
|
|
2877
|
+
}
|
|
2878
|
+
node.append(row);
|
|
2879
|
+
state.lastSender = null;
|
|
2880
|
+
return node;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2214
2883
|
function renderAlert(event) {
|
|
2215
2884
|
const { message } = event.payload;
|
|
2216
2885
|
if (!replaying) { state.brakeArmed = true; updateStopAll(); }
|
|
@@ -2224,6 +2893,27 @@ function renderAlert(event) {
|
|
|
2224
2893
|
return node;
|
|
2225
2894
|
}
|
|
2226
2895
|
|
|
2896
|
+
function renderAshValidation(event) {
|
|
2897
|
+
if (event.payload.ok) return null;
|
|
2898
|
+
const issueText = (issue) => {
|
|
2899
|
+
if (issue === 'empty response') return t('empty response');
|
|
2900
|
+
if (issue === 'unclosed code fence') return t('unclosed code fence');
|
|
2901
|
+
if (issue === 'text after ```pulse block') return t('text after ```pulse block');
|
|
2902
|
+
if (issue === 'missing negation') return t('missing negation');
|
|
2903
|
+
const limit = issue.match(/^(\d+) (words|lines), maximum (\d+)$/);
|
|
2904
|
+
if (limit) return t('{count} {unit}, maximum {maximum}', { count: limit[1], unit: t(limit[2]), maximum: limit[3] });
|
|
2905
|
+
const missing = issue.match(/^missing (.+)$/);
|
|
2906
|
+
if (missing) return t('missing {value}', { value: missing[1] });
|
|
2907
|
+
return issue;
|
|
2908
|
+
};
|
|
2909
|
+
const details = (event.payload.issues ?? []).map(issueText).join(' · ');
|
|
2910
|
+
const node = el('div', 'system alert');
|
|
2911
|
+
node.append(el('b', null, 'ASH › '), t('REVIEW · the local check found: {details}', { details }));
|
|
2912
|
+
if (!replaying) toast(`ASH › ${t('REVIEW · the local check found: {details}', { details })}`);
|
|
2913
|
+
state.lastSender = null;
|
|
2914
|
+
return node;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2227
2917
|
function renderHalted(event) {
|
|
2228
2918
|
const { reason, plans, turns, agents = [] } = event.payload;
|
|
2229
2919
|
const node = el('div', 'system halted');
|
|
@@ -2392,7 +3082,12 @@ function renderEventNode(event) {
|
|
|
2392
3082
|
}
|
|
2393
3083
|
case 'room.settings': return;
|
|
2394
3084
|
case 'extension.toggled':
|
|
2395
|
-
|
|
3085
|
+
// A module switched on or off changes what the composer accepts.
|
|
3086
|
+
if (!replaying) void refreshCommands();
|
|
3087
|
+
// The replay is history, not configuration. /api/state already supplied the live switch;
|
|
3088
|
+
// letting an older toggle overwrite it made Ash appear to turn itself off or on at boot
|
|
3089
|
+
// and whenever the human changed conversation.
|
|
3090
|
+
if (!replaying && event.payload.id === 'ash') syncAshUI(Boolean(event.payload.enabled));
|
|
2396
3091
|
if (event.payload.id === 'ripley') { state.ripley = Boolean(event.payload.enabled); syncViewerMode(); }
|
|
2397
3092
|
if (!replaying) void refreshModules();
|
|
2398
3093
|
return;
|
|
@@ -2402,7 +3097,14 @@ function renderEventNode(event) {
|
|
|
2402
3097
|
break;
|
|
2403
3098
|
case 'agent.started': state.running.set(event.payload.messageId, event.payload.agent); updateStopAll(); node = renderThinking(event); break;
|
|
2404
3099
|
case 'turn.reading': showReading(event); return;
|
|
2405
|
-
case 'turn.cost':
|
|
3100
|
+
case 'turn.cost': {
|
|
3101
|
+
settleReading(event);
|
|
3102
|
+
// The card, if the human happens to be looking at it, follows the room instead of the
|
|
3103
|
+
// moment MODULES was opened.
|
|
3104
|
+
const open = document.getElementById('ash-economy');
|
|
3105
|
+
if (open && !replaying) void paintAshEconomy(open);
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
2406
3108
|
case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); return;
|
|
2407
3109
|
case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
|
|
2408
3110
|
case 'handoff.created': node = renderHandoff(event); break;
|
|
@@ -2410,11 +3112,13 @@ function renderEventNode(event) {
|
|
|
2410
3112
|
case 'message.rated': { const { messageId: rated, rating } = event.payload; if (rating === 'none') state.ratings.delete(rated); else state.ratings.set(rated, rating); state.ratingNodes.get(rated)?.apply(rating === 'none' ? null : rating); return; }
|
|
2411
3113
|
case 'privacy.redacted': node = renderPrivacy(event); break;
|
|
2412
3114
|
case 'module.proposed': node = renderModuleProposed(event); break;
|
|
2413
|
-
case 'extension.installed': case 'extension.removed': node = renderModuleInstalledOrRemoved(event); break;
|
|
3115
|
+
case 'extension.installed': case 'extension.removed': if (!replaying) void refreshCommands(); node = renderModuleInstalledOrRemoved(event); break;
|
|
2414
3116
|
case 'privacy.purged': node = renderPrivacy(event); break;
|
|
2415
3117
|
case 'privacy.warning': if (!replaying) toast(t('MU/TH/UR › your message carries {n} private {term}. Agents will read it as you wrote it; their replies are guarded.', { n: event.payload.hits, term: event.payload.hits === 1 ? t('term') : t('terms') })); return;
|
|
2416
3118
|
case 'memory.forgotten': node = renderForgotten(event); break;
|
|
2417
|
-
|
|
3119
|
+
// A new decision changes what the head of the conversation should say.
|
|
3120
|
+
case 'memory.noted': attachMemoryHint(event); if (!replaying && (event.payload.notes ?? []).some((note) => note.kind === 'decision')) void renderSettled();
|
|
3121
|
+
void renderOpenQuestions(); return;
|
|
2418
3122
|
case 'dataset.exported': return;
|
|
2419
3123
|
case 'agents.updated': {
|
|
2420
3124
|
for (const agent of event.payload.agents ?? []) {
|
|
@@ -2459,7 +3163,9 @@ function renderEventNode(event) {
|
|
|
2459
3163
|
case 'plan.stopped':
|
|
2460
3164
|
node = renderPlanEvent(event);
|
|
2461
3165
|
break;
|
|
3166
|
+
case 'choice.offered': node = renderChoice(event); break;
|
|
2462
3167
|
case 'room.alert': node = renderAlert(event); break;
|
|
3168
|
+
case 'ash.validated': if (event.payload.ok) return; node = renderAshValidation(event); break;
|
|
2463
3169
|
case 'room.stopped': node = renderHalted(event); break;
|
|
2464
3170
|
case 'lease.granted': node = renderLease(event); break;
|
|
2465
3171
|
case 'lease.refused': node = renderLeaseRefused(event); break;
|
|
@@ -2478,6 +3184,10 @@ function renderEventNode(event) {
|
|
|
2478
3184
|
case 'eyecat.settled': settleEyecat(event); return;
|
|
2479
3185
|
default: return;
|
|
2480
3186
|
}
|
|
3187
|
+
// A renderer may decline: a choice with no options, a reading with nothing to report. Declining
|
|
3188
|
+
// is not an error, but appending the nothing it returned puts a hole in the column that every
|
|
3189
|
+
// later pass over the children trips on.
|
|
3190
|
+
if (!node) return;
|
|
2481
3191
|
removeEmpty();
|
|
2482
3192
|
const stickToBottom = els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 120;
|
|
2483
3193
|
els.column.append(node);
|
|
@@ -2614,12 +3324,173 @@ async function loadTreeLevel(path, list) {
|
|
|
2614
3324
|
}
|
|
2615
3325
|
}
|
|
2616
3326
|
|
|
3327
|
+
/* ---------- the finder: search, and moving things by hand ---------- */
|
|
3328
|
+
//
|
|
3329
|
+
// Copy and move are the human's own hands in their own project, so there is no permission mode
|
|
3330
|
+
// here — but the zones the room guards from agents are guarded from slips too. Anything touching
|
|
3331
|
+
// .git, .pulse, .madre, a .env or the local settings asks for the project's designation first,
|
|
3332
|
+
// the same word CONTROL asks for. Typing a folder name is a small ceremony; getting a key back
|
|
3333
|
+
// after it was moved somewhere is not.
|
|
3334
|
+
const finder = {
|
|
3335
|
+
search: document.querySelector('#tree-search'),
|
|
3336
|
+
q: document.querySelector('#tree-q'),
|
|
3337
|
+
clip: document.querySelector('#tree-clip'),
|
|
3338
|
+
menu: document.querySelector('#tree-menu'),
|
|
3339
|
+
held: null, // { path, name, operation }
|
|
3340
|
+
};
|
|
3341
|
+
|
|
3342
|
+
const GUARDED = /^\.git(\/|$)|^\.pulse(\/|$)|(^|\/)\.env(\.|$)|(^|\/)\.madre(\/|$)|(^|\/)\.claude\/settings\.local\.json$/;
|
|
3343
|
+
const finderGuarded = (...paths) => paths.filter(Boolean).some((path) => GUARDED.test(String(path).replace(/^\.\//, '')));
|
|
3344
|
+
|
|
3345
|
+
function finderShowClip() {
|
|
3346
|
+
if (!finder.clip) return;
|
|
3347
|
+
if (!finder.held) { finder.clip.hidden = true; finder.clip.replaceChildren(); return; }
|
|
3348
|
+
finder.clip.replaceChildren();
|
|
3349
|
+
// No t() around a template that is only two slots and a dot: there is nothing in it to say in
|
|
3350
|
+
// another language, and the catalogue's own guard rejects an entry whose Spanish is the English.
|
|
3351
|
+
finder.clip.append(el('span', 'what', `${finder.held.operation === 'copy' ? t('COPY') : t('MOVE')} · ${finder.held.name}`));
|
|
3352
|
+
const cancel = el('button', null, t('CANCEL'));
|
|
3353
|
+
cancel.type = 'button';
|
|
3354
|
+
cancel.addEventListener('click', () => { finder.held = null; finderShowClip(); });
|
|
3355
|
+
finder.clip.append(cancel);
|
|
3356
|
+
finder.clip.hidden = false;
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
// Asking for a word without leaving the panel. A name, or the designation: both are typed in the
|
|
3360
|
+
// same strip under the search box, where the hand already is. A browser prompt would take the
|
|
3361
|
+
// whole window for one word and throw away where the human was looking.
|
|
3362
|
+
function finderAsk({ label, value = '', hint = '' }) {
|
|
3363
|
+
return new Promise((resolve) => {
|
|
3364
|
+
if (!finder.clip) { resolve(null); return; }
|
|
3365
|
+
const form = el('form', 'tree-ask');
|
|
3366
|
+
const input = el('input');
|
|
3367
|
+
input.type = 'text';
|
|
3368
|
+
input.value = value;
|
|
3369
|
+
input.spellcheck = false;
|
|
3370
|
+
input.autocomplete = 'off';
|
|
3371
|
+
input.placeholder = label;
|
|
3372
|
+
input.setAttribute('aria-label', label);
|
|
3373
|
+
const ok = el('button', 'go', t('OK'));
|
|
3374
|
+
ok.type = 'submit';
|
|
3375
|
+
const no = el('button', null, t('CANCEL'));
|
|
3376
|
+
no.type = 'button';
|
|
3377
|
+
form.append(input, ok, no);
|
|
3378
|
+
const done = (answer) => { finderShowClip(); resolve(answer); };
|
|
3379
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); done(input.value.trim() || null); });
|
|
3380
|
+
no.addEventListener('click', () => done(null));
|
|
3381
|
+
input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.stopPropagation(); done(null); } });
|
|
3382
|
+
finder.clip.replaceChildren();
|
|
3383
|
+
if (hint) finder.clip.append(el('span', 'what', hint));
|
|
3384
|
+
finder.clip.append(form);
|
|
3385
|
+
finder.clip.hidden = false;
|
|
3386
|
+
input.focus();
|
|
3387
|
+
// The extension stays out of the selection: renaming is almost always about the name.
|
|
3388
|
+
const dot = value.lastIndexOf('.');
|
|
3389
|
+
input.setSelectionRange(0, dot > 0 ? dot : value.length);
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// One door for everything that writes: it asks for the designation only when it already knows one
|
|
3394
|
+
// will be wanted, so the human is not surprised mid-operation.
|
|
3395
|
+
async function finderRun(body, { say }) {
|
|
3396
|
+
const guarded = [body.from, body.to].filter(Boolean).find((path) => finderGuarded(path));
|
|
3397
|
+
if (guarded) {
|
|
3398
|
+
const typed = await finderAsk({ label: t('PROJECT DESIGNATION'), hint: t('{path} is a place MADRE guards.', { path: guarded }) });
|
|
3399
|
+
if (!typed) return false;
|
|
3400
|
+
body.designation = typed;
|
|
3401
|
+
}
|
|
3402
|
+
const response = await fetch('/api/tree/op', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
3403
|
+
const result = await response.json().catch(() => ({}));
|
|
3404
|
+
if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? t('That could not be done.')}`); return false; }
|
|
3405
|
+
toast(say);
|
|
3406
|
+
els.treeBody.replaceChildren();
|
|
3407
|
+
void setTree(true);
|
|
3408
|
+
return true;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
const finderDirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.');
|
|
3412
|
+
const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
|
|
3413
|
+
|
|
3414
|
+
async function finderPaste(intoDir) {
|
|
3415
|
+
if (!finder.held) return;
|
|
3416
|
+
const held = finder.held;
|
|
3417
|
+
const to = finderJoin(intoDir, held.name);
|
|
3418
|
+
const did = await finderRun({ operation: held.operation, from: held.path, to }, { say: `${held.path} → ${to}` });
|
|
3419
|
+
if (!did) return;
|
|
3420
|
+
finder.held = null;
|
|
3421
|
+
finderShowClip();
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
async function finderRename(path, entry) {
|
|
3425
|
+
const name = await finderAsk({ label: t('NEW NAME'), value: entry.name });
|
|
3426
|
+
if (!name || name === entry.name) return;
|
|
3427
|
+
const to = finderJoin(finderDirOf(path), name);
|
|
3428
|
+
await finderRun({ operation: 'move', from: path, to }, { say: `${path} → ${to}` });
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
async function finderCreate(dir, kind) {
|
|
3432
|
+
const name = await finderAsk({ label: kind === 'new-folder' ? t('NEW FOLDER') : t('NEW FILE') });
|
|
3433
|
+
if (!name) return;
|
|
3434
|
+
const to = finderJoin(dir, name);
|
|
3435
|
+
await finderRun({ operation: kind, to }, { say: to });
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function finderMenu(event, path, entry) {
|
|
3439
|
+
event.preventDefault();
|
|
3440
|
+
const menu = finder.menu;
|
|
3441
|
+
if (!menu) return;
|
|
3442
|
+
menu.replaceChildren();
|
|
3443
|
+
const add = (label, run, cls) => {
|
|
3444
|
+
const button = el('button', cls || null, label);
|
|
3445
|
+
button.type = 'button';
|
|
3446
|
+
button.addEventListener('click', () => { menu.hidden = true; run(); });
|
|
3447
|
+
menu.append(button);
|
|
3448
|
+
};
|
|
3449
|
+
// Where the new thing lands: inside the folder that was clicked, or beside the file.
|
|
3450
|
+
const here = entry.kind === 'dir' ? path : finderDirOf(path);
|
|
3451
|
+
add(t('Copy'), () => { finder.held = { path, name: entry.name, operation: 'copy' }; finderShowClip(); });
|
|
3452
|
+
add(t('Move'), () => { finder.held = { path, name: entry.name, operation: 'move' }; finderShowClip(); });
|
|
3453
|
+
add(t('Rename'), () => void finderRename(path, entry));
|
|
3454
|
+
if (finder.held && entry.kind === 'dir') add(t('Paste here'), () => void finderPaste(path));
|
|
3455
|
+
if (finder.held && entry.kind === 'file') add(t('Paste beside it'), () => void finderPaste(here));
|
|
3456
|
+
add(t('New file'), () => void finderCreate(here, 'new-file'));
|
|
3457
|
+
add(t('New folder'), () => void finderCreate(here, 'new-folder'));
|
|
3458
|
+
if (finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
|
|
3459
|
+
menu.hidden = false;
|
|
3460
|
+
// Opened where the hand is, and kept inside the panel: a menu that spills across the room is a
|
|
3461
|
+
// menu the human has to go looking for.
|
|
3462
|
+
const panel = els.tree.getBoundingClientRect();
|
|
3463
|
+
const box = menu.getBoundingClientRect();
|
|
3464
|
+
menu.style.left = `${Math.max(6, Math.min(event.clientX - panel.left, panel.width - box.width - 6))}px`;
|
|
3465
|
+
menu.style.top = `${Math.max(6, Math.min(event.clientY - panel.top, panel.height - box.height - 6))}px`;
|
|
3466
|
+
}
|
|
3467
|
+
document.addEventListener('click', (event) => { if (finder.menu && !finder.menu.hidden && !finder.menu.contains(event.target)) finder.menu.hidden = true; });
|
|
3468
|
+
|
|
3469
|
+
// Searching the project by name, with the same scoring the composer's "!" menu uses.
|
|
3470
|
+
let finderTimer = null;
|
|
3471
|
+
finder.q?.addEventListener('input', () => {
|
|
3472
|
+
clearTimeout(finderTimer);
|
|
3473
|
+
finderTimer = setTimeout(async () => {
|
|
3474
|
+
const query = finder.q.value.trim();
|
|
3475
|
+
if (!query) { els.treeBody.replaceChildren(); void setTree(true); return; }
|
|
3476
|
+
let matches = [];
|
|
3477
|
+
try { matches = (await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=40`).then((r) => r.json()))?.matches ?? []; }
|
|
3478
|
+
catch { return; }
|
|
3479
|
+
const list = el('ul');
|
|
3480
|
+
for (const match of matches) list.append(treeNode(match.path, { kind: 'file', name: match.path, contentType: match.contentType }));
|
|
3481
|
+
els.treeBody.replaceChildren(list);
|
|
3482
|
+
if (!matches.length) els.treeBody.append(el('div', 'tree-empty', t('NOTHING MATCHES {q}', { q: query })));
|
|
3483
|
+
}, 160);
|
|
3484
|
+
});
|
|
3485
|
+
finder.search?.addEventListener('submit', (event) => event.preventDefault());
|
|
3486
|
+
|
|
2617
3487
|
function treeNode(path, entry) {
|
|
2618
3488
|
const item = el('li');
|
|
2619
3489
|
item.setAttribute('role', 'treeitem');
|
|
2620
3490
|
const button = el('button', `node ${entry.kind}${entry.shallow ? ' shallow' : ''}${entry.kind === 'file' && /^image\//.test(entry.contentType ?? '') ? ' image' : ''}${entry.kind === 'file' && CODE_EXT.test(entry.name) ? ' code' : ''}`);
|
|
2621
3491
|
button.type = 'button';
|
|
2622
3492
|
button.title = path;
|
|
3493
|
+
button.addEventListener('contextmenu', (event) => finderMenu(event, path, entry));
|
|
2623
3494
|
if (entry.kind === 'dir') {
|
|
2624
3495
|
const caret = el('span', 'caret', '▸');
|
|
2625
3496
|
button.append(caret, el('span', 'name', entry.name));
|
|
@@ -2849,6 +3720,13 @@ els.thread.addEventListener('keydown', (event) => trackHold(event.key === 'Arrow
|
|
|
2849
3720
|
/* ---------- bootstrap ---------- */
|
|
2850
3721
|
|
|
2851
3722
|
const initial = await fetch('/api/state').then((response) => response.json());
|
|
3723
|
+
// The corner of the room: which MADRE this is, and whose it is. Quiet enough to forget about,
|
|
3724
|
+
// there the moment a bug report needs it. The notice is NOTICE's, word for word — a copyright
|
|
3725
|
+
// line the page invents for itself is a copyright line that drifts from the one that counts.
|
|
3726
|
+
if (initial.version) {
|
|
3727
|
+
const corner = document.querySelector('#colophon');
|
|
3728
|
+
if (corner) corner.textContent = `MADRE ${initial.version} · © 2026 Jossue Alcalá`;
|
|
3729
|
+
}
|
|
2852
3730
|
adoptLanguage(initial.language);
|
|
2853
3731
|
els.project.textContent = initial.projectRoot.split('/').filter(Boolean).at(-1) || initial.projectRoot;
|
|
2854
3732
|
els.project.title = initial.projectRoot;
|
|
@@ -2869,7 +3747,9 @@ state.budget = Number.isFinite(initial.softTokenBudget) && initial.softTokenBudg
|
|
|
2869
3747
|
state.timeouts = initial.timeouts ?? {};
|
|
2870
3748
|
state.sessions = initial.sessions ?? {};
|
|
2871
3749
|
state.capabilities = initial.capabilities ?? {};
|
|
3750
|
+
state.ripleyWeb = Boolean(initial.ripleyWeb);
|
|
2872
3751
|
syncAshUI(Boolean(initial.ash?.enabled));
|
|
3752
|
+
void renderSettled();
|
|
2873
3753
|
state.ripley = Boolean(initial.ripley?.enabled);
|
|
2874
3754
|
state.projectRoot = initial.projectRoot ?? '';
|
|
2875
3755
|
state.platform = initial.platform ?? null;
|
|
@@ -2896,6 +3776,10 @@ if (!els.target.options.length) els.target.add(new Option(t('No agent ready'), '
|
|
|
2896
3776
|
renderAgents();
|
|
2897
3777
|
renderPicker();
|
|
2898
3778
|
renderOnboarding();
|
|
3779
|
+
// What the composer will accept. A module's commands live on the server, so the page has to ask:
|
|
3780
|
+
// this call had slipped inside the branch that ends a live installation, and until one happened
|
|
3781
|
+
// `/git` and every module command answered «unknown» in a room that had them enabled all along.
|
|
3782
|
+
void refreshCommands();
|
|
2899
3783
|
for (const event of initial.events) renderEvent(event);
|
|
2900
3784
|
scrollToEnd();
|
|
2901
3785
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
|
|
@@ -3016,6 +3900,49 @@ function syncModuleComposer() {
|
|
|
3016
3900
|
updatePlaceholder();
|
|
3017
3901
|
if (active && !state.create) askForModuleMode();
|
|
3018
3902
|
}
|
|
3903
|
+
// A question about what this room already said is a question the room can answer itself, on this
|
|
3904
|
+
// computer, for nothing. @madre reads the same archive every paid agent reads and bills no
|
|
3905
|
+
// tokens at all — so the saving here is not a shorter answer or a better cache, it is the
|
|
3906
|
+
// absence of a charge.
|
|
3907
|
+
//
|
|
3908
|
+
// It offers and never decides. The human addressed somebody, or addressed nobody and MADRE
|
|
3909
|
+
// picked the first ready agent; changing that silently would be MADRE spending — or not
|
|
3910
|
+
// spending — in their name. One line, one button, and it says what it matched on.
|
|
3911
|
+
const OFFER_WHY = {
|
|
3912
|
+
decision: 'what was decided',
|
|
3913
|
+
discussed: 'whether this came up before',
|
|
3914
|
+
where: 'where the room left off',
|
|
3915
|
+
why: 'why something was chosen',
|
|
3916
|
+
said: 'what somebody said',
|
|
3917
|
+
open: 'what is still open',
|
|
3918
|
+
};
|
|
3919
|
+
function syncFreeOffer() {
|
|
3920
|
+
const box = els.freeOffer;
|
|
3921
|
+
if (!box) return;
|
|
3922
|
+
const madre = state.agents.get('madre');
|
|
3923
|
+
const chosen = els.target.value;
|
|
3924
|
+
const why = archiveQuestion(els.input.value);
|
|
3925
|
+
// Only worth offering when it is free, ready, and not already where it would go.
|
|
3926
|
+
if (!why || !madre?.ready || chosen === 'madre' || state.mode >= 2) { box.hidden = true; box.replaceChildren(); return; }
|
|
3927
|
+
if (box.dataset.why === why && !box.hidden) return;
|
|
3928
|
+
box.dataset.why = why;
|
|
3929
|
+
box.replaceChildren();
|
|
3930
|
+
box.append(el('span', 'free-what', t('@madre can answer this from the room archive, on this computer, for no tokens · it read {why}', { why: t(OFFER_WHY[why]) })));
|
|
3931
|
+
const go = el('button', 'free-go', t('ASK @madre'));
|
|
3932
|
+
go.type = 'button';
|
|
3933
|
+
go.addEventListener('click', () => {
|
|
3934
|
+
els.target.value = 'madre';
|
|
3935
|
+
renderPicker();
|
|
3936
|
+
box.hidden = true;
|
|
3937
|
+
els.input.focus();
|
|
3938
|
+
});
|
|
3939
|
+
box.append(go);
|
|
3940
|
+
box.hidden = false;
|
|
3941
|
+
}
|
|
3942
|
+
els.input.addEventListener('input', syncFreeOffer);
|
|
3943
|
+
els.input.addEventListener('input', () => { if (els.openq) els.openq.hidden = Boolean(els.input.value.trim()); });
|
|
3944
|
+
els.composer?.addEventListener('submit', () => { if (els.freeOffer) { els.freeOffer.hidden = true; els.freeOffer.dataset.why = ''; } });
|
|
3945
|
+
|
|
3019
3946
|
els.input.addEventListener('input', syncModuleComposer);
|
|
3020
3947
|
syncModuleComposer();
|
|
3021
3948
|
|
|
@@ -3122,7 +4049,15 @@ function showMenu(items, found) {
|
|
|
3122
4049
|
button.setAttribute('role', 'option');
|
|
3123
4050
|
button.setAttribute('aria-selected', String(index === menu.index));
|
|
3124
4051
|
if (item.color) button.style.setProperty('--agent', item.color);
|
|
3125
|
-
|
|
4052
|
+
// The name and what it does share the first line; the argument syntax gets its own, because
|
|
4053
|
+
// a usage string like `/git [status|log|diff|commit "message"|push [confirm]]` is wider than
|
|
4054
|
+
// the menu and used to push the description out of the row entirely — leaving a command with
|
|
4055
|
+
// no explanation, which is the one thing this menu exists to give.
|
|
4056
|
+
const [name, ...rest] = String(item.key).split(' ');
|
|
4057
|
+
const args = rest.join(' ');
|
|
4058
|
+
button.append(el('span', 'key', name), el('span', 'what', item.what));
|
|
4059
|
+
if (args) button.append(el('span', 'args', args));
|
|
4060
|
+
button.title = `${item.key} · ${item.what}`;
|
|
3126
4061
|
button.addEventListener('mousedown', (event) => { event.preventDefault(); pickMenu(index); });
|
|
3127
4062
|
els.slashMenu.append(button);
|
|
3128
4063
|
});
|
|
@@ -3187,7 +4122,9 @@ async function runSlashCommand(text) {
|
|
|
3187
4122
|
return {
|
|
3188
4123
|
handled: false,
|
|
3189
4124
|
target,
|
|
3190
|
-
|
|
4125
|
+
// The card shows `vendor` under the name, so the instruction has to say what it is for.
|
|
4126
|
+
// Left unsaid, Codex wrote `vendor: 'you'` and the room printed it as the author.
|
|
4127
|
+
text: t('Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Set vendor to whoever wrote it — the human\'s name or team if you know it, otherwise leave it out; never the word "you". Do not install it; MADRE will show the human an install card.', { request: rest }),
|
|
3191
4128
|
};
|
|
3192
4129
|
}
|
|
3193
4130
|
return { handled: false, text: rest, target };
|
|
@@ -3396,6 +4333,16 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
3396
4333
|
const ready = state.pending.filter((item) => item.id && !item.uploading);
|
|
3397
4334
|
if (!text && !ready.length) return;
|
|
3398
4335
|
if (state.pending.some((item) => item.uploading)) { toast(t('An attachment is still uploading.')); return; }
|
|
4336
|
+
// Called by name. Not routed, not recorded, not billed — and whatever agent the chip is on, it
|
|
4337
|
+
// never hears about it. See abduction.js.
|
|
4338
|
+
if (calledByName(text, { attachments: state.pending.length })) {
|
|
4339
|
+
els.input.value = '';
|
|
4340
|
+
autosize();
|
|
4341
|
+
syncModuleComposer();
|
|
4342
|
+
await abduct({ composer: els.composer, reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches });
|
|
4343
|
+
els.input.focus();
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
3399
4346
|
if (STOPALL.test(text)) {
|
|
3400
4347
|
// Master command: never reaches an agent.
|
|
3401
4348
|
els.input.value = '';
|
|
@@ -3823,20 +4770,6 @@ mother.dialog.addEventListener('close', () => clearTimeout(mother.bootTimer));
|
|
|
3823
4770
|
|
|
3824
4771
|
/* ---------- MODULES: optional integrations installed by their own tools ---------- */
|
|
3825
4772
|
|
|
3826
|
-
const modules = {
|
|
3827
|
-
dialog: document.querySelector('#modules'),
|
|
3828
|
-
button: document.querySelector('#modules-button'),
|
|
3829
|
-
count: document.querySelector('#modules-count'),
|
|
3830
|
-
close: document.querySelector('#modules-close'),
|
|
3831
|
-
list: document.querySelector('#modules-list'),
|
|
3832
|
-
add: document.querySelector('#modules-add'),
|
|
3833
|
-
file: document.querySelector('#modules-file'),
|
|
3834
|
-
note: document.querySelector('#modules-note'),
|
|
3835
|
-
items: [],
|
|
3836
|
-
installing: null,
|
|
3837
|
-
logs: new Map(), // id -> array of lines
|
|
3838
|
-
confirming: null,
|
|
3839
|
-
};
|
|
3840
4773
|
|
|
3841
4774
|
function renderModuleEvent(event) {
|
|
3842
4775
|
const { id, name, command, platforms = [], ok, code, error, status, problems = [] } = event.payload;
|
|
@@ -3856,7 +4789,7 @@ function renderModuleEvent(event) {
|
|
|
3856
4789
|
: `${t('{name} install failed', { name })}${error ? ` · ${error}` : code !== undefined ? t(' · exit {code}', { code }) : ''}`);
|
|
3857
4790
|
modules.installing = null;
|
|
3858
4791
|
void refreshModules();
|
|
3859
|
-
void refreshCommands();
|
|
4792
|
+
void refreshCommands();
|
|
3860
4793
|
}
|
|
3861
4794
|
state.lastSender = null;
|
|
3862
4795
|
return node;
|
|
@@ -4311,15 +5244,211 @@ async function updateModule(item, button) {
|
|
|
4311
5244
|
(panel ?? card)?.prepend(box);
|
|
4312
5245
|
}
|
|
4313
5246
|
|
|
5247
|
+
// The economy, at the size it deserves. The card in MODULES is the glance; this is the reading.
|
|
5248
|
+
//
|
|
5249
|
+
// Order is the argument: what the room never paid for comes first, because it is the only figure
|
|
5250
|
+
// here that is money, then what Ash has been seen to do to the answers, then where the tokens
|
|
5251
|
+
// that WERE paid for went. Nothing in this panel is a number the room did not measure or cannot
|
|
5252
|
+
// name as an estimate.
|
|
5253
|
+
const economyPanel = {
|
|
5254
|
+
dialog: document.querySelector('#economy'),
|
|
5255
|
+
close: document.querySelector('#economy-close'),
|
|
5256
|
+
body: document.querySelector('#economy-body'),
|
|
5257
|
+
};
|
|
5258
|
+
|
|
5259
|
+
function openEconomy() {
|
|
5260
|
+
if (!economyPanel.dialog || economyPanel.dialog.open) return;
|
|
5261
|
+
economyPanel.dialog.showModal();
|
|
5262
|
+
void renderEconomy();
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
economyPanel.close?.addEventListener('click', () => economyPanel.dialog.close());
|
|
5266
|
+
|
|
5267
|
+
async function renderEconomy() {
|
|
5268
|
+
const box = economyPanel.body;
|
|
5269
|
+
if (!box) return;
|
|
5270
|
+
const count = (value) => (Number.isFinite(value) ? value.toLocaleString() : '—');
|
|
5271
|
+
const pct = (value) => (Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—');
|
|
5272
|
+
let read;
|
|
5273
|
+
try { read = await fetch('/api/economy').then((response) => response.json()); }
|
|
5274
|
+
catch { box.replaceChildren(el('p', 'note', t('ECONOMY UNAVAILABLE'))); return; }
|
|
5275
|
+
box.replaceChildren();
|
|
5276
|
+
if (!read?.turns) {
|
|
5277
|
+
box.append(el('p', 'note', t('NO TURNS WEIGHED YET · SEND A MESSAGE AND THIS FILLS')));
|
|
5278
|
+
return;
|
|
5279
|
+
}
|
|
5280
|
+
const saved = read.saved ?? {};
|
|
5281
|
+
const totals = read.totals;
|
|
5282
|
+
|
|
5283
|
+
// The room in six figures, across the full width. A stat row, not a chart: six numbers of six
|
|
5284
|
+
// different kinds have no shape to draw, and drawing one anyway is how a panel starts lying.
|
|
5285
|
+
const strip = el('div', 'economy-strip');
|
|
5286
|
+
for (const [value, label, hint] of [
|
|
5287
|
+
[count(read.turns), t('TURNS WEIGHED'), t('every turn this room has been billed for')],
|
|
5288
|
+
[count(totals.input), t('INPUT CHARGED'), t('tokens the CLIs charged as fresh reading')],
|
|
5289
|
+
[count(totals.output), t('OUTPUT CHARGED'), t('tokens written back · the dearer half')],
|
|
5290
|
+
[count(saved.tokens), t('NEVER CHARGED'), t('read back from a cache, plus what was never sent')],
|
|
5291
|
+
[pct(totals.cacheShare), t('CAME FROM CACHE'), t('of everything read, the share nobody charged twice')],
|
|
5292
|
+
[totals.charsPerInputToken ? totals.charsPerInputToken.toFixed(2) : '—', t('CH PER TOKEN'), t('what MADRE wrote against what the CLIs were charged for reading · not a tokenizer')],
|
|
5293
|
+
]) {
|
|
5294
|
+
const cell = el('div', 'economy-stat');
|
|
5295
|
+
cell.title = hint;
|
|
5296
|
+
cell.append(el('b', null, String(value)), el('span', null, label));
|
|
5297
|
+
strip.append(cell);
|
|
5298
|
+
}
|
|
5299
|
+
box.append(strip);
|
|
5300
|
+
|
|
5301
|
+
// 1 · The only money on this screen.
|
|
5302
|
+
const first = el('div', 'card-block');
|
|
5303
|
+
first.append(el('h5', null, t('NEVER CHARGED FOR')));
|
|
5304
|
+
first.append(el('div', 'economy-headline', count(saved.tokens)));
|
|
5305
|
+
metrics(first, [
|
|
5306
|
+
[t('FROM THE CACHE'), count(saved.cachedTokens), t('the CLI said it read these back instead of charging them again · measured')],
|
|
5307
|
+
[t('NEVER SENT'), count(saved.unsentTokens), t('briefing a turn had no use for · estimated from characters, not measured')],
|
|
5308
|
+
[t('OF THE INPUT'), pct(saved.cachedShare), t('the share of everything read that came back from a cache')],
|
|
5309
|
+
]);
|
|
5310
|
+
first.append(el('p', 'note', t('One half is measured and the other is estimated, and they are never added without saying so. Neither of them is Ash: Ash shortens the answer, which is the next reading.')));
|
|
5311
|
+
box.append(first);
|
|
5312
|
+
|
|
5313
|
+
// 1b · The turns with no bill at all. Nothing here is compared to anything: a count, and the
|
|
5314
|
+
// scale of a paid turn beside it so the count means something.
|
|
5315
|
+
if (read.free?.turns) {
|
|
5316
|
+
const free = el('div', 'card-block');
|
|
5317
|
+
free.append(el('h5', null, t('TURNS THE ROOM ANSWERED ITSELF')));
|
|
5318
|
+
metrics(free, [
|
|
5319
|
+
[t('FREE TURNS'), count(read.free.turns), t('answered by @madre on this computer · no provider, no bill')],
|
|
5320
|
+
[t('OF EVERY TURN'), pct(read.free.share), t('of the turns weighed in this room')],
|
|
5321
|
+
[t('A PAID TURN'), read.free.paidOutputMedian ? `${count(read.free.paidOutputMedian)}↑` : '—', t('the middle paid turn here · what the others cost, not what these would have')],
|
|
5322
|
+
]);
|
|
5323
|
+
free.append(el('p', 'note', t('These turns have no bill because nothing left this computer to answer them. MADRE does not say what they would have cost somewhere else: nobody measured that.')));
|
|
5324
|
+
box.append(free);
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
// 2 · Ash, observed rather than claimed.
|
|
5328
|
+
const second = el('div', 'card-block');
|
|
5329
|
+
second.append(el('h5', null, t('WHAT ASH DOES TO AN ANSWER')));
|
|
5330
|
+
// The price before the promise. Ash concatenates an instruction to every prompt it is on, so it
|
|
5331
|
+
// ALWAYS costs input — and the room measures that block exactly, because it is the block the
|
|
5332
|
+
// room itself wrote. Showing only the shorter answer below, which is observed and conditional,
|
|
5333
|
+
// would be an argument for Ash rather than a reading of it.
|
|
5334
|
+
const block = read.blocks.find((one) => one.id === 'ash');
|
|
5335
|
+
if (block) {
|
|
5336
|
+
metrics(second, [
|
|
5337
|
+
[t('ASH ADDS'), `${block.perTurn} CH`, t('to every prompt it is on · measured, not estimated')],
|
|
5338
|
+
[t('IN TOKENS'), `≈${Math.round(block.perTurn / CH_PER_TOKEN)}`, t('at {rate} characters per token · an estimate', { rate: CH_PER_TOKEN })],
|
|
5339
|
+
[t('TURNS CARRYING IT'), count(block.turns), t('of the {n} weighed in this room', { n: count(read.turns) })],
|
|
5340
|
+
]);
|
|
5341
|
+
second.append(el('p', 'note', t('What Ash costs is certain and measured here. What it saves is not: the shorter answer below is observed, never promised. Output is the dearer half of every bill, so the trade is usually favourable — MADRE does not turn it into money because it does not know your prices.')));
|
|
5342
|
+
}
|
|
5343
|
+
if (read.ash?.agents?.length) ashComparison(second, read.ash);
|
|
5344
|
+
else second.append(el('p', 'note', t('No turns with the switch recorded yet. Leave Ash on for a few turns and off for a few more, and this fills.')));
|
|
5345
|
+
box.append(second);
|
|
5346
|
+
|
|
5347
|
+
// 3 · Where what WAS paid for went.
|
|
5348
|
+
const third = el('div', 'card-block');
|
|
5349
|
+
third.append(el('h5', null, t('WHERE EACH PROMPT GOES')));
|
|
5350
|
+
economyBlocks(third, read);
|
|
5351
|
+
third.append(el('p', 'note', t('{pct} of each prompt is the unchanging head a cache can match. MADRE wrote {chars} characters of the {input} input tokens you were charged for; the rest is what the CLIs read on their own.', { pct: pct(read.totals.prefixShare), chars: count(read.totals.chars), input: count(read.totals.input) })));
|
|
5352
|
+
box.append(third);
|
|
5353
|
+
|
|
5354
|
+
// 4 · One row per agent, because they do not behave alike.
|
|
5355
|
+
// Per agent: turns, two token counts and two shares — five attributes of four different kinds,
|
|
5356
|
+
// which is a table and not a row of bars. A bar can only carry one measure honestly, and these
|
|
5357
|
+
// are read by looking one agent up, not by comparing lengths.
|
|
5358
|
+
const fourth = el('div', 'card-block wide');
|
|
5359
|
+
fourth.append(el('h5', null, t('BY AGENT')));
|
|
5360
|
+
const table = el('table', 'economy-table');
|
|
5361
|
+
const head = el('tr');
|
|
5362
|
+
for (const column of [t('AGENT'), t('TURNS'), t('INPUT'), t('OUTPUT'), t('CACHE'), t('CACHED %'), t('CH / TOKEN')]) head.append(el('th', null, column));
|
|
5363
|
+
table.append(el('thead', null)).lastChild.append(head);
|
|
5364
|
+
const body = el('tbody');
|
|
5365
|
+
for (const agent of read.agents) {
|
|
5366
|
+
const row = el('tr');
|
|
5367
|
+
row.append(el('th', null, String(agent.agent).toUpperCase()));
|
|
5368
|
+
for (const cell of [count(agent.turns), count(agent.input), count(agent.output), count(agent.cached), pct(agent.cacheShare), agent.charsPerInputToken ? agent.charsPerInputToken.toFixed(2) : '—']) {
|
|
5369
|
+
row.append(el('td', null, String(cell)));
|
|
5370
|
+
}
|
|
5371
|
+
body.append(row);
|
|
5372
|
+
}
|
|
5373
|
+
table.append(body);
|
|
5374
|
+
fourth.append(table);
|
|
5375
|
+
box.append(fourth);
|
|
5376
|
+
}
|
|
5377
|
+
|
|
5378
|
+
// The blocks a prompt is made of, widest first. Shared by the card and the panel so the two
|
|
5379
|
+
// cannot drift into saying different things about the same room.
|
|
5380
|
+
function economyBlocks(box, read) {
|
|
5381
|
+
const widest = read.blocks[0]?.chars || 1;
|
|
5382
|
+
const bars = el('div', 'ash-blocks');
|
|
5383
|
+
for (const block of read.blocks.slice(0, 10)) {
|
|
5384
|
+
const row = el('div', 'ash-block');
|
|
5385
|
+
const bar = el('i');
|
|
5386
|
+
bar.style.setProperty('--fill', `${Math.max(2, Math.round((block.chars / widest) * 100))}%`);
|
|
5387
|
+
row.append(el('b', null, block.id.toUpperCase()), bar, el('span', null, `${block.perTurn} CH/TURN${block.always ? ' · ALWAYS' : ''}`));
|
|
5388
|
+
bars.append(row);
|
|
5389
|
+
}
|
|
5390
|
+
box.append(bars);
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
// What Ash has actually done here, in output tokens, per agent.
|
|
5394
|
+
//
|
|
5395
|
+
// Never a saving: the room does not run a turn twice, so the number that would say "you saved
|
|
5396
|
+
// this" was never measured by anybody. This is the middle turn with Ash against the middle turn
|
|
5397
|
+
// without it, in this room, with the count of turns behind each side in plain sight.
|
|
5398
|
+
//
|
|
5399
|
+
// Two bars on one scale, not one bar of a ratio. A single bar had to encode "what fraction of
|
|
5400
|
+
// the usual length is left", which reads backwards — a longer bar meant Ash did less. Two bars
|
|
5401
|
+
// say it the way anybody reads a bar: the shorter one is the shorter answer.
|
|
5402
|
+
function ashComparison(box, ash) {
|
|
5403
|
+
if (!ash?.agents?.length) return;
|
|
5404
|
+
const widest = Math.max(...ash.agents.flatMap((agent) => [agent.on.medianOutput ?? 0, agent.off.medianOutput ?? 0]), 1);
|
|
5405
|
+
const list = el('div', 'ash-compare');
|
|
5406
|
+
for (const agent of ash.agents) {
|
|
5407
|
+
const group = el('div', 'ash-agent');
|
|
5408
|
+
group.append(el('b', null, String(agent.agent).toUpperCase()));
|
|
5409
|
+
if (agent.comparable) {
|
|
5410
|
+
for (const [side, label, turns] of [['on', t('with ASH'), agent.on.turns], ['off', t('without ASH'), agent.off.turns]]) {
|
|
5411
|
+
const row = el('div', `ash-bar ${side}`);
|
|
5412
|
+
const bar = el('i');
|
|
5413
|
+
bar.style.setProperty('--fill', `${Math.max(2, Math.round((agent[side].medianOutput / widest) * 100))}%`);
|
|
5414
|
+
row.append(el('span', 'ash-side', label), bar, el('span', 'ash-figure', t('{n}↑ · {turns} turns', { n: agent[side].medianOutput.toLocaleString(), turns })));
|
|
5415
|
+
group.append(row);
|
|
5416
|
+
}
|
|
5417
|
+
} else {
|
|
5418
|
+
group.append(el('p', 'ash-waiting', t('{n} turns with ASH, {m} without · {min} of each before this can be read', {
|
|
5419
|
+
n: agent.on.turns, m: agent.off.turns, min: ash.minTurns,
|
|
5420
|
+
})));
|
|
5421
|
+
}
|
|
5422
|
+
// Two medians are not a finding. When the gap is smaller than how much this agent's answers
|
|
5423
|
+
// vary anyway, the honest thing on the screen is that there is nothing to read here yet.
|
|
5424
|
+
if (agent.comparable) {
|
|
5425
|
+
group.append(el('p', agent.readable ? 'ash-verdict' : 'ash-verdict noise', agent.readable
|
|
5426
|
+
? t('{pct} shorter with ASH, wider than this agent varies here (±{on} and ±{off})', { pct: `${Math.round(Math.abs(agent.share) * 100)}%`, on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })
|
|
5427
|
+
: t('the difference fits inside how much this agent varies anyway (±{on} and ±{off}) · nothing to read yet', { on: agent.on.spread.toLocaleString(), off: agent.off.spread.toLocaleString() })));
|
|
5428
|
+
}
|
|
5429
|
+
list.append(group);
|
|
5430
|
+
}
|
|
5431
|
+
box.append(list);
|
|
5432
|
+
box.append(el('p', 'note', t('The middle turn of each side, not an average. You choose when to ask for compact prose, so the two sides are different tasks, not the same task twice — MADRE does not claim a percentage saved.')));
|
|
5433
|
+
}
|
|
5434
|
+
|
|
4314
5435
|
// The economy, on the card of the thing it is about. It fills as the room is used, because it
|
|
4315
5436
|
// weighs turns, not history.
|
|
4316
5437
|
function ashReading(panel) {
|
|
4317
5438
|
const box = cardBlock(panel, t('ECONOMY · THIS ROOM'));
|
|
4318
|
-
|
|
4319
|
-
box.append(
|
|
5439
|
+
box.id = 'ash-economy';
|
|
5440
|
+
box.append(el('p', 'note', t('READING…')));
|
|
5441
|
+
paintAshEconomy(box);
|
|
5442
|
+
}
|
|
5443
|
+
|
|
5444
|
+
// The card is the glance, and a glance is four figures and a door. Everything that needs width —
|
|
5445
|
+
// the bars, where each prompt goes, the sentences that say which half is measured — is in the
|
|
5446
|
+
// panel behind SEE THE ECONOMY. A card that tries to be the panel ends up being neither: the
|
|
5447
|
+
// lines wrap out of their box and nothing on it can be read at a glance.
|
|
5448
|
+
function paintAshEconomy(box) {
|
|
4320
5449
|
const pct = (value) => (Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—');
|
|
4321
5450
|
const count = (value) => (Number.isFinite(value) ? value.toLocaleString() : '—');
|
|
4322
|
-
fetch('/api/economy').then((response) => response.json()).then((read) => {
|
|
5451
|
+
return fetch('/api/economy').then((response) => response.json()).then((read) => {
|
|
4323
5452
|
box.replaceChildren(el('h5', null, t('ECONOMY · THIS ROOM')));
|
|
4324
5453
|
if (!read?.turns) {
|
|
4325
5454
|
box.append(el('p', 'note', t('NO TURNS WEIGHED YET · SEND A MESSAGE AND THIS FILLS')));
|
|
@@ -4335,20 +5464,6 @@ function ashReading(panel) {
|
|
|
4335
5464
|
[t('SPENT OUT'), count(totals.output), t('output tokens, the dearer half')],
|
|
4336
5465
|
[t('TURNS'), count(read.turns), t('weighed so far')],
|
|
4337
5466
|
]);
|
|
4338
|
-
// Not a tokenizer: what MADRE wrote against what the CLIs were charged for reading. The
|
|
4339
|
-
// gap between the two is the agents' own system prompts, their tools and the files they
|
|
4340
|
-
// opened during a turn, which is the useful thing this number has to say.
|
|
4341
|
-
box.append(el('p', 'note', t('{pct} OF EACH PROMPT IS THE UNCHANGING HEAD A CACHE CAN MATCH · MADRE WROTE {chars} CHARACTERS OF THE {input} INPUT TOKENS YOU WERE CHARGED FOR; THE REST IS WHAT THE CLIs READ ON THEIR OWN', { pct: pct(totals.prefixShare), chars: count(totals.chars), input: count(totals.input) })));
|
|
4342
|
-
const widest = read.blocks[0]?.chars || 1;
|
|
4343
|
-
const bars = el('div', 'ash-blocks');
|
|
4344
|
-
for (const block of read.blocks.slice(0, 8)) {
|
|
4345
|
-
const row = el('div', 'ash-block');
|
|
4346
|
-
const bar = el('i');
|
|
4347
|
-
bar.style.setProperty('--fill', `${Math.max(2, Math.round((block.chars / widest) * 100))}%`);
|
|
4348
|
-
row.append(el('b', null, block.id.toUpperCase()), bar, el('span', null, `${block.perTurn} CH/TURN${block.always ? ' · ALWAYS' : ''}`));
|
|
4349
|
-
bars.append(row);
|
|
4350
|
-
}
|
|
4351
|
-
box.append(bars);
|
|
4352
5467
|
}).catch(() => { box.replaceChildren(el('h5', null, t('ECONOMY · THIS ROOM')), el('p', 'note', t('ECONOMY UNAVAILABLE'))); });
|
|
4353
5468
|
}
|
|
4354
5469
|
|
|
@@ -4379,7 +5494,7 @@ function builtinCard(item) {
|
|
|
4379
5494
|
for (const [role, model, present, label] of [
|
|
4380
5495
|
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('EMBEDDINGS')],
|
|
4381
5496
|
['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('ARCHIVIST')],
|
|
4382
|
-
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), '@
|
|
5497
|
+
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the room')],
|
|
4383
5498
|
]) {
|
|
4384
5499
|
if (present) {
|
|
4385
5500
|
const box = el('label', 'toggle');
|
|
@@ -4492,7 +5607,10 @@ function builtinCard(item) {
|
|
|
4492
5607
|
toggle.disabled = false;
|
|
4493
5608
|
}
|
|
4494
5609
|
});
|
|
4495
|
-
|
|
5610
|
+
const open = el('button', null, t('SEE THE ECONOMY'));
|
|
5611
|
+
open.type = 'button';
|
|
5612
|
+
open.addEventListener('click', () => openEconomy());
|
|
5613
|
+
actions.append(open, toggle);
|
|
4496
5614
|
return card;
|
|
4497
5615
|
}
|
|
4498
5616
|
|
|
@@ -4680,7 +5798,6 @@ function trackStats(event) {
|
|
|
4680
5798
|
|
|
4681
5799
|
function fmtMs(ms) { return ms === null ? '—' : ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`; }
|
|
4682
5800
|
|
|
4683
|
-
const CAP_LABELS = { read: t('read'), imageIn: t('image in'), write: t('create'), imageGen: t('image gen'), web: t('web') };
|
|
4684
5801
|
function capabilityBadges(id) {
|
|
4685
5802
|
const caps = state.capabilities[id] ?? {};
|
|
4686
5803
|
const wrap = el('div', 'caps');
|
|
@@ -4870,17 +5987,6 @@ const core = {
|
|
|
4870
5987
|
tabs: null,
|
|
4871
5988
|
};
|
|
4872
5989
|
|
|
4873
|
-
// Characters to tokens, in the core. The same estimate the room uses for text nobody was
|
|
4874
|
-
// charged for, kept here because the page cannot import from the server: src/room/economy.mjs
|
|
4875
|
-
// holds the other copy and a test keeps the two equal.
|
|
4876
|
-
//
|
|
4877
|
-
// It is an estimate and it is labelled as one. What this room MEASURES — what MADRE wrote
|
|
4878
|
-
// against what the CLI was charged for reading — is a different quantity: the CLIs bill their
|
|
4879
|
-
// own system prompt, their own tools and every file they open, so in this room that ratio was
|
|
4880
|
-
// 0.62 and called a 14,356-character briefing 23,155 tokens.
|
|
4881
|
-
const CH_PER_TOKEN = 3.5;
|
|
4882
|
-
const tokensFor = (chars) => Math.round((Number(chars) || 0) / CH_PER_TOKEN);
|
|
4883
|
-
|
|
4884
5990
|
const CORE_BOOT = [
|
|
4885
5991
|
t('INTERFACE 2037 · CORE ACCESS'),
|
|
4886
5992
|
t('MU/TH/UR 6000 READY FOR INQUIRY.'),
|
|
@@ -4948,7 +6054,7 @@ async function loadCore() {
|
|
|
4948
6054
|
core.panes?.classList.add('reading');
|
|
4949
6055
|
let briefing;
|
|
4950
6056
|
try {
|
|
4951
|
-
const query = new URLSearchParams({ mode: String(core.mode), ...(core.agent ? { agent: core.agent } : {}), ...(core.text ? { text: core.text } : {}) });
|
|
6057
|
+
const query = new URLSearchParams({ mode: String(core.mode), ash: String(state.ashInstalled && state.ash), ...(core.agent ? { agent: core.agent } : {}), ...(core.text ? { text: core.text } : {}) });
|
|
4952
6058
|
briefing = await fetch(`/api/briefing?${query}`).then((response) => response.json());
|
|
4953
6059
|
if (briefing.error) throw new Error(briefing.error);
|
|
4954
6060
|
} catch (error) {
|
|
@@ -6065,16 +7171,6 @@ function askMotherAbout(conditionId) {
|
|
|
6065
7171
|
|
|
6066
7172
|
/* ---------- NOSTROMO: memory research. The archive as a solar system: the room is a red sun, every distilled memory a smoking planet, plasma between them. ---------- */
|
|
6067
7173
|
|
|
6068
|
-
// Memories burn as stars burn, and a star's colour is its class. A decision is a yellow dwarf,
|
|
6069
|
-
// the steady kind a system is built around. A fact is a white dwarf: cold, dense, settled. A
|
|
6070
|
-
// preference is a red dwarf, dim and personal and very long lived. A question is a blue dwarf,
|
|
6071
|
-
// the hottest thing in the sky and the least settled.
|
|
6072
|
-
const MEMORY_COLORS = { decision: '#ffdc3c', fact: '#dfeeff', preference: '#5cf07a', question: '#3dc6ff', aberration: '#b14cff' };
|
|
6073
|
-
// A memory's kind is an id in the ledger and a word on a screen. Only the word is said in the
|
|
6074
|
-
// room's language: what was written down never moves. Declared one by one, so the catalogue's
|
|
6075
|
-
// guard sees all five and an id nobody translated cannot slip past as itself.
|
|
6076
|
-
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
|
|
6077
|
-
const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
|
|
6078
7174
|
// One kind is not a star. An aberration is a claim the room established is false, and it burns
|
|
6079
7175
|
// nothing: it is a collapsed body with a ring of what fell into it, and it gives off no light
|
|
6080
7176
|
// of its own. It is drawn dark on purpose, because it is the one thing in here nobody should
|