@jossuealcala/madre 1.0.2 → 1.0.3

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.
Files changed (76) hide show
  1. package/CHANGELOG.en.md +69 -1
  2. package/CHANGELOG.md +69 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1626 -151
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +393 -25
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +98 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +307 -30
  33. package/public/styles.css +838 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +4 -2
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server.mjs +617 -48
  76. package/src/setup.mjs +1 -1
@@ -12,11 +12,17 @@ import { brandOf, monogram } from './brands.js';
12
12
  import { startReviewAsk } from './review-ask.js';
13
13
  import { startTour } from './tour.js';
14
14
  import { dropStickyHover } from './touch.js';
15
- import { drawAttribution, drawLegal } from './attribution.js';
15
+ import { drawAttribution, drawColophonKeys, drawLegal } from './attribution.js';
16
16
 
17
17
  // Where nothing can hover (a tablet), a tapped key does not stay lit (public/touch.js).
18
18
  dropStickyHover();
19
19
  import { creep, finishLoader, makeLoader, setLoader, showCurtain } from './loader.js';
20
+ import { labelPowerKey, powerKey } from './power.js';
21
+ import { drawHardware } from './hardware.js';
22
+ import { CUSTOM_WALL, addPicture, customTile, customTools, customWall, paintCustomWall, rememberedWall } from './wallpaper.js';
23
+ import { neuralChosen, rememberNeural } from './neural.js';
24
+ import { openMarket } from './market-view.js';
25
+ import { loadMarket } from './market.js';
20
26
 
21
27
  const $ = (id) => document.getElementById(id);
22
28
  const root = document.documentElement;
@@ -66,8 +72,8 @@ const ACCENTS = ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon',
66
72
  const ACCENT_WORDS = { madre: () => t('Madre green'), mountain: () => t('Mountain green'), caribbean: () => t('Caribbean blue'), phantom: () => t('Phantom purple'), malbec: () => t('Malbec red'), dijon: () => t('Dijon yellow'), pacific: () => t('Pacific blue'), love: () => t('Love pink'), sierra: () => t('Sierra blue'), pitahaya: () => 'Pitahaya', papaya: () => 'Papaya', agave: () => 'Agave', dinamo: () => t('Dynamo black'), marina: () => t('Marina blue'), pulso: () => t('Pulse green'), leal: () => t('Loyal orange'), dulce: () => t('Sweet yellow') };
67
73
  // The wallpapers are the room's (app.js, LOOKS.wallpaper; styles.css, --wp-*): the same images, the
68
74
  // same veil, the same «mesh» and blur the room's PERSONALIZE sets, read from the same keys.
