@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/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
- const saved = cost.cached ? t(' · {n} saved', { n: brief(cost.cached) }) : '';
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 ?? t('you') })
1739
- : t(' removed by {who}', { who: by ?? t('you') }));
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
- 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.
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('b', null, kindWord(note.kind).toLowerCase()), ` ${note.text.length > 64 ? `${note.text.slice(0, 63)}…` : note.text}`);
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
- 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
+ }
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 · ')), 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)));
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 · ')), 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'));
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 · ')), 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 }));
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((scope) => CAP_LABELS[scope] ?? scope).join(', ');
2167
- node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map((scope) => CAP_LABELS[scope] ?? scope).join(', ') }) : ''} `);
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
- if (event.payload.id === 'ash') syncAshUI(Boolean(event.payload.enabled));
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': 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
+ }
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
- 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;
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
- 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}`;
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
- text: t('Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Do not install it; MADRE will show the human an install card.', { request: rest }),
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
- const waiting = el('p', 'note', t('READING…'));
4319
- 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) {
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), '@MADRE IN THE ROOM'],
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
- 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);
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