@jossuealcala/madre 0.5.2 → 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/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'),
@@ -204,6 +209,31 @@ const formatTokens = (value) => {
204
209
  return String(value);
205
210
  };
206
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
+
207
237
  const formatTime = (iso) => {
208
238
  const date = iso ? new Date(iso) : new Date();
209
239
  return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -251,6 +281,38 @@ function scrollToEnd() {
251
281
  els.thread.scrollTop = els.thread.scrollHeight;
252
282
  }
253
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
+
254
316
  /* ---------- safe Markdown (DOM only, no HTML injection) ---------- */
255
317
 
256
318
  const SAFE_URL = /^https?:\/\//i;
@@ -495,6 +557,361 @@ const ripley = {
495
557
  current: null, // project-relative path of the page on screen
496
558
  root: 'project',
497
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
+
498
915
  function ripleyPathFromPreview(pathname) {
499
916
  const match = String(pathname ?? '').match(/^\/preview\/(project|attachments)\/(.*)$/);
500
917
  return match ? { root: match[1], path: decodeURIComponent(match[2]) } : null;
@@ -505,6 +922,7 @@ function ripleyNavigate(url, { push = true } = {}) {
505
922
  ripley.frame.src = url;
506
923
  ripley.back.disabled = ripley.history.length < 2;
507
924
  }
925
+
508
926
  function ripleyClearErrors() { if (ripley.errors) { ripley.errors.hidden = true; ripley.errors.replaceChildren(); } }
509
927
  function ripleyShowError({ message, source, line }) {
510
928
  if (!ripley.errors || !viewerMode.preview) return;
@@ -611,6 +1029,7 @@ async function openViewer({ root = 'project', path = null, url = null, label = n
611
1029
  viewerMode.kind = null;
612
1030
  syncViewerMode();
613
1031
  if (!viewer.dialog.open) viewer.dialog.showModal();
1032
+
614
1033
  try {
615
1034
  const response = await fetch(src);
616
1035
  if (!response.ok) { const err = await response.json().catch(() => ({})); viewer.body.replaceChildren(el('div', 'err', err.error ?? `HTTP ${response.status}`)); return; }
@@ -796,7 +1215,27 @@ function ringPercent(agent) {
796
1215
  return official === null ? localPercent(agent) : official;
797
1216
  }
798
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
+
799
1237
  function renderAgents() {
1238
+ renderAgentsCompact();
800
1239
  els.agents.replaceChildren();
801
1240
  for (const agent of state.agents.values()) {
802
1241
  const percent = ringPercent(agent);
@@ -1385,7 +1824,7 @@ function row(kind, agentId, { compact = false } = {}) {
1385
1824
 
1386
1825
  function renderUserMessage(event) {
1387
1826
  const { messageId, target, text, ash, model, attachments = [], create, mode } = event.payload;
1388
- state.userMessages.set(messageId, { text, target, model });
1827
+ state.userMessages.set(messageId, { text, target, model, mode: event.payload.mode ?? 1 });
1389
1828
  const reviewed = state.expendable;
1390
1829
  const node = row('user');
1391
1830
  if (event.ghost || mode === 0) node.classList.add('ghost');
@@ -1452,6 +1891,8 @@ function renderAssistantMessage(event) {
1452
1891
  col.append(bubble);
1453
1892
  const used = attachMemoryUsed(event.payload);
1454
1893
  if (used) col.append(used);
1894
+ const needs = attachModeAsk(event.payload);
1895
+ if (needs) col.append(needs);
1455
1896
  const stamp = el('div', 'stamp');
1456
1897
  stamp.id = `usage-${messageId}`;
1457
1898
  stamp.append(el('span', null, formatTime(event.timestamp)));
@@ -1667,6 +2108,31 @@ function showReading(event) {
1667
2108
 
1668
2109
  // The bill arrived: the estimate is replaced by what was actually charged, and stays on the
1669
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
+
1670
2136
  function settleReading(event) {
1671
2137
  const cost = event.payload ?? {};
1672
2138
  if (!cost.responseMessageId) return;
@@ -1678,10 +2144,46 @@ function settleReading(event) {
1678
2144
  // Short enough to sit on the same line as everything else above the bubble. What was read back
1679
2145
  // from the cache is money not spent, so it is the part worth naming.
1680
2146
  const brief = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
1681
- const saved = cost.cached ? t(' · {n} saved', { n: brief(cost.cached) }) : '';
1682
- badge.textContent = `${brief(cost.input)}↓ ${brief(cost.output)}↑${saved}`;
2147
+ badge.textContent = `${brief(cost.input)}↓ ${brief(cost.output)}↑`;
1683
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
+ }
1684
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
+ }
1685
2187
  }
1686
2188
 
1687
2189
  function renderThinking(event) {
@@ -1864,6 +2366,37 @@ function renderDistilled(event) {
1864
2366
  // briefing and nobody ever saw it. Same shape as a memory saved, because the human should have to
1865
2367
  // learn one thing, not two — and a note that came along by association says so, since something
1866
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
+
1867
2400
  function attachMemoryUsed(payload) {
1868
2401
  const used = payload.recalled ?? [];
1869
2402
  if (!used.length) return null;
@@ -1871,18 +2404,28 @@ function attachMemoryUsed(payload) {
1871
2404
  hint.style.setProperty('--agent', agentColor(payload.sender));
1872
2405
  const carried = used.filter((note) => note.via === 'cascade').length;
1873
2406
  hint.append(el('span', 'lead', t('◉ memory used · {n}', { n: used.length }) + (carried ? t(' · {n} by association', { n: carried }) : '')));
1874
- for (const note of used.slice(0, 3)) {
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.
1875
2413
  const pill = el('button', `pill ${note.kind}${note.via === 'cascade' ? ' carried' : ''}`);
1876
2414
  pill.type = 'button';
1877
2415
  pill.style.setProperty('--kind', MEMORY_COLORS[note.kind] ?? MEMORY_COLORS.fact);
1878
- pill.append(el('b', null, kindWord(note.kind).toLowerCase()), ` ${note.text.length > 64 ? `${note.text.slice(0, 63)}…` : note.text}`);
1879
- 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'
1880
2418
  ? t('Came along because this room keeps carrying it with one of the others.')
1881
2419
  : t('The archive matched this to what you asked.')} ${t('Click to see it in NOSTROMO.')}`;
1882
2420
  pill.addEventListener('click', () => { nostromo.focusId = note.id; nostromo.button?.click(); });
1883
- hint.append(pill);
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
+ }
1884
2428
  }
1885
- if (used.length > 3) hint.append(el('span', 'more', `+${used.length - 3}`));
1886
2429
  return hint;
1887
2430
  }
1888
2431
 
@@ -2024,7 +2567,7 @@ function renderControlStarted(event) {
2024
2567
  const { agent, commit, message } = event.payload;
2025
2568
  const node = el('div', 'system control');
2026
2569
  node.style.setProperty('--agent', agentColor(agent));
2027
- node.append(el('b', null, t('CONTROL · ')), el('b', 'who', `@${agent}`), t(' holds the project · checkpoint '), el('code', null, (commit ?? '').slice(0, 7)));
2570
+ node.append(el('b', null, t('CONTROL')), el('b', 'who', `@${agent}`), t(' holds the project · checkpoint '), el('code', null, (commit ?? '').slice(0, 7)));
2028
2571
  node.title = message;
2029
2572
  if (!replaying) toast(t('MU/TH/UR › @{agent} holds CONTROL. Checkpoint taken; UNDO will be one click.', { agent }));
2030
2573
  state.lastSender = null;
@@ -2036,7 +2579,7 @@ function renderControlChanged(event) {
2036
2579
  node.id = `control-${checkpointId}`;
2037
2580
  node.style.setProperty('--agent', agentColor(agent));
2038
2581
  const head = el('div', 'head');
2039
- head.append(el('b', null, t('CONTROL · ')), el('b', 'who', `@${agent}`), files.length ? t(' changed {n} files', { n: files.length }) : t(' changed nothing'));
2582
+ head.append(el('b', null, t('CONTROL')), el('b', 'who', `@${agent}`), files.length ? t(' changed {n} files', { n: files.length }) : t(' changed nothing'));
2040
2583
  node.append(head);
2041
2584
  if (files.length) {
2042
2585
  const list = el('ul', 'files');
@@ -2078,7 +2621,7 @@ function renderControlReverted(event) {
2078
2621
  const { agent, checkpointId, restored = [], removed = [], message } = event.payload;
2079
2622
  const node = el('div', 'system control reverted');
2080
2623
  node.style.setProperty('--agent', agentColor(agent));
2081
- 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 }));
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 }));
2082
2625
  node.title = message;
2083
2626
  const card = document.getElementById(`control-${checkpointId}`);
2084
2627
  card?.querySelector('.undo')?.replaceWith(el('span', 'outcome', t('RESTORED')));
@@ -2240,6 +2783,103 @@ function artifactTiles(files) {
2240
2783
  return wrap;
2241
2784
  }
2242
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
+
2243
2883
  function renderAlert(event) {
2244
2884
  const { message } = event.payload;
2245
2885
  if (!replaying) { state.brakeArmed = true; updateStopAll(); }
@@ -2253,6 +2893,27 @@ function renderAlert(event) {
2253
2893
  return node;
2254
2894
  }
2255
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
+
2256
2917
  function renderHalted(event) {
2257
2918
  const { reason, plans, turns, agents = [] } = event.payload;
2258
2919
  const node = el('div', 'system halted');
@@ -2423,7 +3084,10 @@ function renderEventNode(event) {
2423
3084
  case 'extension.toggled':
2424
3085
  // A module switched on or off changes what the composer accepts.
2425
3086
  if (!replaying) void refreshCommands();
2426
- if (event.payload.id === 'ash') syncAshUI(Boolean(event.payload.enabled));
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));
2427
3091
  if (event.payload.id === 'ripley') { state.ripley = Boolean(event.payload.enabled); syncViewerMode(); }
2428
3092
  if (!replaying) void refreshModules();
2429
3093
  return;
@@ -2433,7 +3097,14 @@ function renderEventNode(event) {
2433
3097
  break;
2434
3098
  case 'agent.started': state.running.set(event.payload.messageId, event.payload.agent); updateStopAll(); node = renderThinking(event); break;
2435
3099
  case 'turn.reading': showReading(event); return;
2436
- case 'turn.cost': settleReading(event); return;
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
+ }
2437
3108
  case 'agent.completed': state.running.delete(event.payload.messageId); updateStopAll(); removeThinking(event.payload.messageId); return;
2438
3109
  case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
2439
3110
  case 'handoff.created': node = renderHandoff(event); break;
@@ -2445,7 +3116,9 @@ function renderEventNode(event) {
2445
3116
  case 'privacy.purged': node = renderPrivacy(event); break;
2446
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;
2447
3118
  case 'memory.forgotten': node = renderForgotten(event); break;
2448
- case 'memory.noted': attachMemoryHint(event); return;
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;
2449
3122
  case 'dataset.exported': return;
2450
3123
  case 'agents.updated': {
2451
3124
  for (const agent of event.payload.agents ?? []) {
@@ -2490,7 +3163,9 @@ function renderEventNode(event) {
2490
3163
  case 'plan.stopped':
2491
3164
  node = renderPlanEvent(event);
2492
3165
  break;
3166
+ case 'choice.offered': node = renderChoice(event); break;
2493
3167
  case 'room.alert': node = renderAlert(event); break;
3168
+ case 'ash.validated': if (event.payload.ok) return; node = renderAshValidation(event); break;
2494
3169
  case 'room.stopped': node = renderHalted(event); break;
2495
3170
  case 'lease.granted': node = renderLease(event); break;
2496
3171
  case 'lease.refused': node = renderLeaseRefused(event); break;
@@ -2509,6 +3184,10 @@ function renderEventNode(event) {
2509
3184
  case 'eyecat.settled': settleEyecat(event); return;
2510
3185
  default: return;
2511
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;
2512
3191
  removeEmpty();
2513
3192
  const stickToBottom = els.thread.scrollHeight - els.thread.scrollTop - els.thread.clientHeight < 120;
2514
3193
  els.column.append(node);
@@ -2645,12 +3324,173 @@ async function loadTreeLevel(path, list) {
2645
3324
  }
2646
3325
  }
2647
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
+
2648
3487
  function treeNode(path, entry) {
2649
3488
  const item = el('li');
2650
3489
  item.setAttribute('role', 'treeitem');
2651
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' : ''}`);
2652
3491
  button.type = 'button';
2653
3492
  button.title = path;
3493
+ button.addEventListener('contextmenu', (event) => finderMenu(event, path, entry));
2654
3494
  if (entry.kind === 'dir') {
2655
3495
  const caret = el('span', 'caret', '▸');
2656
3496
  button.append(caret, el('span', 'name', entry.name));
@@ -2880,6 +3720,13 @@ els.thread.addEventListener('keydown', (event) => trackHold(event.key === 'Arrow
2880
3720
  /* ---------- bootstrap ---------- */
2881
3721
 
2882
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
+ }
2883
3730
  adoptLanguage(initial.language);
2884
3731
  els.project.textContent = initial.projectRoot.split('/').filter(Boolean).at(-1) || initial.projectRoot;
2885
3732
  els.project.title = initial.projectRoot;
@@ -2900,7 +3747,9 @@ state.budget = Number.isFinite(initial.softTokenBudget) && initial.softTokenBudg
2900
3747
  state.timeouts = initial.timeouts ?? {};
2901
3748
  state.sessions = initial.sessions ?? {};
2902
3749
  state.capabilities = initial.capabilities ?? {};
3750
+ state.ripleyWeb = Boolean(initial.ripleyWeb);
2903
3751
  syncAshUI(Boolean(initial.ash?.enabled));
3752
+ void renderSettled();
2904
3753
  state.ripley = Boolean(initial.ripley?.enabled);
2905
3754
  state.projectRoot = initial.projectRoot ?? '';
2906
3755
  state.platform = initial.platform ?? null;
@@ -3051,6 +3900,49 @@ function syncModuleComposer() {
3051
3900
  updatePlaceholder();
3052
3901
  if (active && !state.create) askForModuleMode();
3053
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
+
3054
3946
  els.input.addEventListener('input', syncModuleComposer);
3055
3947
  syncModuleComposer();
3056
3948
 
@@ -3157,7 +4049,15 @@ function showMenu(items, found) {
3157
4049
  button.setAttribute('role', 'option');
3158
4050
  button.setAttribute('aria-selected', String(index === menu.index));
3159
4051
  if (item.color) button.style.setProperty('--agent', item.color);
3160
- button.append(el('span', 'key', item.key), el('span', 'what', item.what));
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}`;
3161
4061
  button.addEventListener('mousedown', (event) => { event.preventDefault(); pickMenu(index); });
3162
4062
  els.slashMenu.append(button);
3163
4063
  });
@@ -3433,6 +4333,16 @@ els.composer.addEventListener('submit', async (event) => {
3433
4333
  const ready = state.pending.filter((item) => item.id && !item.uploading);
3434
4334
  if (!text && !ready.length) return;
3435
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
+ }
3436
4346
  if (STOPALL.test(text)) {
3437
4347
  // Master command: never reaches an agent.
3438
4348
  els.input.value = '';
@@ -4334,15 +5244,211 @@ async function updateModule(item, button) {
4334
5244
  (panel ?? card)?.prepend(box);
4335
5245
  }
4336
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
+
4337
5435
  // The economy, on the card of the thing it is about. It fills as the room is used, because it
4338
5436
  // weighs turns, not history.
4339
5437
  function ashReading(panel) {
4340
5438
  const box = cardBlock(panel, t('ECONOMY · THIS ROOM'));
4341
- const waiting = el('p', 'note', t('READING…'));
4342
- box.append(waiting);
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) {
4343
5449
  const pct = (value) => (Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—');
4344
5450
  const count = (value) => (Number.isFinite(value) ? value.toLocaleString() : '—');
4345
- fetch('/api/economy').then((response) => response.json()).then((read) => {
5451
+ return fetch('/api/economy').then((response) => response.json()).then((read) => {
4346
5452
  box.replaceChildren(el('h5', null, t('ECONOMY · THIS ROOM')));
4347
5453
  if (!read?.turns) {
4348
5454
  box.append(el('p', 'note', t('NO TURNS WEIGHED YET · SEND A MESSAGE AND THIS FILLS')));
@@ -4358,20 +5464,6 @@ function ashReading(panel) {
4358
5464
  [t('SPENT OUT'), count(totals.output), t('output tokens, the dearer half')],
4359
5465
  [t('TURNS'), count(read.turns), t('weighed so far')],
4360
5466
  ]);
4361
- // Not a tokenizer: what MADRE wrote against what the CLIs were charged for reading. The
4362
- // gap between the two is the agents' own system prompts, their tools and the files they
4363
- // opened during a turn, which is the useful thing this number has to say.
4364
- 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) })));
4365
- const widest = read.blocks[0]?.chars || 1;
4366
- const bars = el('div', 'ash-blocks');
4367
- for (const block of read.blocks.slice(0, 8)) {
4368
- const row = el('div', 'ash-block');
4369
- const bar = el('i');
4370
- bar.style.setProperty('--fill', `${Math.max(2, Math.round((block.chars / widest) * 100))}%`);
4371
- row.append(el('b', null, block.id.toUpperCase()), bar, el('span', null, `${block.perTurn} CH/TURN${block.always ? ' · ALWAYS' : ''}`));
4372
- bars.append(row);
4373
- }
4374
- box.append(bars);
4375
5467
  }).catch(() => { box.replaceChildren(el('h5', null, t('ECONOMY · THIS ROOM')), el('p', 'note', t('ECONOMY UNAVAILABLE'))); });
4376
5468
  }
4377
5469
 
@@ -4515,7 +5607,10 @@ function builtinCard(item) {
4515
5607
  toggle.disabled = false;
4516
5608
  }
4517
5609
  });
4518
- actions.append(toggle);
5610
+ const open = el('button', null, t('SEE THE ECONOMY'));
5611
+ open.type = 'button';
5612
+ open.addEventListener('click', () => openEconomy());
5613
+ actions.append(open, toggle);
4519
5614
  return card;
4520
5615
  }
4521
5616
 
@@ -4892,17 +5987,6 @@ const core = {
4892
5987
  tabs: null,
4893
5988
  };
4894
5989
 
4895
- // Characters to tokens, in the core. The same estimate the room uses for text nobody was
4896
- // charged for, kept here because the page cannot import from the server: src/room/economy.mjs
4897
- // holds the other copy and a test keeps the two equal.
4898
- //
4899
- // It is an estimate and it is labelled as one. What this room MEASURES — what MADRE wrote
4900
- // against what the CLI was charged for reading — is a different quantity: the CLIs bill their
4901
- // own system prompt, their own tools and every file they open, so in this room that ratio was
4902
- // 0.62 and called a 14,356-character briefing 23,155 tokens.
4903
- const CH_PER_TOKEN = 3.5;
4904
- const tokensFor = (chars) => Math.round((Number(chars) || 0) / CH_PER_TOKEN);
4905
-
4906
5990
  const CORE_BOOT = [
4907
5991
  t('INTERFACE 2037 · CORE ACCESS'),
4908
5992
  t('MU/TH/UR 6000 READY FOR INQUIRY.'),
@@ -4970,7 +6054,7 @@ async function loadCore() {
4970
6054
  core.panes?.classList.add('reading');
4971
6055
  let briefing;
4972
6056
  try {
4973
- 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 } : {}) });
4974
6058
  briefing = await fetch(`/api/briefing?${query}`).then((response) => response.json());
4975
6059
  if (briefing.error) throw new Error(briefing.error);
4976
6060
  } catch (error) {
@@ -6087,16 +7171,6 @@ function askMotherAbout(conditionId) {
6087
7171
 
6088
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. ---------- */
6089
7173
 
6090
- // Memories burn as stars burn, and a star's colour is its class. A decision is a yellow dwarf,
6091
- // the steady kind a system is built around. A fact is a white dwarf: cold, dense, settled. A
6092
- // preference is a red dwarf, dim and personal and very long lived. A question is a blue dwarf,
6093
- // the hottest thing in the sky and the least settled.
6094
- const MEMORY_COLORS = { decision: '#ffdc3c', fact: '#dfeeff', preference: '#5cf07a', question: '#3dc6ff', aberration: '#b14cff' };
6095
- // A memory's kind is an id in the ledger and a word on a screen. Only the word is said in the
6096
- // room's language: what was written down never moves. Declared one by one, so the catalogue's
6097
- // guard sees all five and an id nobody translated cannot slip past as itself.
6098
- const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
6099
- const kindWord = (kind) => KIND_WORDS[kind] ?? String(kind).toUpperCase();
6100
7174
  // One kind is not a star. An aberration is a claim the room established is false, and it burns
6101
7175
  // nothing: it is a collapsed body with a ring of what fell into it, and it gives off no light
6102
7176
  // of its own. It is drawn dark on purpose, because it is the one thing in here nobody should