69
- const WALLPAPERS = ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania'];
70
- const WALLPAPER_WORDS = { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania' };
75
+ const WALLPAPERS = ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL];
76
+ const WALLPAPER_WORDS = { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania', custom: () => t('Your picture') };
71
77
  // The conversation's own looks (app.js, LOOKS.bubbles and LOOKS.text): the lobby has no
72
78
  // conversation, so here they are only chosen, for the room served at this address.
73
79
  const BUBBLES = ['comfortable', 'large', 'compact', 'fitted'];
@@ -85,7 +91,7 @@ const HOME_LOOKS = {
85
91
  text: { key: 'pulse.home.text', values: ['comfortable', 'larger', 'smaller'], words: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller') } },
86
92
  order: { key: 'pulse.home.order', values: ['attention', 'name', 'recent', 'mine'], words: { attention: () => t('Attention'), name: () => t('Name'), recent: () => t('Recent'), mine: () => t('Your order') } },
87
93
  };
88
- const LOOK_KEYS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', ...Object.values(HOME_LOOKS).map((one) => one.key)];
94
+ const LOOK_KEYS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', 'pulse.neural', ...Object.values(HOME_LOOKS).map((one) => one.key)];
89
95
  function readLooks() {
90
96
  let theme = 'auto';
91
97
  let accent = null;
@@ -104,7 +110,8 @@ function readLooks() {
104
110
  return {
105
111
  theme: ['auto', 'light', 'dark'].includes(theme) ? theme : 'auto',
106
112
  accent: ACCENTS.includes(accent) ? accent : 'madre',
107
- wallpaper: WALLPAPERS.includes(wallpaper) ? wallpaper : 'default',
113
+ // The person's own picture only while it is here (public/wallpaper.js); taken away, the sky.
114
+ wallpaper: WALLPAPERS.includes(wallpaper) && (wallpaper !== CUSTOM_WALL || customWall()) ? wallpaper : 'default',
108
115
  style: style === 'mesh' ? 'mesh' : 'veil',
109
116
  veil: number(veil, 0, 140, 100),
110
117
  blur: number(blur, 0, 24, 0),
@@ -135,6 +142,8 @@ function applyLooks() {
135
142
  if (looks.home.cards !== 'full') root.dataset.homeCards = looks.home.cards; else delete root.dataset.homeCards;
136
143
  if (looks.home.text !== 'comfortable') root.dataset.homeText = looks.home.text; else delete root.dataset.homeText;
137
144
  }
145
+ // The person's picture as this browser last knew it, before the first look is painted.
146
+ paintCustomWall(rememberedWall());
138
147
  applyLooks();
139
148
  function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
140
149
  // The first value of each look is the room as it always was, kept as no choice at all (app.js, saveLook).
@@ -154,6 +163,7 @@ function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
154
163
  function saveHome(name, value) {
155
164
  const spec = HOME_LOOKS[name];
156
165
  try { if (value === spec.values[0]) localStorage.removeItem(spec.key); else localStorage.setItem(spec.key, value); } catch { /* the choice lasts as long as the page */ }
166
+ pushHomeLayout();
157
167
  applyLooks();
158
168
  if (name === 'order' && typeof draw === 'function') draw();
159
169
  if (name === 'order' && typeof paintOrderKey === 'function') paintOrderKey();
@@ -172,7 +182,7 @@ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change'
172
182
  // app.js's pullLooks; Home's own card choices (HOME_LOOKS) stay here.
173
183
  // The wallpaper is each page's own (2026-10-13): Home keeps the one chosen in Home, each project
174
184
  // the one chosen in it (src/looks.mjs, `rooms`). The rest of the looks are shared.
175
- const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
185
+ const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', 'pulse.neural'];
176
186
  // Before a page had its own, every page wore the shared one: that is where a page starts.
177
187
  const wallpaperOf = (answer) => answer.own?.wallpaper ?? answer.looks?.['pulse.wallpaper'] ?? null;
178
188
  function saveOwnWallpaper(value) {
@@ -187,15 +197,49 @@ function pushLooks() {
187
197
  void fetch('/api/looks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => null);
188
198
  }, 250);
189
199
  }
200
+ // How Home is arranged, the same from every port it is shown on and after MADRE comes back
201
+ // (src/looks.mjs, home.json): written when the person changes it, read when Home opens or comes
202
+ // back into view. The browser's copy stays, so Home paints as it was before it hears from there.
203
+ const HOME_LAYOUT_KEYS = ['pulse.home.layout', 'pulse.home.projects', 'pulse.lobbyBar', 'pulse.home.cards', 'pulse.home.text', 'pulse.home.order'];
204
+ let homePush = 0;
205
+ function pushHomeLayout() {
206
+ clearTimeout(homePush);
207
+ homePush = setTimeout(() => {
208
+ const patch = {};
209
+ try { for (const key of HOME_LAYOUT_KEYS) patch[key] = localStorage.getItem(key); } catch { return; }
210
+ void fetch('/api/home-layout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => null);
211
+ }, 250);
212
+ }
213
+ async function pullHomeLayout() {
214
+ let answer = null;
215
+ try { answer = await fetch('/api/home-layout', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return false; }
216
+ if (!answer?.home) return false;
217
+ try {
218
+ // Nothing kept yet: the arrangement this browser had becomes the one every page shows.
219
+ if (!answer.saved) { if (HOME_LAYOUT_KEYS.some((key) => localStorage.getItem(key) !== null)) pushHomeLayout(); return false; }
220
+ let changed = false;
221
+ for (const key of HOME_LAYOUT_KEYS) {
222
+ const value = answer.home[key] ?? null;
223
+ if (localStorage.getItem(key) === value) continue;
224
+ if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
225
+ changed = true;
226
+ }
227
+ return changed;
228
+ } catch { return false; }
229
+ }
190
230
  async function pullLooks() {
191
231
  let answer = null;
192
232
  try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
193
233
  if (!answer?.looks) return;
234
+ // The person's own picture, kept once for every page (public/wallpaper.js): painted as it is now.
235
+ const before = customWall()?.v ?? null;
236
+ paintCustomWall(answer.custom ?? null);
194
237
  try {
195
238
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
196
239
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
197
- let changed = false;
198
- const wall = wallpaperOf(answer);
240
+ let changed = before !== (answer.custom?.v ?? null);
241
+ let wall = wallpaperOf(answer);
242
+ if (wall === CUSTOM_WALL && !answer.custom) wall = null;
199
243
  for (const [key, value] of [...SHARED_LOOKS.map((one) => [one, answer.looks[one] ?? null]), ['pulse.wallpaper', wall === 'default' ? null : wall]]) {
200
244
  if (localStorage.getItem(key) === value) continue;
201
245
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
@@ -286,6 +330,28 @@ function button(className, words, onClick) {
286
330
  return node;
287
331
  }
288
332
 
333
+ // The other way in for a connection: what the room does, typed in a terminal. Folded, so whoever
334
+ // has never opened one is never asked to; a comment line is said, the rest is what gets copied.
335
+ // The panel repaints every few seconds: which folds are open is kept here, so one never closes on
336
+ // the person reading it. So is which agent is choosing the account to sign in again with.
337
+ const openWays = new Set();
338
+ const choosingAccount = new Set();
339
+ function terminalWay(lines, key) {
340
+ const fold = el('details', 'sm-terminal-way');
341
+ fold.open = openWays.has(key);
342
+ fold.addEventListener('toggle', () => { if (fold.open) openWays.add(key); else openWays.delete(key); });
343
+ fold.append(el('summary', null, t('Prefer your terminal?')));
344
+ const block = el('div', 'sm-terminal-lines');
345
+ for (const line of lines) block.append(line.startsWith('#') ? el('small', 'sm-agent-note', line.slice(1).trim()) : el('code', 'sm-agent-command', line));
346
+ const runnable = lines.filter((line) => !line.startsWith('#'));
347
+ const copy = button('sm-btn small', t('Copy'), async () => {
348
+ try { await navigator.clipboard.writeText(runnable.join('\n')); copy.textContent = t('Copied'); setTimeout(() => { copy.textContent = t('Copy'); }, 1500); }
349
+ catch { copy.textContent = t('Select it'); }
350
+ });
351
+ fold.append(block, copy);
352
+ return fold;
353
+ }
354
+
289
355
  function card(room) {
290
356
  const glance = room.glance ?? {};
291
357
  const state = stateOf(room);
@@ -810,6 +876,9 @@ function linkRow(enlace) {
810
876
  if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
811
877
  // D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
812
878
  if (enlace.signed?.by === 'bishop') what.push(t('signed by the Coordinator'));
879
+ if (enlace.train) what.push(t('the Coordinator learns from it'));
880
+ if (enlace.state) what.push(t('shares its state'));
881
+ if (enlace.deliver) what.push(t('receives delivered notes'));
813
882
  node.append(el('p', 'what', what.join(' · ')));
814
883
  const traffic = enlace.traffic ?? {};
815
884
  // S-021: the last reading says who read, as the room that asked said it.
@@ -828,6 +897,36 @@ function linkRow(enlace) {
828
897
  });
829
898
  recall.append(recallBox, document.createTextNode(t('Always include (up to 3 notes)')));
830
899
  node.append(recall);
900
+ // D-S15: on a link the Coordinator reads, whether it also learns from that project.
901
+ if (reader === data.salas) {
902
+ const learn = el('label', 'row sm-recall');
903
+ const learnBox = el('input'); learnBox.type = 'checkbox'; learnBox.checked = Boolean(enlace.train);
904
+ learnBox.addEventListener('change', async () => {
905
+ learnBox.disabled = true;
906
+ const answer = await fetch('/api/salas/enlaces/train', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: enlace.id, train: learnBox.checked }) })
907
+ .then((response) => response.ok).catch(() => false);
908
+ if (!answer) learnBox.checked = !learnBox.checked;
909
+ learnBox.disabled = false;
910
+ await refresh();
911
+ });
912
+ learn.append(learnBox, document.createTextNode(t('The Coordinator learns from it')));
913
+ node.append(learn);
914
+ }
915
+ // D-S16 and D-S17: what else the link allows, ticked or cleared here by you.
916
+ for (const [field, label] of [['state', t('It shares its state')], ['deliver', t('It receives delivered notes')]]) {
917
+ const allow = el('label', 'row sm-recall');
918
+ const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(enlace[field]);
919
+ box.addEventListener('change', async () => {
920
+ box.disabled = true;
921
+ const answer = await fetch('/api/salas/enlaces/allow', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: enlace.id, field, on: box.checked }) })
922
+ .then((response) => response.ok).catch(() => false);
923
+ if (!answer) box.checked = !box.checked;
924
+ box.disabled = false;
925
+ await refresh();
926
+ });
927
+ allow.append(box, document.createTextNode(label));
928
+ node.append(allow);
929
+ }
831
930
  const actions = el('div', 'sm-actions');
832
931
  if (revoking.has(enlace.id)) actions.append(el('span', 'sm-ask', t('Nothing crosses from then on.')), button('sm-btn danger', t('Revoke it'), () => revokeLink(enlace.id)), button('sm-btn ghost', t('Cancel'), () => { revoking.delete(enlace.id); draw(); }));
833
932
  else actions.append(button('sm-btn ghost', t('Revoke'), () => { revoking.add(enlace.id); draw(); }));
@@ -1308,6 +1407,7 @@ function saveLayout() {
1308
1407
  if (JSON.stringify(layout) === JSON.stringify(DEFAULT_LAYOUT)) localStorage.removeItem(LAYOUT_KEY);
1309
1408
  else localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
1310
1409
  } catch { /* the arrangement lasts as long as the page */ }
1410
+ pushHomeLayout();
1311
1411
  applyLayout();
1312
1412
  }
1313
1413
  const placed = (id) => layout.order.includes(id);
@@ -1608,7 +1708,10 @@ async function openHomeAdd() {
1608
1708
  const list = el('div', 'sm-add-list');
1609
1709
  body.append(list);
1610
1710
  homeAddSheet.append(body);
1611
- homeAddSheet.addEventListener('close', () => { homeAddSheet?.remove(); homeAddSheet = null; });
1711
+ // The key's squares stay filled while the sheet it opened is there.
1712
+ const key = document.getElementById('sm-add-widgets');
1713
+ key?.classList.add('filled');
1714
+ homeAddSheet.addEventListener('close', () => { homeAddSheet?.remove(); homeAddSheet = null; key?.classList.remove('filled'); });
1612
1715
  document.body.append(homeAddSheet);
1613
1716
  homeAddSheet.showModal();
1614
1717
  void homeTour.hint('home-add', list, { inside: true, title: t('Add to Home'), text: t('The tiles you took off, and the widgets. A widget is offered only when something on this Mac feeds it; right-click any tile to move it, resize it or take it off.') });
@@ -1794,6 +1897,10 @@ function makeWidget(id) {
1794
1897
  };
1795
1898
  node.start = () => { if (timer) return; void read(); timer = setInterval(() => void read(), 20000); };
1796
1899
  node.stop = () => { clearInterval(timer); timer = 0; };
1900
+ // Said again in the language Home settled on: a widget is made once, before the first answer
1901
+ // tells Home which language MADRE speaks (a new MADRE starts in Spanish and may adopt English),
1902
+ // and its title stayed in the first one (2026-10-17).
1903
+ node.say = () => { head.firstChild.textContent = spec.name(); if (timer) void read(); };
1797
1904
  return node;
1798
1905
  }
1799
1906
  applyLayout();
@@ -1837,6 +1944,8 @@ homeTour.firstOpen('home-links', ui.bar.links, {
1837
1944
  text: () => t('Which projects can look up what another learned, and the contract each one signed. Nothing is shared without your signature, and you can cancel any connection here.'),
1838
1945
  });
1839
1946
  homeTour.firstOpen('home-map', ui.macro, {
1947
+ // Under the map's own head, never beside Home's key, which the map covers.
1948
+ opened: () => document.querySelector('.mv .mv-head .mother-title'),
1840
1949
  title: () => t('Project map'),
1841
1950
  text: () => t('Your projects as a map. Drag one onto another to connect them, click one to go to it, and press Esc to come back.'),
1842
1951
  after: 900,
@@ -1934,7 +2043,7 @@ function openLinkSheet({ reader = null, source = null } = {}) {
1934
2043
  const linkedPair = (x, y) => (x && y ? (data.enlaces ?? []).find((one) => [one.rooms.a, one.rooms.b].sort().join('\n') === [x, y].sort().join('\n')) ?? null : null);
1935
2044
  const pick = {
1936
2045
  reader: reader ?? (source !== data.salas && rooms.some((room) => room.id === data.salas) ? data.salas : rooms.find((room) => room.id !== source)?.id),
1937
- source: null, both: false, kinds: new Set(['decision', 'fact']), days: 7, recall: false,
2046
+ source: null, both: false, kinds: new Set(['decision', 'fact']), days: 7, recall: false, train: false, state: false, deliver: false,
1938
2047
  orders: false, maxMode: 1, perHour: 6, designation: '',
1939
2048
  };
1940
2049
  const free = (room) => room.id !== pick.reader && room.id !== data.salas && !linkedPair(room.id, pick.reader);
@@ -2215,6 +2324,11 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2215
2324
  ends.append(rail(LINK_DAYS.map(([value, words]) => [value, words()]), pick.days, (value) => { pick.days = value; }, t('It ends')));
2216
2325
  box.append(row(t('It ends'), t('A week is enough to try it; signing it again takes a moment.'), ends, { recommended: pick.days === 7 }));
2217
2326
  box.append(row(t('Always include'), t('With every question it gets up to 3 related notes without looking for them; each time counts toward the hourly limit. Off, its agents look them up only when they need to.'), toggle(pick.recall, (on) => { pick.recall = on; render(); }, t('Always include'))));
2327
+ // D-S15: only with the Coordinator reading, and only by you.
2328
+ if (coordinator(pick.reader)) box.append(row(t('The Coordinator learns from it'), t("Its notes also go into the training of the Coordinator's local AI, so it knows this project without opening it. Clearing it or revoking takes it out of the next export; a model already trained keeps what it learned until you train it again."), toggle(pick.train, (on) => { pick.train = on; render(); }, t('The Coordinator learns from it'))));
2329
+ // D-S16 and D-S17: by you only, off unless you turn them on.
2330
+ box.append(row(t('It shares its state'), t('Besides its notes, its canonical state as Continuity keeps it: objective, phase, next step, its last handoff, decisions and what is still open. Only if the project has Continuity installed; never its files or its code.'), toggle(pick.state, (on) => { pick.state = on; render(); }, t('It shares its state'))));
2331
+ box.append(row(t('It receives delivered notes'), t('The project that reads can hand it notes. They wait there until you accept them (or an agent of that project in #3 EDIT or above): nothing enters its memory on its own.'), toggle(pick.deliver, (on) => { pick.deliver = on; render(); }, t('It receives delivered notes'))));
2218
2332
  page.append(box);
2219
2333
  actions.append(next());
2220
2334
  } else if (kind === 'orders') {
@@ -2246,8 +2360,12 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2246
2360
  said(pick.both ? sayBold('{a} and {b} will look up each other\'s {kinds}.', { a: nameOf(pick.reader), b: nameOf(pick.source), kinds: kindsSaid() }, ['a', 'b']) : sayBold('{reader} will look up the {kinds} of {source}.', { reader: nameOf(pick.reader), source: nameOf(pick.source), kinds: kindsSaid() }, ['reader', 'source']));
2247
2361
  said(pick.days ? t('Until {date}.', { date: until() }) : t('With no expiry: it lasts until you revoke it.'));
2248
2362
  said(pick.recall ? t('With every question, up to 3 of these notes arrive without being looked for.') : t('Its agents look them up only when they need them.'));
2363
+ if (coordinator(pick.reader)) said(pick.train ? sayBold('The Coordinator learns from {room}: its notes go into its training.', { room: nameOf(pick.source) }) : t('The Coordinator does not learn from it: it only looks it up.'));
2249
2364
  if (ordersInto()) said(pick.orders ? sayBold('The Coordinator may send orders into {room}, up to #{n}, {k} an hour.', { room: nameOf(ordersInto()), n: pick.maxMode, k: pick.perHour }, ['room']) : t('No orders: the Coordinator only looks up.'));
2250
- said(t('Nothing is written across, and you can revoke it at any moment from Connections.'));
2365
+ if (pick.state) said(pick.both ? t('Both also read each other\'s state, as Continuity keeps it.') : sayBold('{reader} also reads the state of {source}, as Continuity keeps it.', { reader: nameOf(pick.reader), source: nameOf(pick.source) }, ['reader', 'source']));
2366
+ said(pick.deliver
2367
+ ? (pick.both ? t('Each can deliver notes to the other; they wait until that project accepts them. Nothing is written directly, and you can revoke it at any moment from Connections.') : sayBold('{reader} can deliver notes to {source}; they wait there until accepted. Nothing is written directly, and you can revoke it at any moment from Connections.', { reader: nameOf(pick.reader), source: nameOf(pick.source) }, ['reader', 'source']))
2368
+ : t('Nothing is written across, and you can revoke it at any moment from Connections.'));
2251
2369
  page.append(list);
2252
2370
  const warn = cautions();
2253
2371
  if (warn.length) { const box = card(); for (const words of warn) box.append(caution(words)); page.append(box); }
@@ -2259,6 +2377,9 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2259
2377
  orders: pick.orders && ordersInto() ? { allowed: true, maxMode: pick.maxMode, perHour: pick.perHour } : { allowed: false, maxMode: 0, perHour: pick.perHour },
2260
2378
  designation: pick.orders && ordersInto() ? pick.designation.trim() : undefined,
2261
2379
  recall: pick.recall,
2380
+ train: pick.train && coordinator(pick.reader),
2381
+ state: pick.state,
2382
+ deliver: pick.deliver,
2262
2383
  };
2263
2384
  sign.disabled = true;
2264
2385
  sign.textContent = t('Signing…');
@@ -2302,13 +2423,13 @@ async function refresh() {
2302
2423
  const mine = chosenHere === 'es' || chosenHere === 'en' ? chosenHere : browserLanguage();
2303
2424
  if (await post('/api/language', { language: mine }).then((response) => response?.ok !== false).catch(() => false)) answer.language = mine;
2304
2425
  }
2305
- if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
2426
+ if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); for (const widget of document.querySelectorAll('.sm-widget')) widget.say?.(); homeTour.repaint(); }
2306
2427
  data = answer;
2307
2428
  // A project that came up after Home stopped waiting is open: its card no longer says it did not.
2308
2429
  for (const room of answer.rooms ?? []) if (room.open) failed.delete(room.id);
2309
2430
  ui.foot.textContent = t('Every project runs on this Mac, on its own, with its own memory.');
2310
2431
  // The room's corner (app.js, #colophon): the version this Home runs, and NOTICE's own line.
2311
- if (answer.version) drawAttribution(ui.colophon, answer.version);
2432
+ if (answer.version) { drawAttribution(ui.colophon, answer.version); void drawColophonKeys(ui.colophon); }
2312
2433
  draw();
2313
2434
  }
2314
2435
 
@@ -2492,7 +2613,10 @@ ui.macro.addEventListener('click', async () => {
2492
2613
  // BISHOP at the centre (D-S12): the macroverse draws its body itself (macroverso.js, bishopOrb).
2493
2614
  bishop: data.salas ? { id: data.salas } : null,
2494
2615
  words: macroWords(),
2616
+ // Drawn as neurons when the person chose it (public/neural.js), here or in a project.
2617
+ neural: neuralChosen(),
2495
2618
  actions: {
2619
+ neural: (on) => { rememberNeural(on); pushLooks(); },
2496
2620
  wake: async (room) => { await startRoom(room); },
2497
2621
  // Dragged from one universe onto another: the notes of the first would travel to the second.
2498
2622
  link: (source, reader) => openLinkSheet({ source: source?.id ?? null, reader: reader?.id ?? null }),
@@ -2538,7 +2662,8 @@ function drawTopbar() {
2538
2662
  b.agents.replaceChildren();
2539
2663
  for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
2540
2664
  const state = agentState(agent, agentsSeen);
2541
- const key = button(`sm-agent-key ${state.key}`, null, () => openSettings('agents'));
2665
+ // An agent that cannot answer (no session, not installed) looks switched off, as in the room.
2666
+ const key = button(`sm-agent-key ${state.key}${!state.ok && !state.busyNow ? ' off' : ''}`, null, () => openSettings('agents'));
2542
2667
  key.append(mark(agent.id, 24));
2543
2668
  key.title = `${agent.label ?? agent.id} · ${state.words}`;
2544
2669
  key.setAttribute('aria-label', key.title);
@@ -2614,6 +2739,7 @@ function pinsShown() {
2614
2739
  function pinsWrite(list) {
2615
2740
  pins.saved = [...list];
2616
2741
  try { localStorage.setItem(PIN_KEY, JSON.stringify(pins.saved)); } catch { /* this page only */ }
2742
+ pushHomeLayout();
2617
2743
  drawTopbar();
2618
2744
  settingsSheet?.sync({ detail: true });
2619
2745
  }
@@ -2629,6 +2755,7 @@ function pinRemove(id) { pinsWrite(pinsShown().filter((one) => one !== id)); }
2629
2755
  function pinsReset() {
2630
2756
  pins.saved = null;
2631
2757
  try { localStorage.removeItem(PIN_KEY); } catch { /* this page only */ }
2758
+ pushHomeLayout();
2632
2759
  drawTopbar();
2633
2760
  settingsSheet?.sync({ detail: true });
2634
2761
  }
@@ -3087,17 +3214,19 @@ document.addEventListener('click', (event) => {
3087
3214
  // and over any open sheet, for a few seconds.
3088
3215
  let toastNode = null;
3089
3216
  let toastTimer = 0;
3090
- function toast(words, tone = 'done') {
3217
+ // `action` ({ label, run }) puts one key at its end: the Market's news says «See it» (2026-10-18).
3218
+ function toast(words, tone = 'done', action = null) {
3091
3219
  toastNode?.remove();
3092
3220
  const node = el('div', 'toast sm-toast');
3093
3221
  node.setAttribute('role', 'status');
3094
3222
  node.dataset.tone = tone;
3095
3223
  node.append(el('i', 'toast-dot'), el('span', 'toast-text', words));
3224
+ if (action) node.append(button('toast-act', action.label, () => { node.remove(); action.run(); }));
3096
3225
  document.body.append(node);
3097
3226
  if (typeof node.showPopover === 'function') { node.popover = 'manual'; node.showPopover(); }
3098
3227
  toastNode = node;
3099
3228
  clearTimeout(toastTimer);
3100
- toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, 5000);
3229
+ toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, action ? 9000 : 5000);
3101
3230
  }
3102
3231
 
3103
3232
  // ---- THE LOBBY (2026-10-10). The first screen of MADRE: every project on this Mac, BISHOP above
@@ -3294,7 +3423,7 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3294
3423
  const chosen = new Map(); // path → name
3295
3424
  const node = el('div', 'sm-picker');
3296
3425
  const pages = foldPages();
3297
- let source = 'found'; // found · browse
3426
+ let source = 'found'; // found · browse · new
3298
3427
  let found = null; // the suggestions, once read
3299
3428
  let here = null; // the folder being walked, as /api/salas/folders answered
3300
3429
  let query = '';
@@ -3319,7 +3448,46 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3319
3448
  const error = el('p', 'error');
3320
3449
  footer.append(pages.pager, count);
3321
3450
  if (addButton) footer.append(addButton);
3322
- node.append(head, line, pages.grid, footer, error);
3451
+ // A project from nothing (2026-10-17): its name, where the folder goes (the one being walked in
3452
+ // Your folders, else the home folder) and one key. Made, it is added like any chosen folder.
3453
+ const fresh = el('form', 'sm-new-project');
3454
+ fresh.hidden = true;
3455
+ const nameField = el('label', 'sm-new-name');
3456
+ const nameInput = el('input');
3457
+ nameInput.type = 'text';
3458
+ nameInput.autocomplete = 'off';
3459
+ nameInput.spellcheck = false;
3460
+ nameInput.maxLength = 120;
3461
+ nameInput.placeholder = t('my-new-project');
3462
+ nameField.append(el('span', null, t('Project name')), nameInput);
3463
+ const whereLine = el('p', 'sm-new-where');
3464
+ const makeKey = button('sm-btn primary', t('Create project'), () => {});
3465
+ makeKey.type = 'submit';
3466
+ const freshError = el('p', 'error');
3467
+ fresh.append(nameField, whereLine, makeKey, freshError);
3468
+ const parentNow = () => (here?.path && here.path !== here.home ? here.path : here?.home ?? null);
3469
+ const sayWhere = () => {
3470
+ whereLine.replaceChildren(el('span', null, t('The folder is made in')), ' ', el('code', null, parentNow() ? homeWord(parentNow()) : '~'), ' ');
3471
+ const other = button('sm-new-other', t('Choose another place'), () => { touched = true; source = 'browse'; draw(); if (!here) void walk(); });
3472
+ other.type = 'button';
3473
+ whereLine.append(other);
3474
+ };
3475
+ fresh.addEventListener('submit', async (event) => {
3476
+ event.preventDefault();
3477
+ freshError.textContent = '';
3478
+ makeKey.disabled = true;
3479
+ const answer = await post('/api/salas/new', { name: nameInput.value, parent: parentNow() });
3480
+ makeKey.disabled = false;
3481
+ if (!answer.ok) { freshError.textContent = answer.body.error ?? t('It could not be created.'); nameInput.focus(); return; }
3482
+ const added = answer.body.added ?? [];
3483
+ nameInput.value = '';
3484
+ await refresh();
3485
+ if (added[0]) toast(t('{name} was created and added to Home.', { name: added[0].name }));
3486
+ onAdded?.(added);
3487
+ void loadFound();
3488
+ if (here) void walk(here.path === here.home ? null : here.path);
3489
+ });
3490
+ node.append(head, line, pages.grid, fresh, footer, error);
3323
3491
  const sync = () => {
3324
3492
  count.textContent = chosen.size ? (chosen.size === 1 ? t('1 folder chosen') : t('{n} folders chosen', { n: chosen.size })) : t('Choose one or more folders');
3325
3493
  if (addButton) {
@@ -3336,14 +3504,25 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3336
3504
  onToggle: (on) => { if (on) chosen.set(one.path, one.name); else chosen.delete(one.path); sync(); },
3337
3505
  });
3338
3506
  function draw({ keepPage = false } = {}) {
3339
- const fresh = customSeg([['found', t('Found on this Mac')], ['browse', t('Your folders')]], source, (value) => {
3507
+ const options = customSeg([['found', t('Found on this Mac')], ['browse', t('Your folders')], ['new', t('New project')]], source, (value) => {
3340
3508
  touched = true;
3341
3509
  source = value;
3342
3510
  draw();
3343
- if (value === 'browse' && !here) void walk();
3344
- }, 'two');
3345
- seg.replaceWith(fresh);
3346
- seg = fresh;
3511
+ if (value !== 'found' && !here) void walk();
3512
+ if (value === 'new') nameInput.focus();
3513
+ });
3514
+ seg.replaceWith(options);
3515
+ seg = options;
3516
+ const making = source === 'new';
3517
+ fresh.hidden = !making;
3518
+ pages.grid.hidden = making;
3519
+ footer.hidden = making;
3520
+ search.hidden = making;
3521
+ if (making) {
3522
+ line.replaceChildren(el('span', 'sm-picker-note', t('An empty folder, made for you, that becomes a project.')));
3523
+ sayWhere();
3524
+ return;
3525
+ }
3347
3526
  const noMatch = () => t('No folder matches «{query}».', { query: input.value.trim() });
3348
3527
  if (source === 'found') {
3349
3528
  line.replaceChildren(el('span', 'sm-picker-note', found ? t('Folders that look like projects, in the places people keep them.') : t('Looking…')));
@@ -3382,7 +3561,7 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3382
3561
  const answer = await fetch(`/api/salas/folders${path ? `?path=${encodeURIComponent(path)}` : ''}`, { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
3383
3562
  here = answer ?? { error: t('That folder is not there.') };
3384
3563
  if (path !== null) { query = ''; input.value = ''; }
3385
- if (source === 'browse') draw();
3564
+ if (source !== 'found') draw();
3386
3565
  }
3387
3566
  async function add() {
3388
3567
  error.textContent = '';
@@ -3438,6 +3617,30 @@ function openAddSheet() {
3438
3617
  }
3439
3618
  ui.add.addEventListener('click', openAddSheet);
3440
3619
 
3620
+ // THE MARKET and MODULES (2026-10-18), full screen (market-view.js): the store, and what is on in
3621
+ // which project. From Home a module is turned on for every project or only in one; one that writes
3622
+ // into a project's folder opens that project, on its card, where its commands are read first.
3623
+ const openHomeMarket = (tab = 'shop', focus = null) => openMarket({
3624
+ where: 'home',
3625
+ tab,
3626
+ focus,
3627
+ host: {
3628
+ localAi: () => openSettings('local'),
3629
+ toast: (words) => toast(words),
3630
+ changed: () => drawColophonKeys(ui.colophon),
3631
+ openProject: async (projectId, item) => {
3632
+ const answer = await post('/api/salas/start', { room: projectId });
3633
+ if (!answer.ok || !answer.body.port) return false;
3634
+ window.open(`http://127.0.0.1:${answer.body.port}/#modulo=${encodeURIComponent(item.id)}`, '_blank', 'noopener');
3635
+ return true;
3636
+ },
3637
+ },
3638
+ });
3639
+ document.querySelector('#sm-tb-market')?.addEventListener('click', () => void openHomeMarket('shop'));
3640
+ document.querySelector('#sm-tb-modules')?.addEventListener('click', () => void openHomeMarket('mine'));
3641
+ // The Market's catalog from madre.run, and what it updated on its own since, said once.
3642
+ void loadMarket({ toast: (words, action) => toast(words, action ? 'news' : 'done', action), open: (id) => openHomeMarket('shop', id) });
3643
+
3441
3644
  // The agents of this computer, with everything a connection needs in one place: which account
3442
3645
  // each one signs in with, the doors it has, the box where a pasted key goes, the address a
3443
3646
  // sign-in opened and the code that page gives back. Home used to show a name, a lamp and one
@@ -3446,7 +3649,7 @@ ui.add.addEventListener('click', openAddSheet);
3446
3649
  // `compact` is the welcome's first step: there, a connected agent is done, and a button to change
3447
3650
  // the account it signs in with is a question nobody asked on their first minute.
3448
3651
  // Who makes each agent, said under its name as the project's AJUSTES says it.
3449
- const VENDOR_WORDS = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'OpenCode' };
3652
+ const VENDOR_WORDS = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'OpenCode', cursor: 'Cursor' };
3450
3653
 
3451
3654
  // OpenCode's providers in Home, the same block as the project's AJUSTES › Agents › OpenCode: a line
3452
3655
  // per provider with a key or an account and its switch for the chat, one row to add another, and
@@ -3571,6 +3774,16 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3571
3774
  open.rel = 'noopener';
3572
3775
  box.append(open);
3573
3776
  }
3777
+ // A device sign-in (GitHub Copilot through OpenCode) shows its code instead of asking for one.
3778
+ if (live.deviceCode) {
3779
+ const said = el('div', 'sm-agent-device');
3780
+ said.append(el('span', null, t('Type this code on that page:')), el('code', null, live.deviceCode));
3781
+ const copy = button('sm-btn small', t('Copy'), async () => {
3782
+ try { await navigator.clipboard.writeText(live?.deviceCode ?? ''); copy.textContent = t('Copied'); } catch { copy.textContent = t('Select it'); }
3783
+ });
3784
+ said.append(copy);
3785
+ box.append(said);
3786
+ }
3574
3787
  if (live.asksForCode || live.awaitingCode) {
3575
3788
  const form = el('form', 'sm-agent-code');
3576
3789
  const input = el('input');
@@ -3682,10 +3895,19 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3682
3895
  // One button per account the CLI can sign in with: Claude has two, and only the human can
3683
3896
  // say which one is paying.
3684
3897
  const doorKeys = () => {
3685
- const doors = agent.login?.methods?.length ? agent.login.methods : [{ id: null, label: null, note: agent.login?.note }];
3898
+ // With a session, every agent offers the same «sign in again» as Codex. A single door
3899
+ // (Gemini's Google account) is that door; two doors (Claude's subscription and Console)
3900
+ // are chosen after the click.
3901
+ const methods = agent.login?.methods ?? [];
3902
+ const choosing = choosingAccount.has(agent.id);
3903
+ const doors = !state.ok ? (methods.length ? methods : [{ id: null, label: null, note: agent.login?.note }])
3904
+ : methods.length > 1 && choosing ? methods
3905
+ : [{ id: methods.length === 1 ? methods[0].id : null, label: null, note: methods.length === 1 ? methods[0].note : agent.login?.note, choose: methods.length > 1 }];
3686
3906
  return doors.map((door) => {
3687
3907
  const name = door.label ? t(door.label) : state.ok ? t('Sign in again') : t('Sign in');
3688
- const key = button(`sm-btn ${state.ok ? '' : 'primary'} small`, name, async (event) => {
3908
+ const key = button(`sm-btn ${(state.ok && !choosing) || agent.key?.first ? '' : 'primary'} small`, name, async (event) => {
3909
+ if (door.choose) { choosingAccount.add(agent.id); void paint(); return; }
3910
+ choosingAccount.delete(agent.id);
3689
3911
  event.currentTarget.disabled = true;
3690
3912
  await post(`/api/agents/${agent.id}/login`, { method: door.id });
3691
3913
  void paint();
@@ -3694,6 +3916,9 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3694
3916
  return key;
3695
3917
  });
3696
3918
  };
3919
+ // Gemini's key is its way in (agent.key.first): the field before the Google door.
3920
+ const keyFirst = Boolean(agent.detected && agent.key?.first);
3921
+ if (keyFirst) body.append(keyForm(agent));
3697
3922
  const acts = el('div', 'sm-agent-acts');
3698
3923
  if (!state.busyNow) {
3699
3924
  if (!agent.detected && agent.install) acts.append(button('sm-btn primary small', t('Install'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/install`); void paint(); }));
@@ -3703,9 +3928,12 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3703
3928
  const ask = askBlock(agent);
3704
3929
  if (ask) body.append(ask);
3705
3930
  // A key, pasted here; OpenCode's are its providers', in the block below.
3706
- if (agent.detected && agent.key && agent.id !== 'opencode') body.append(keyForm(agent));
3707
- // A CLI that signs in from its own prompt gets the command, until it has a session.
3708
- if (agent.detected && agent.login && !agent.login.headless && !state.ok) body.append(el('code', 'sm-agent-command', agent.login.display));
3931
+ if (agent.detected && agent.key && agent.id !== 'opencode' && !keyFirst) body.append(keyForm(agent));
3932
+ // The same install or sign-in typed in a terminal, folded for whoever prefers it. Signed in,
3933
+ // OpenCode's providers are added in the block below; its terminal is for a first sign-in.
3934
+ const way = !agent.detected ? agent.install && [agent.install.display, ...(agent.install.alternatives ?? []).map((line) => `# ${t(line)}`)]
3935
+ : agent.login?.terminal && !(agent.id === 'opencode' && state.ok) ? [agent.login.terminal.command, ...(agent.login.terminal.note ? [`# ${t(agent.login.terminal.note)}`] : [])] : null;
3936
+ if (way && !state.busyNow) body.append(terminalWay(way, agent.id));
3709
3937
  if (agent.id === 'opencode' && agent.detected) body.append(openCodeProviders.node());
3710
3938
  item.append(body);
3711
3939
  list.append(item);
@@ -4110,6 +4338,15 @@ function fillLook(host) {
4110
4338
  const papers = el('div', 'custom-papers');
4111
4339
  papers.setAttribute('role', 'radiogroup');
4112
4340
  for (const value of WALLPAPERS) {
4341
+ // The person's own picture: chosen like any other once it is here; until then, the way to add it.
4342
+ if (value === CUSTOM_WALL) {
4343
+ const mine = customWall();
4344
+ const paper = mine ? button('paper', null, () => saveLooks({ wallpaper: CUSTOM_WALL })) : button('paper', null, () => void addPicture({ busy: paper, chosen: () => saveLooks({ wallpaper: CUSTOM_WALL }), failed: (message) => toast(message, 'look') }));
4345
+ if (mine) { paper.setAttribute('role', 'radio'); paper.setAttribute('aria-checked', String(looks.wallpaper === CUSTOM_WALL)); }
4346
+ customTile(paper, mine);
4347
+ papers.append(paper);
4348
+ continue;
4349
+ }
4113
4350
  const paper = button('paper', null, () => saveLooks({ wallpaper: value }));
4114
4351
  paper.dataset.wp = value;
4115
4352
  paper.setAttribute('role', 'radio');
@@ -4146,6 +4383,15 @@ function fillLook(host) {
4146
4383
  return line;
4147
4384
  };
4148
4385
  tune.append(dial('veil', t('Intensity')), dial('blur', t('Blur')));
4386
+ // Your picture: where the screen falls on it, and changing it or taking it away.
4387
+ if (looks.wallpaper === CUSTOM_WALL && customWall()) {
4388
+ tune.append(customTools(customWall(), {
4389
+ button: (words, click) => sxButton(words, click),
4390
+ changed: (kept, { reframed }) => { if (!reframed) settingsSheet?.paintLooks(); },
4391
+ removed: () => saveLooks({ wallpaper: 'default' }),
4392
+ failed: (message) => toast(message, 'look'),
4393
+ }));
4394
+ }
4149
4395
  paperBox.append(tune);
4150
4396
  }
4151
4397
  sxBlock(page, t('Wallpaper'), photo
@@ -4217,8 +4463,11 @@ const LOCAL_MODE_SAYS = {
4217
4463
  manual: () => t('Each project decides in its own Modules › Local AI. New ones start with it.'),
4218
4464
  };
4219
4465
  let localFailed = false; // the last read got no answer: the sheet says so instead of «Reading…» forever
4466
+ let localHardware = null; // what this computer can run and train (src/modules/local-models.mjs)
4220
4467
  async function loadLocal() {
4221
4468
  const answer = await fetch('/api/salas/local-ai', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
4469
+ const ollama = await fetch('/api/ollama', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
4470
+ if (ollama?.recommended?.hardware) localHardware = ollama.recommended.hardware;
4222
4471
  if (answer) localSeen = answer;
4223
4472
  localFailed = !answer;
4224
4473
  settingsSheet?.sync({ detail: true });
@@ -4266,6 +4515,8 @@ function fillLocal(host) {
4266
4515
  sxRow(mode, t('Mode'), null, localModeSeg());
4267
4516
  if (!localSeen.master) sxRow(mode, t('Local AI is off for every project.'), t('Turn it on in a project, in Modules › Local AI.'));
4268
4517
  else if (!localSeen.ollama.running) sxRow(mode, t('Ollama is not running'), t('Projects keep working with their agents meanwhile. Start it from a project, in Modules › Local AI.'));
4518
+ // What this computer can run and train, as a project shows it in Modules › Local AI.
4519
+ if (localHardware) drawHardware(host, localHardware, { el, sxBlock, sxRow, sxChip: (text, tone = '') => el('span', `sx-chip${tone ? ` ${tone}` : ''}`, text) });
4269
4520
  const list = sxBlock(host, t('Projects'), localSeen.mode === 'auto' ? t('All of them use it. Choose «One by one» or «Manual» to turn some off.') : t('A switch here is the same one the project shows in its Settings.'));
4270
4521
  if (!localSeen.projects.length) { sxRow(list, t('No projects yet'), null); return; }
4271
4522
  const pages = Math.ceil(localSeen.projects.length / LOCAL_PER_PAGE);
@@ -4540,6 +4791,10 @@ function openSettings(section = null) {
4540
4791
  show(section);
4541
4792
  }
4542
4793
  ui.gear.addEventListener('click', () => openSettings());
4794
+ // Turning MADRE off (public/power.js), last in the bar, after the settings.
4795
+ const powerButton = powerKey('sm-icon-key bar');
4796
+ powerButton.id = 'sm-power';
4797
+ ui.gear.after(powerButton);
4543
4798
 
4544
4799
  function translateStatic() {
4545
4800
  document.title = `MADRE · ${t('Home')}`;
@@ -4549,6 +4804,8 @@ function translateStatic() {
4549
4804
  ui.bar.open.setAttribute('aria-label', t('Projects on the bar'));
4550
4805
  ui.bar.agents.setAttribute('aria-label', t('Agents'));
4551
4806
  ui.bar.links.querySelector('span').textContent = t('Connections');
4807
+ document.querySelector('#sm-tb-market span')?.replaceChildren(t('Market'));
4808
+ document.querySelector('#sm-tb-modules span')?.replaceChildren(t('Modules'));
4552
4809
  ui.bar.reload.title = t('Reload the page');
4553
4810
  ui.bar.reload.setAttribute('aria-label', t('Reload the page'));
4554
4811
  const addKey = document.getElementById('sm-add-widgets');
@@ -4558,6 +4815,7 @@ function translateStatic() {
4558
4815
  ui.macro.querySelector('span').textContent = t('Project map');
4559
4816
  ui.gear.title = t('Home settings');
4560
4817
  ui.gear.setAttribute('aria-label', t('Home settings'));
4818
+ labelPowerKey(powerButton);
4561
4819
  ui.search.placeholder = t('Search a project…');
4562
4820
  ui.search.setAttribute('aria-label', t('Search a project…'));
4563
4821
  ui.filter.setAttribute('aria-label', t('Show'));
@@ -4588,6 +4846,20 @@ function translateStatic() {
4588
4846
  translateStatic();
4589
4847
  drawWork();
4590
4848
  drawDecisions();
4849
+ // The arrangement kept for every port, before the first full drawing, and again whenever Home
4850
+ // comes back into view (another page may have changed it).
4851
+ const homeArranged = async () => {
4852
+ if (!(await pullHomeLayout())) return;
4853
+ layout = readLayout();
4854
+ pins.saved = null;
4855
+ applyLooks();
4856
+ applyLayout();
4857
+ if (data) { draw(); drawTopbar(); }
4858
+ paintOrderKey();
4859
+ settingsSheet?.paintLooks();
4860
+ };
4861
+ await homeArranged();
4862
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void homeArranged(); });
4591
4863
  await refresh();
4592
4864
  // Arriving from RELOAD (salas-mac.html put the curtain back): Home has read its projects, so the
4593
4865
  // bar fills and the curtain clears.
@@ -4608,6 +4880,11 @@ void loadDecisions();
4608
4880
  if (location.hash === '#welcome') { history.replaceState(null, '', location.pathname); welcomed = true; openWizard(); }
4609
4881
  // Sent here for the Macroverso (the key in BISHOP's room): straight into it, on a large screen.
4610
4882
  if (location.hash === '#macroverso' && data && !ui.macro.hidden) { history.replaceState(null, '', location.pathname); ui.macro.click(); }
4883
+ // Sent here for the Market (a project's MODULES, or a module offered in the conversation): straight in,
4884
+ // on that module's page when one is named.
4885
+ // The Market's addresses: #mercado, #mercado=<module>, #modulos.
4886
+ if (/^#mercado(=|$)/.test(location.hash)) openHomeMarket('shop', decodeURIComponent(location.hash.split('=')[1] ?? '') || null);
4887
+ else if (location.hash === '#modulos') openHomeMarket('mine');
4611
4888
  timer = setInterval(() => { if (!document.hidden && !busy.size && !pins.dropping && !pins.drag) void refresh(); }, 4000);
4612
4889
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { void refresh(); void loadAgents(); void loadWork(); void loadDecisions(); } });
4613
4890
  // The decisions are asked again every few minutes (the route itself asks the rooms no oftener).