@jossuealcala/madre 1.0.1 → 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 (189) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +2013 -0
  3. package/CHANGELOG.md +102 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +241 -0
  8. package/README.md +21 -12
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.cjs +40 -0
  12. package/bin/madre.mjs +7 -0
  13. package/docs/INTERNALS.md +6 -0
  14. package/docs/REFERENCE.md +40 -5
  15. package/docs/SDK.md +4 -2
  16. package/docs/en/INTERNALS.md +103 -0
  17. package/docs/en/REFERENCE.md +365 -0
  18. package/docs/en/SDK.md +308 -0
  19. package/docs/en/training/README.md +59 -0
  20. package/docs/training/README.md +48 -9
  21. package/docs/training/train.sh +7 -2
  22. package/package.json +13 -5
  23. package/public/abduction.js +2 -0
  24. package/public/app.js +3154 -539
  25. package/public/attribution.js +114 -0
  26. package/public/brands.js +23 -6
  27. package/public/code-tools.js +143 -0
  28. package/public/editor.js +22 -4
  29. package/public/es.js +1004 -416
  30. package/public/hardware.js +50 -0
  31. package/public/i18n.js +25 -1
  32. package/public/index.html +18 -11
  33. package/public/inquiry.js +2 -0
  34. package/public/loader.js +2 -0
  35. package/public/macroverso.js +189 -96
  36. package/public/market-art.js +161 -0
  37. package/public/market-view.js +529 -0
  38. package/public/market.js +189 -0
  39. package/public/neural.js +110 -0
  40. package/public/nostromo-engine.js +414 -47
  41. package/public/power.js +100 -0
  42. package/public/resay.js +11 -0
  43. package/public/review-ask.js +2 -0
  44. package/public/routing.js +23 -1
  45. package/public/salas-mac.css +119 -18
  46. package/public/salas-mac.html +14 -2
  47. package/public/salas-mac.js +358 -56
  48. package/public/salas.css +2 -0
  49. package/public/salas.html +1 -0
  50. package/public/salas.js +4 -2
  51. package/public/styles.css +1793 -77
  52. package/public/syntax.js +2 -0
  53. package/public/touch.js +2 -0
  54. package/public/tour-chapters.js +2 -0
  55. package/public/tour.js +84 -5
  56. package/public/troubleshooting.js +24 -17
  57. package/public/wallpaper.js +275 -0
  58. package/src/adapters/claude.mjs +2 -0
  59. package/src/adapters/codex.mjs +2 -0
  60. package/src/adapters/cursor.mjs +96 -0
  61. package/src/adapters/gemini.mjs +13 -4
  62. package/src/adapters/madre.mjs +5 -2
  63. package/src/adapters/opencode.mjs +7 -3
  64. package/src/adapters/process.mjs +2 -0
  65. package/src/asking.mjs +2 -0
  66. package/src/auth-probe.mjs +145 -12
  67. package/src/bridge/devices.mjs +2 -0
  68. package/src/bridge/handoff.mjs +2 -0
  69. package/src/bridge/host.mjs +2 -0
  70. package/src/bridge/network.mjs +2 -0
  71. package/src/bridge/policy.mjs +3 -1
  72. package/src/bridge/qr.mjs +2 -0
  73. package/src/bridge/server.mjs +2 -0
  74. package/src/capabilities.mjs +11 -0
  75. package/src/chats.mjs +2 -0
  76. package/src/checkpoint.mjs +18 -1
  77. package/src/choices.mjs +2 -0
  78. package/src/cli-args.mjs +2 -0
  79. package/src/cold.mjs +2 -0
  80. package/src/commands.mjs +2 -0
  81. package/src/config.mjs +2 -0
  82. package/src/conversation-context.mjs +15 -1
  83. package/src/credentials.mjs +35 -3
  84. package/src/dataset.mjs +78 -4
  85. package/src/directives.mjs +2 -0
  86. package/src/distiller.mjs +2 -0
  87. package/src/embeddings.mjs +2 -0
  88. package/src/enlaces.mjs +214 -6
  89. package/src/event-store.mjs +2 -0
  90. package/src/exam.mjs +6 -4
  91. package/src/extensions.mjs +13 -3
  92. package/src/eyecat-watch.mjs +2 -0
  93. package/src/eyecat.mjs +2 -0
  94. package/src/file-ops.mjs +2 -0
  95. package/src/files.mjs +32 -0
  96. package/src/github.mjs +2 -0
  97. package/src/heartbeat.mjs +2 -0
  98. package/src/i18n.mjs +31 -1
  99. package/src/image-studio.mjs +2 -0
  100. package/src/json-file.mjs +2 -0
  101. package/src/launch.mjs +2 -0
  102. package/src/lease.mjs +5 -1
  103. package/src/lobby-work.mjs +2 -0
  104. package/src/local-ai.mjs +2 -0
  105. package/src/looks.mjs +127 -2
  106. package/src/market-key.mjs +8 -0
  107. package/src/market-remote.mjs +239 -0
  108. package/src/maturity.mjs +3 -1
  109. package/src/mcp/connect.mjs +345 -0
  110. package/src/mcp/github-server.mjs +2 -0
  111. package/src/mcp/image-server.mjs +2 -0
  112. package/src/mcp/memory-server.mjs +2 -0
  113. package/src/mcp/proxy.mjs +153 -0
  114. package/src/mcp/smtp-server.mjs +2 -0
  115. package/src/memory-dashboard.mjs +2 -0
  116. package/src/memory-tools.mjs +2 -0
  117. package/src/memory.mjs +2 -0
  118. package/src/models.mjs +49 -3
  119. package/src/module-scope.mjs +68 -0
  120. package/src/modules/ahp.mjs +104 -7
  121. package/src/modules/ash-policy.mjs +2 -0
  122. package/src/modules/ash.mjs +3 -1
  123. package/src/modules/bishop-exam.mjs +122 -0
  124. package/src/modules/bishop-plan.mjs +5 -3
  125. package/src/modules/bishop.mjs +84 -23
  126. package/src/modules/choices.mjs +2 -0
  127. package/src/modules/correo.mjs +2 -0
  128. package/src/modules/enlaces.mjs +89 -6
  129. package/src/modules/git-pulse.mjs +2 -0
  130. package/src/modules/github.mjs +2 -0
  131. package/src/modules/helpers.mjs +2 -0
  132. package/src/modules/image-studio.mjs +13 -2
  133. package/src/modules/index.mjs +4 -1
  134. package/src/modules/intents-calibration.mjs +2 -0
  135. package/src/modules/intents-desk.mjs +4 -2
  136. package/src/modules/intents-observe.mjs +2 -0
  137. package/src/modules/intents-policy.mjs +4 -1
  138. package/src/modules/intents-try.mjs +2 -0
  139. package/src/modules/intents.mjs +11 -7
  140. package/src/modules/local-models.mjs +124 -0
  141. package/src/modules/mcp-connect.mjs +293 -0
  142. package/src/modules/ollama.mjs +15 -4
  143. package/src/modules/playwright.mjs +88 -15
  144. package/src/modules/ripley.mjs +2 -0
  145. package/src/modules/runs.mjs +5 -3
  146. package/src/modules/sdk.mjs +63 -9
  147. package/src/modules/updates.mjs +2 -0
  148. package/src/mother.mjs +2 -0
  149. package/src/npm-launch.mjs +18 -0
  150. package/src/ollama.mjs +8 -5
  151. package/src/outbound.mjs +16 -6
  152. package/src/override.mjs +2 -0
  153. package/src/ports.mjs +6 -2
  154. package/src/prices.mjs +2 -0
  155. package/src/privacy.mjs +2 -0
  156. package/src/quota-monitor.mjs +2 -0
  157. package/src/quota-sources.mjs +2 -0
  158. package/src/request-guard.mjs +2 -0
  159. package/src/review-ask.mjs +2 -0
  160. package/src/room/archivist.mjs +2 -0
  161. package/src/room/attachments.mjs +2 -0
  162. package/src/room/budget.mjs +2 -0
  163. package/src/room/context.mjs +2 -0
  164. package/src/room/control.mjs +7 -1
  165. package/src/room/copilot.mjs +76 -0
  166. package/src/room/economy.mjs +2 -0
  167. package/src/room/escalation.mjs +2 -0
  168. package/src/room/ghost.mjs +2 -0
  169. package/src/room/guard.mjs +2 -0
  170. package/src/room/prompt.mjs +25 -5
  171. package/src/room/returns.mjs +82 -0
  172. package/src/room/runs.mjs +4 -0
  173. package/src/room/vectors.mjs +2 -0
  174. package/src/room.mjs +223 -35
  175. package/src/router.mjs +3 -1
  176. package/src/runs.mjs +6 -1
  177. package/src/runtime-detection.mjs +11 -0
  178. package/src/salas.mjs +28 -0
  179. package/src/sentinel-errors.mjs +2 -0
  180. package/src/server-sentinel.mjs +2 -0
  181. package/src/server.mjs +757 -81
  182. package/src/setup.mjs +3 -1
  183. package/src/smtp.mjs +2 -0
  184. package/src/tour.mjs +2 -0
  185. package/src/updates.mjs +2 -0
  186. package/src/usage-sentinel.mjs +2 -0
  187. package/src/vault.mjs +2 -0
  188. package/src/verdict.mjs +3 -1
  189. package/src/whats-new.mjs +2 -0
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // SALAS on the Mac: every room this Mac knows, open or asleep, in one place (docs/MACROVERSO.md,
2
4
  // phase 1). The room that serves this page answers for all of them (src/salas.mjs): it lists
3
5
  // them, opens one that is asleep, asks one to close, and stops every agent in every room. A room
@@ -5,15 +7,22 @@
5
7
  //
6
8
  // The phone has its own SALAS (salas.js), served by the bridge; the two read the same rooms.
7
9
 
8
- import { setLanguage, t } from './i18n.js';
10
+ import { browserLanguage, setLanguage, t } from './i18n.js';
9
11
  import { brandOf, monogram } from './brands.js';
10
12
  import { startReviewAsk } from './review-ask.js';
11
13
  import { startTour } from './tour.js';
12
14
  import { dropStickyHover } from './touch.js';
15
+ import { drawAttribution, drawColophonKeys, drawLegal } from './attribution.js';
13
16
 
14
17
  // Where nothing can hover (a tablet), a tapped key does not stay lit (public/touch.js).
15
18
  dropStickyHover();
16
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';
17
26
 
18
27
  const $ = (id) => document.getElementById(id);
19
28
  const root = document.documentElement;
@@ -34,6 +43,7 @@ const ui = {
34
43
  attention: { title: $('sm-attention-title'), count: $('sm-attention-count'), list: $('sm-attention-list') },
35
44
  };
36
45
  let language = 'es';
46
+ let languageAdopted = false;
37
47
  let timer = 0;
38
48
  let arming = 0;
39
49
  let data = null;
@@ -62,8 +72,8 @@ const ACCENTS = ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon',
62
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') };
63
73
  // The wallpapers are the room's (app.js, LOOKS.wallpaper; styles.css, --wp-*): the same images, the
64
74
  // same veil, the same «mesh» and blur the room's PERSONALIZE sets, read from the same keys.
65
- const WALLPAPERS = ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania'];
66
- 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') };
67
77
  // The conversation's own looks (app.js, LOOKS.bubbles and LOOKS.text): the lobby has no
68
78
  // conversation, so here they are only chosen, for the room served at this address.
69
79
  const BUBBLES = ['comfortable', 'large', 'compact', 'fitted'];
@@ -81,7 +91,7 @@ const HOME_LOOKS = {
81
91
  text: { key: 'pulse.home.text', values: ['comfortable', 'larger', 'smaller'], words: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller') } },
82
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') } },
83
93
  };
84
- 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)];
85
95
  function readLooks() {
86
96
  let theme = 'auto';
87
97
  let accent = null;
@@ -100,7 +110,8 @@ function readLooks() {
100
110
  return {
101
111
  theme: ['auto', 'light', 'dark'].includes(theme) ? theme : 'auto',
102
112
  accent: ACCENTS.includes(accent) ? accent : 'madre',
103
- 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',
104
115
  style: style === 'mesh' ? 'mesh' : 'veil',
105
116
  veil: number(veil, 0, 140, 100),
106
117
  blur: number(blur, 0, 24, 0),
@@ -131,6 +142,8 @@ function applyLooks() {
131
142
  if (looks.home.cards !== 'full') root.dataset.homeCards = looks.home.cards; else delete root.dataset.homeCards;
132
143
  if (looks.home.text !== 'comfortable') root.dataset.homeText = looks.home.text; else delete root.dataset.homeText;
133
144
  }
145
+ // The person's picture as this browser last knew it, before the first look is painted.
146
+ paintCustomWall(rememberedWall());
134
147
  applyLooks();
135
148
  function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
136
149
  // The first value of each look is the room as it always was, kept as no choice at all (app.js, saveLook).
@@ -150,6 +163,7 @@ function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
150
163
  function saveHome(name, value) {
151
164
  const spec = HOME_LOOKS[name];
152
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();
153
167
  applyLooks();
154
168
  if (name === 'order' && typeof draw === 'function') draw();
155
169
  if (name === 'order' && typeof paintOrderKey === 'function') paintOrderKey();
@@ -168,7 +182,7 @@ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change'
168
182
  // app.js's pullLooks; Home's own card choices (HOME_LOOKS) stay here.
169
183
  // The wallpaper is each page's own (2026-10-13): Home keeps the one chosen in Home, each project
170
184
  // the one chosen in it (src/looks.mjs, `rooms`). The rest of the looks are shared.
171
- 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'];
172
186
  // Before a page had its own, every page wore the shared one: that is where a page starts.
173
187
  const wallpaperOf = (answer) => answer.own?.wallpaper ?? answer.looks?.['pulse.wallpaper'] ?? null;
174
188
  function saveOwnWallpaper(value) {
@@ -183,15 +197,49 @@ function pushLooks() {
183
197
  void fetch('/api/looks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => null);
184
198
  }, 250);
185
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
+ }
186
230
  async function pullLooks() {
187
231
  let answer = null;
188
232
  try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
189
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);
190
237
  try {
191
238
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
192
239
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
193
- let changed = false;
194
- 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;
195
243
  for (const [key, value] of [...SHARED_LOOKS.map((one) => [one, answer.looks[one] ?? null]), ['pulse.wallpaper', wall === 'default' ? null : wall]]) {
196
244
  if (localStorage.getItem(key) === value) continue;
197
245
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
@@ -230,8 +278,8 @@ const modeName = (n) => [t('INCOGNITO'), t('CHAT ONLY'), t('CREATE'), t('EDIT'),
230
278
  function askLine(ask) {
231
279
  if (ask.kind === 'mode') return t('@{agent} asks for #{n} {label}', { agent: ask.agent, n: ask.mode, label: modeName(ask.mode) });
232
280
  if (ask.kind === 'run') return ask.text ? t('@{agent} wants to run {line}', { agent: ask.agent, line: ask.text }) : t('@{agent} wants to run a command', { agent: ask.agent });
233
- if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: unlock it with your key', { n: ask.mode, agent: ask.agent });
234
- if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for your press: {reason}', { reason: ask.text }) : t('The Coordinator waits for your press');
281
+ if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: confirm it to unlock it', { n: ask.mode, agent: ask.agent });
282
+ if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for you to confirm: {reason}', { reason: ask.text }) : t('The Coordinator waits for you to confirm');
235
283
  return ask.text ? t('@{agent} asks you: {question}', { agent: ask.agent, question: ask.text }) : t('@{agent} asks you to choose', { agent: ask.agent });
236
284
  }
237
285
  // The folder, as a person writes it: the home folder is ~.
@@ -241,7 +289,7 @@ const addressOf = (room) => (room.here ? '/' : `http://127.0.0.1:${room.open.por
241
289
 
242
290
  // The memory kinds, in the colours NOSTROMO gives them (styles.css, --mem-*).
243
291
  const KINDS = ['decision', 'fact', 'preference', 'question', 'aberration'];
244
- const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
292
+ const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('DOUBTFUL MEMORY') };
245
293
  // A kind as a sentence says it: «Decisión», not «DECISIÓN».
246
294
  const kindWord = (kind) => { const word = String(KIND_WORDS[kind] ?? kind).toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1); };
247
295
 
@@ -282,6 +330,28 @@ function button(className, words, onClick) {
282
330
  return node;
283
331
  }
284
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
+
285
355
  function card(room) {
286
356
  const glance = room.glance ?? {};
287
357
  const state = stateOf(room);
@@ -338,7 +408,7 @@ function card(room) {
338
408
  if (room.open && glance.census) {
339
409
  node.append(memoryStrip(glance.census));
340
410
  const counts = [conversationsCount(glance.chats ?? 0), memoriesCount(glance.census.memories)];
341
- if (glance.census.memories) counts.push(t('{n} travel', { n: glance.census.travelling }));
411
+ if (glance.census.memories) counts.push(t('{n} in use', { n: glance.census.travelling }));
342
412
  node.append(el('p', 'sm-counts', counts.join(' · ')));
343
413
  }
344
414
 
@@ -563,7 +633,7 @@ function drawMemory() {
563
633
  const item = el('span', `k-${kind}`, `${kindWord(kind)} · ${sum.kinds[kind]}`);
564
634
  legend.append(item);
565
635
  }
566
- ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} travel', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
636
+ ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} in use', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
567
637
  }
568
638
 
569
639
  // AGENTS: the ones on this computer, with the session each has. Connected once, every project
@@ -777,7 +847,7 @@ function drawWork() {
777
847
  w.note.textContent = state === 'working' ? t('MADRE is summarising your latest conversations…')
778
848
  : state === 'unavailable' ? (bullets.length ? t('MADRE\'s local model is off: this is the last summary it wrote.') : t('MADRE\'s local model is off: these are your latest conversations, as they are.'))
779
849
  : state === 'failed' ? t('MADRE could not summarise them this time: these are your latest conversations, as they are.')
780
- : work.stale ? t('There is newer work than this summary: it is written again in a few minutes, or now if you ask.')
850
+ : work.stale ? t('There is newer work than this summary: it updates itself in a few minutes, or now if you press Summarize again.')
781
851
  : t('Written by MADRE on this Mac from your latest conversations.');
782
852
  if (typeof fitTiles === 'function') fitTiles();
783
853
  }
@@ -806,6 +876,9 @@ function linkRow(enlace) {
806
876
  if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
807
877
  // D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
808
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'));
809
882
  node.append(el('p', 'what', what.join(' · ')));
810
883
  const traffic = enlace.traffic ?? {};
811
884
  // S-021: the last reading says who read, as the room that asked said it.
@@ -824,6 +897,36 @@ function linkRow(enlace) {
824
897
  });
825
898
  recall.append(recallBox, document.createTextNode(t('Always include (up to 3 notes)')));
826
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
+ }
827
930
  const actions = el('div', 'sm-actions');
828
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(); }));
829
932
  else actions.append(button('sm-btn ghost', t('Revoke'), () => { revoking.add(enlace.id); draw(); }));
@@ -843,7 +946,7 @@ function drawLinks(projects) {
843
946
  ui.linksSum.textContent = enlaces.length
844
947
  ? [enlaces.length === 1 ? t('1 in force') : t('{n} in force', { n: enlaces.length }), t('{n} reads in 24 h', { n: reads })].join(' · ')
845
948
  : enough ? t('No connections yet.') : t('Add a project to connect it.');
846
- ui.linksNote.textContent = t('A connection lets one project read the distilled notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes that travel into turns cross.');
949
+ ui.linksNote.textContent = t('A connection lets one project read the summarized notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes already used in answers are shared.');
847
950
  ui.links.replaceChildren(...enlaces.map(linkRow));
848
951
  if (!enlaces.length && ui.linksSheet.open) ui.linksSheet.close();
849
952
  drawMap(projects, enlaces);
@@ -1247,7 +1350,7 @@ const SIZES = [[4, () => t('Small')], [6, () => t('Medium')], [8, () => t('Large
1247
1350
  // not fit in a chosen height scrolls inside the tile.
1248
1351
  const HEIGHTS = [[0, () => t('Automatic')], [2, () => t('2 rows')], [3, () => t('3 rows')], [4, () => t('4 rows')], [5, () => t('5 rows')]];
1249
1352
  const HOME_TILES = {
1250
- bishop: { id: 'sm-bishop', name: () => t('Coordinator'), about: () => t('Your coordinator, its state and its recent summary.'), size: 8, rows: 5 },
1353
+ bishop: { id: 'sm-bishop', name: () => t('Coordinator'), about: () => t('Your Coordinator, its state and its recent summary.'), size: 8, rows: 5 },
1251
1354
  waiting: { id: 'sm-waiting', name: () => t('Waiting for you'), about: () => t('The questions and permissions an agent is waiting on.'), size: 4, rows: 2 },
1252
1355
  links: { id: 'sm-links-tile', name: () => t('Connections'), about: () => t('The map of who looks up whom.'), size: 4, rows: 3 },
1253
1356
  projects: { id: 'sm-drawer', name: () => t('Projects'), about: () => t('Every project, open or asleep, three rows at a time.'), size: 12, sizes: [12], kept: true },
@@ -1259,7 +1362,7 @@ const WIDGETS = {
1259
1362
  'w-system': { kind: 'system', name: () => t('System resources'), about: () => t('How busy this Mac\'s processor is, and how much memory is free.'), size: 4, rows: 3 },
1260
1363
  'w-servers': { kind: 'servers', name: () => t('Servers'), about: () => t('The servers running in your open projects, and whether they answer.'), size: 4, rows: 3, needs: () => t('Turn on Preview in a project\'s Modules to see its servers.') },
1261
1364
  'w-memory': { kind: 'memory', name: () => t('Memory quality'), about: () => t('How grown each open project\'s memory is, and its weakest side.'), size: 4, rows: 3, needs: () => t('Open a project to read its memory.') },
1262
- 'w-coordinator': { kind: 'coordinator', name: () => t('Coordinator training'), about: () => t('How far the Coordinator\'s own record is from a model of its own.'), size: 4, rows: 3 },
1365
+ 'w-coordinator': { kind: 'coordinator', name: () => t('Coordinator training'), about: () => t('How far the Coordinator\'s own record is from training a model of its own.'), size: 4, rows: 3 },
1263
1366
  // Only for MADRE's author, in a copy run from source with the author's token (src/server.mjs, homeWidget):
1264
1367
  // anywhere else the server has no such widget and Home never offers it.
1265
1368
  'w-usage': { kind: 'usage', owner: true, name: () => t('MADRE usage'), about: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'), size: 8, rows: 3 },
@@ -1304,6 +1407,7 @@ function saveLayout() {
1304
1407
  if (JSON.stringify(layout) === JSON.stringify(DEFAULT_LAYOUT)) localStorage.removeItem(LAYOUT_KEY);
1305
1408
  else localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
1306
1409
  } catch { /* the arrangement lasts as long as the page */ }
1410
+ pushHomeLayout();
1307
1411
  applyLayout();
1308
1412
  }
1309
1413
  const placed = (id) => layout.order.includes(id);
@@ -1604,7 +1708,10 @@ async function openHomeAdd() {
1604
1708
  const list = el('div', 'sm-add-list');
1605
1709
  body.append(list);
1606
1710
  homeAddSheet.append(body);
1607
- 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'); });
1608
1715
  document.body.append(homeAddSheet);
1609
1716
  homeAddSheet.showModal();
1610
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.') });
@@ -1727,7 +1834,7 @@ const WIDGET_PAINT = {
1727
1834
  const top = el('div', 'sm-w-line');
1728
1835
  top.append(el('b', null, one.name), el('span', 'sm-w-stage', one.stage ?? '—'), el('small', null, pct(one.score)));
1729
1836
  row.append(top, meter(one.score));
1730
- const notes = [one.weakest?.label ? t('weakest: {what}', { what: one.weakest.label.toLowerCase() }) : null, one.aberrations ? t('{n} odd memories', { n: one.aberrations }) : null].filter(Boolean);
1837
+ const notes = [one.weakest?.label ? t('weakest: {what}', { what: one.weakest.label.toLowerCase() }) : null, one.aberrations ? t('{n} doubtful memories', { n: one.aberrations }) : null].filter(Boolean);
1731
1838
  if (notes.length) row.append(el('small', null, notes.join(' · ')));
1732
1839
  grid.append(row);
1733
1840
  }
@@ -1790,6 +1897,10 @@ function makeWidget(id) {
1790
1897
  };
1791
1898
  node.start = () => { if (timer) return; void read(); timer = setInterval(() => void read(), 20000); };
1792
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(); };
1793
1904
  return node;
1794
1905
  }
1795
1906
  applyLayout();
@@ -1833,6 +1944,8 @@ homeTour.firstOpen('home-links', ui.bar.links, {
1833
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.'),
1834
1945
  });
1835
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'),
1836
1949
  title: () => t('Project map'),
1837
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.'),
1838
1951
  after: 900,
@@ -1876,6 +1989,15 @@ function welcomeHome() {
1876
1989
  : t('A connection lets one project look up what another learned, by a contract you sign. Nothing is written across.'),
1877
1990
  });
1878
1991
  }, 2200);
1992
+ // 1.0.2 brings one key to Home's bar: said once, after any other card, never over a sheet.
1993
+ let tries = 0;
1994
+ const sayAdd = async () => {
1995
+ const key = document.getElementById('sm-add-widgets');
1996
+ if (!key || document.querySelector('dialog[open]') || !(await homeTour.hint('new-add-widgets', key, { title: t('New: add widgets'), text: t('This button opens «Add to Home»: choose the widgets you want to see here, or bring back the ones you removed.') }))) {
1997
+ if ((tries += 1) < 6) setTimeout(sayAdd, 6000)?.unref?.();
1998
+ }
1999
+ };
2000
+ setTimeout(sayAdd, 5000)?.unref?.();
1879
2001
  });
1880
2002
  }
1881
2003
 
@@ -1921,7 +2043,7 @@ function openLinkSheet({ reader = null, source = null } = {}) {
1921
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);
1922
2044
  const pick = {
1923
2045
  reader: reader ?? (source !== data.salas && rooms.some((room) => room.id === data.salas) ? data.salas : rooms.find((room) => room.id !== source)?.id),
1924
- 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,
1925
2047
  orders: false, maxMode: 1, perHour: 6, designation: '',
1926
2048
  };
1927
2049
  const free = (room) => room.id !== pick.reader && room.id !== data.salas && !linkedPair(room.id, pick.reader);
@@ -2202,11 +2324,16 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2202
2324
  ends.append(rail(LINK_DAYS.map(([value, words]) => [value, words()]), pick.days, (value) => { pick.days = value; }, t('It ends')));
2203
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 }));
2204
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'))));
2205
2332
  page.append(box);
2206
2333
  actions.append(next());
2207
2334
  } else if (kind === 'orders') {
2208
2335
  const name = nameOf(ordersInto());
2209
- intro(t('Orders from the coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. Edit (#3) and Full access (#4) never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
2336
+ intro(t('Orders from the Coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. #3 EDIT and #4 FULL ACCESS never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
2210
2337
  const box = card();
2211
2338
  box.append(row(sayBold('Allow orders into {room}', { room: name }), sayBold('Leave it off unless you need the Coordinator to act in {room}.', { room: name }), toggle(pick.orders, (on) => { pick.orders = on; render(); }, t('Allow orders into {room}', { room: name })), { recommended: !pick.orders }));
2212
2339
  if (pick.orders) {
@@ -2233,8 +2360,12 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2233
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']));
2234
2361
  said(pick.days ? t('Until {date}.', { date: until() }) : t('With no expiry: it lasts until you revoke it.'));
2235
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.'));
2236
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.'));
2237
- 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.'));
2238
2369
  page.append(list);
2239
2370
  const warn = cautions();
2240
2371
  if (warn.length) { const box = card(); for (const words of warn) box.append(caution(words)); page.append(box); }
@@ -2246,6 +2377,9 @@ function openLinkSheet({ reader = null, source = null } = {}) {
2246
2377
  orders: pick.orders && ordersInto() ? { allowed: true, maxMode: pick.maxMode, perHour: pick.perHour } : { allowed: false, maxMode: 0, perHour: pick.perHour },
2247
2378
  designation: pick.orders && ordersInto() ? pick.designation.trim() : undefined,
2248
2379
  recall: pick.recall,
2380
+ train: pick.train && coordinator(pick.reader),
2381
+ state: pick.state,
2382
+ deliver: pick.deliver,
2249
2383
  };
2250
2384
  sign.disabled = true;
2251
2385
  sign.textContent = t('Signing…');
@@ -2280,16 +2414,22 @@ async function refresh() {
2280
2414
  const answer = await refreshPending;
2281
2415
  refreshPending = null;
2282
2416
  if (!answer) { ui.foot.textContent = t('This project did not answer. It may have closed.'); ui.bar.live.classList.add('is-off'); ui.bar.live.lastChild.textContent = t('Not answering right now'); return; }
2283
- if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
2417
+ // Home is the first screen of `npx madre`: a MADRE nobody has set takes this browser's language
2418
+ // here, once, and every project follows (public/i18n.js, browserLanguage).
2419
+ if (answer.languageChosen === false && !languageAdopted) {
2420
+ languageAdopted = true;
2421
+ let chosenHere = null;
2422
+ try { chosenHere = localStorage.getItem('pulse.language'); } catch { /* no storage */ }
2423
+ const mine = chosenHere === 'es' || chosenHere === 'en' ? chosenHere : browserLanguage();
2424
+ if (await post('/api/language', { language: mine }).then((response) => response?.ok !== false).catch(() => false)) answer.language = mine;
2425
+ }
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(); }
2284
2427
  data = answer;
2285
2428
  // A project that came up after Home stopped waiting is open: its card no longer says it did not.
2286
2429
  for (const room of answer.rooms ?? []) if (room.open) failed.delete(room.id);
2287
2430
  ui.foot.textContent = t('Every project runs on this Mac, on its own, with its own memory.');
2288
2431
  // The room's corner (app.js, #colophon): the version this Home runs, and NOTICE's own line.
2289
- if (answer.version) {
2290
- ui.colophon.replaceChildren(el('b', null, `MADRE ${answer.version}`), document.createTextNode('\u00a0·\u00a0© 2026 Jossue Alcalá'));
2291
- ui.colophon.hidden = false;
2292
- }
2432
+ if (answer.version) { drawAttribution(ui.colophon, answer.version); void drawColophonKeys(ui.colophon); }
2293
2433
  draw();
2294
2434
  }
2295
2435
 
@@ -2387,7 +2527,7 @@ function macroWords() {
2387
2527
  back: t('Back to Home'),
2388
2528
  hint: t('Drag the space to move it · wheel to come closer · click a project to look at it · drag it to turn it · carry one onto the Coordinator for the Coordinator to read it, or onto another to connect them · ← → from one to the next · Esc to come back'),
2389
2529
  bishop: t('Coordinator'),
2390
- bishopAbout: t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
2530
+ bishopAbout: t('Your Coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
2391
2531
  bishopReads: (n) => (n ? (n === 1 ? t('It reads 1 project by a connection') : t('It reads {n} projects by connections', { n })) : t('It reads no project yet: each one it reads is a connection you sign.')),
2392
2532
  notLinked: t('Not connected yet: touch one to connect it'),
2393
2533
  talk: t('Talk to the Coordinator'),
@@ -2395,7 +2535,7 @@ function macroWords() {
2395
2535
  recenter: t('RECENTER'),
2396
2536
  // The legend under its name, in the Memory map's words.
2397
2537
  legend: t('Memory kinds'),
2398
- kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('OUT OF CIRCULATION') },
2538
+ kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('NOT USED') },
2399
2539
  veins: (n) => t('{n} connections', { n }),
2400
2540
  links: t('Connections'),
2401
2541
  link: t('Connect…'),
@@ -2473,7 +2613,10 @@ ui.macro.addEventListener('click', async () => {
2473
2613
  // BISHOP at the centre (D-S12): the macroverse draws its body itself (macroverso.js, bishopOrb).
2474
2614
  bishop: data.salas ? { id: data.salas } : null,
2475
2615
  words: macroWords(),
2616
+ // Drawn as neurons when the person chose it (public/neural.js), here or in a project.
2617
+ neural: neuralChosen(),
2476
2618
  actions: {
2619
+ neural: (on) => { rememberNeural(on); pushLooks(); },
2477
2620
  wake: async (room) => { await startRoom(room); },
2478
2621
  // Dragged from one universe onto another: the notes of the first would travel to the second.
2479
2622
  link: (source, reader) => openLinkSheet({ source: source?.id ?? null, reader: reader?.id ?? null }),
@@ -2519,7 +2662,8 @@ function drawTopbar() {
2519
2662
  b.agents.replaceChildren();
2520
2663
  for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
2521
2664
  const state = agentState(agent, agentsSeen);
2522
- 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'));
2523
2667
  key.append(mark(agent.id, 24));
2524
2668
  key.title = `${agent.label ?? agent.id} · ${state.words}`;
2525
2669
  key.setAttribute('aria-label', key.title);
@@ -2595,6 +2739,7 @@ function pinsShown() {
2595
2739
  function pinsWrite(list) {
2596
2740
  pins.saved = [...list];
2597
2741
  try { localStorage.setItem(PIN_KEY, JSON.stringify(pins.saved)); } catch { /* this page only */ }
2742
+ pushHomeLayout();
2598
2743
  drawTopbar();
2599
2744
  settingsSheet?.sync({ detail: true });
2600
2745
  }
@@ -2610,6 +2755,7 @@ function pinRemove(id) { pinsWrite(pinsShown().filter((one) => one !== id)); }
2610
2755
  function pinsReset() {
2611
2756
  pins.saved = null;
2612
2757
  try { localStorage.removeItem(PIN_KEY); } catch { /* this page only */ }
2758
+ pushHomeLayout();
2613
2759
  drawTopbar();
2614
2760
  settingsSheet?.sync({ detail: true });
2615
2761
  }
@@ -3038,6 +3184,8 @@ function reloadWithGrace() {
3038
3184
  }, 900);
3039
3185
  }
3040
3186
  ui.bar.reload.addEventListener('click', reloadWithGrace);
3187
+ // «Add to Home» had only a right-click and a menu; now a key of its own in the bar (2026-10-14).
3188
+ document.getElementById('sm-add-widgets')?.addEventListener('click', () => openHomeAdd());
3041
3189
 
3042
3190
  // From Home into a project, and back (2026-10-10): the page settles back under a veil of its own
3043
3191
  // ground and goes (styles.css, madre-leaving); the room lifts the veil as it arrives. Every way in
@@ -3066,17 +3214,19 @@ document.addEventListener('click', (event) => {
3066
3214
  // and over any open sheet, for a few seconds.
3067
3215
  let toastNode = null;
3068
3216
  let toastTimer = 0;
3069
- 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) {
3070
3219
  toastNode?.remove();
3071
3220
  const node = el('div', 'toast sm-toast');
3072
3221
  node.setAttribute('role', 'status');
3073
3222
  node.dataset.tone = tone;
3074
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(); }));
3075
3225
  document.body.append(node);
3076
3226
  if (typeof node.showPopover === 'function') { node.popover = 'manual'; node.showPopover(); }
3077
3227
  toastNode = node;
3078
3228
  clearTimeout(toastTimer);
3079
- toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, 5000);
3229
+ toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, action ? 9000 : 5000);
3080
3230
  }
3081
3231
 
3082
3232
  // ---- THE LOBBY (2026-10-10). The first screen of MADRE: every project on this Mac, BISHOP above
@@ -3273,7 +3423,7 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3273
3423
  const chosen = new Map(); // path → name
3274
3424
  const node = el('div', 'sm-picker');
3275
3425
  const pages = foldPages();
3276
- let source = 'found'; // found · browse
3426
+ let source = 'found'; // found · browse · new
3277
3427
  let found = null; // the suggestions, once read
3278
3428
  let here = null; // the folder being walked, as /api/salas/folders answered
3279
3429
  let query = '';
@@ -3298,7 +3448,46 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3298
3448
  const error = el('p', 'error');
3299
3449
  footer.append(pages.pager, count);
3300
3450
  if (addButton) footer.append(addButton);
3301
- 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);
3302
3491
  const sync = () => {
3303
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');
3304
3493
  if (addButton) {
@@ -3315,14 +3504,25 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3315
3504
  onToggle: (on) => { if (on) chosen.set(one.path, one.name); else chosen.delete(one.path); sync(); },
3316
3505
  });
3317
3506
  function draw({ keepPage = false } = {}) {
3318
- 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) => {
3319
3508
  touched = true;
3320
3509
  source = value;
3321
3510
  draw();
3322
- if (value === 'browse' && !here) void walk();
3323
- }, 'two');
3324
- seg.replaceWith(fresh);
3325
- 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
+ }
3326
3526
  const noMatch = () => t('No folder matches «{query}».', { query: input.value.trim() });
3327
3527
  if (source === 'found') {
3328
3528
  line.replaceChildren(el('span', 'sm-picker-note', found ? t('Folders that look like projects, in the places people keep them.') : t('Looking…')));
@@ -3361,7 +3561,7 @@ function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {})
3361
3561
  const answer = await fetch(`/api/salas/folders${path ? `?path=${encodeURIComponent(path)}` : ''}`, { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
3362
3562
  here = answer ?? { error: t('That folder is not there.') };
3363
3563
  if (path !== null) { query = ''; input.value = ''; }
3364
- if (source === 'browse') draw();
3564
+ if (source !== 'found') draw();
3365
3565
  }
3366
3566
  async function add() {
3367
3567
  error.textContent = '';
@@ -3417,6 +3617,30 @@ function openAddSheet() {
3417
3617
  }
3418
3618
  ui.add.addEventListener('click', openAddSheet);
3419
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
+
3420
3644
  // The agents of this computer, with everything a connection needs in one place: which account
3421
3645
  // each one signs in with, the doors it has, the box where a pasted key goes, the address a
3422
3646
  // sign-in opened and the code that page gives back. Home used to show a name, a lamp and one
@@ -3425,7 +3649,7 @@ ui.add.addEventListener('click', openAddSheet);
3425
3649
  // `compact` is the welcome's first step: there, a connected agent is done, and a button to change
3426
3650
  // the account it signs in with is a question nobody asked on their first minute.
3427
3651
  // Who makes each agent, said under its name as the project's AJUSTES says it.
3428
- 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' };
3429
3653
 
3430
3654
  // OpenCode's providers in Home, the same block as the project's AJUSTES › Agents › OpenCode: a line
3431
3655
  // per provider with a key or an account and its switch for the chat, one row to add another, and
@@ -3550,6 +3774,16 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3550
3774
  open.rel = 'noopener';
3551
3775
  box.append(open);
3552
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
+ }
3553
3787
  if (live.asksForCode || live.awaitingCode) {
3554
3788
  const form = el('form', 'sm-agent-code');
3555
3789
  const input = el('input');
@@ -3661,10 +3895,19 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3661
3895
  // One button per account the CLI can sign in with: Claude has two, and only the human can
3662
3896
  // say which one is paying.
3663
3897
  const doorKeys = () => {
3664
- 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 }];
3665
3906
  return doors.map((door) => {
3666
3907
  const name = door.label ? t(door.label) : state.ok ? t('Sign in again') : t('Sign in');
3667
- 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);
3668
3911
  event.currentTarget.disabled = true;
3669
3912
  await post(`/api/agents/${agent.id}/login`, { method: door.id });
3670
3913
  void paint();
@@ -3673,6 +3916,9 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3673
3916
  return key;
3674
3917
  });
3675
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));
3676
3922
  const acts = el('div', 'sm-agent-acts');
3677
3923
  if (!state.busyNow) {
3678
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(); }));
@@ -3682,9 +3928,12 @@ function agentsPanel({ onPaint = null, compact = false } = {}) {
3682
3928
  const ask = askBlock(agent);
3683
3929
  if (ask) body.append(ask);
3684
3930
  // A key, pasted here; OpenCode's are its providers', in the block below.
3685
- if (agent.detected && agent.key && agent.id !== 'opencode') body.append(keyForm(agent));
3686
- // A CLI that signs in from its own prompt gets the command, until it has a session.
3687
- 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));
3688
3937
  if (agent.id === 'opencode' && agent.detected) body.append(openCodeProviders.node());
3689
3938
  item.append(body);
3690
3939
  list.append(item);
@@ -3967,7 +4216,7 @@ function setStatus(id) {
3967
4216
  if (id === 'local') {
3968
4217
  if (!localSeen) return { line: localFailed ? t('Home did not answer.') : t('Reading…') };
3969
4218
  const on = localSeen.projects.filter((one) => one.on).length;
3970
- return { line: `${LOCAL_MODE_WORDS[localSeen.mode]()} · ${t('{n} of {total} projects', { n: on, total: localSeen.projects.length })}`, warn: !localSeen.master ? t('Local AI is switched off for every project.') : !localSeen.ollama.running ? t('Ollama is not running') : null };
4219
+ return { line: `${LOCAL_MODE_WORDS[localSeen.mode]()} · ${t('{n} of {total} projects', { n: on, total: localSeen.projects.length })}`, warn: !localSeen.master ? t('Local AI is off for every project.') : !localSeen.ollama.running ? t('Ollama is not running') : null };
3971
4220
  }
3972
4221
  if (id === 'projects') {
3973
4222
  if (!data) return { line: t('Reading…') };
@@ -3976,7 +4225,7 @@ function setStatus(id) {
3976
4225
  }
3977
4226
  if (id === 'release') {
3978
4227
  if (!versionSeen) return { line: t('Reading…') };
3979
- return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is on npm', { version: versionSeen.latest }) : null };
4228
+ return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is out', { version: versionSeen.latest }) : null };
3980
4229
  }
3981
4230
  return { line: '' };
3982
4231
  }
@@ -4089,6 +4338,15 @@ function fillLook(host) {
4089
4338
  const papers = el('div', 'custom-papers');
4090
4339
  papers.setAttribute('role', 'radiogroup');
4091
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
+ }
4092
4350
  const paper = button('paper', null, () => saveLooks({ wallpaper: value }));
4093
4351
  paper.dataset.wp = value;
4094
4352
  paper.setAttribute('role', 'radio');
@@ -4125,10 +4383,19 @@ function fillLook(host) {
4125
4383
  return line;
4126
4384
  };
4127
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
+ }
4128
4395
  paperBox.append(tune);
4129
4396
  }
4130
4397
  sxBlock(page, t('Wallpaper'), photo
4131
- ? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
4398
+ ? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
4132
4399
  : t('Behind Home, and behind the conversation of the project served at this address. Light and dark each have their own.')).rows.append(paperBox);
4133
4400
 
4134
4401
  // Home's own: its cards, its text, the order of its projects. The conversation's bubbles and text
@@ -4191,13 +4458,16 @@ function fillBishop(host) {
4191
4458
  // show; Ollama itself and what it does are set in a project's Modules › Local AI.
4192
4459
  const LOCAL_MODE_WORDS = { auto: () => t('Automatic'), pick: () => t('One by one'), manual: () => t('Manual') };
4193
4460
  const LOCAL_MODE_SAYS = {
4194
- auto: () => t('Every project uses the local AI, new ones too.'),
4461
+ auto: () => t('Every project uses it, new ones too.'),
4195
4462
  pick: () => t('Only the projects you switch on below. New projects start without it.'),
4196
- manual: () => t('Each project decides in its own Settings › Modules › Local AI. New projects start with it.'),
4463
+ manual: () => t('Each project decides in its own Modules › Local AI. New ones start with it.'),
4197
4464
  };
4198
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)
4199
4467
  async function loadLocal() {
4200
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;
4201
4471
  if (answer) localSeen = answer;
4202
4472
  localFailed = !answer;
4203
4473
  settingsSheet?.sync({ detail: true });
@@ -4240,11 +4510,13 @@ function fillLocal(host) {
4240
4510
  return;
4241
4511
  }
4242
4512
  if (!localSeen) { host.append(el('p', 'settings-reading', t('Reading…'))); return; }
4243
- host.append(el('p', 'sx-intro', t('The local AI is Ollama, on this computer: it searches the memory by meaning, sums it up and answers as @madre, without spending tokens or sending anything out. Here you choose which projects use it.')));
4513
+ host.append(el('p', 'sx-intro', t('Ollama runs local AI: it searches and sums up each project\'s memory and answers as @madre, free and private. Choose which projects use it.')));
4244
4514
  const mode = sxBlock(host, t('Which projects'), LOCAL_MODE_SAYS[localSeen.mode]());
4245
4515
  sxRow(mode, t('Mode'), null, localModeSeg());
4246
- if (!localSeen.master) sxRow(mode, t('Local AI is switched off for every project.'), t('Turn it on in a project, in Modules › Local AI.'));
4247
- else if (!localSeen.ollama.running) sxRow(mode, t('Ollama is not running'), t('The projects keep working with their agents until it starts. Start it from a project, in Modules › Local AI.'));
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.'));
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) });
4248
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.'));
4249
4521
  if (!localSeen.projects.length) { sxRow(list, t('No projects yet'), null); return; }
4250
4522
  const pages = Math.ceil(localSeen.projects.length / LOCAL_PER_PAGE);
@@ -4328,6 +4600,7 @@ function fillRelease(host) {
4328
4600
  const late = sxBlock(host, t('Projects on an older MADRE'), t('They keep running the version they started with until you restart them. What is running in them stops.'), { aside: behind.length > 1 ? sxButton(t('Restart all'), () => void restartAll(), { tone: 'primary' }) : null });
4329
4601
  for (const room of behind) sxRow(late, roomLabel(room), `MADRE ${room.open.version}`, sxButton(t('Restart'), () => { settingsSheet?.close(); void restartRoom(room); }));
4330
4602
  }
4603
+ drawLegal(host);
4331
4604
  }
4332
4605
  async function updateHome(info, key, said) {
4333
4606
  key.disabled = true;
@@ -4518,6 +4791,10 @@ function openSettings(section = null) {
4518
4791
  show(section);
4519
4792
  }
4520
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);
4521
4798
 
4522
4799
  function translateStatic() {
4523
4800
  document.title = `MADRE · ${t('Home')}`;
@@ -4527,12 +4804,18 @@ function translateStatic() {
4527
4804
  ui.bar.open.setAttribute('aria-label', t('Projects on the bar'));
4528
4805
  ui.bar.agents.setAttribute('aria-label', t('Agents'));
4529
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'));
4530
4809
  ui.bar.reload.title = t('Reload the page');
4531
4810
  ui.bar.reload.setAttribute('aria-label', t('Reload the page'));
4811
+ const addKey = document.getElementById('sm-add-widgets');
4812
+ addKey?.setAttribute('title', t('Add widgets to Home'));
4813
+ addKey?.setAttribute('aria-label', t('Add widgets to Home'));
4532
4814
  ui.stop.querySelector('span').textContent = t('Stop all');
4533
4815
  ui.macro.querySelector('span').textContent = t('Project map');
4534
4816
  ui.gear.title = t('Home settings');
4535
4817
  ui.gear.setAttribute('aria-label', t('Home settings'));
4818
+ labelPowerKey(powerButton);
4536
4819
  ui.search.placeholder = t('Search a project…');
4537
4820
  ui.search.setAttribute('aria-label', t('Search a project…'));
4538
4821
  ui.filter.setAttribute('aria-label', t('Show'));
@@ -4544,9 +4827,9 @@ function translateStatic() {
4544
4827
  ui.bishop.go.textContent = t('Talk to the Coordinator');
4545
4828
  ui.bishop.tab.title = t('Open the Coordinator in a new tab');
4546
4829
  ui.bishop.tab.setAttribute('aria-label', t('Open the Coordinator in a new tab'));
4547
- ui.bishop.about.textContent = t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.');
4830
+ ui.bishop.about.textContent = t('Your Coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.');
4548
4831
  ui.work.title.textContent = t('Recent summary');
4549
- ui.work.again.textContent = t('Summarise again');
4832
+ ui.work.again.textContent = t('Summarize again');
4550
4833
  ui.waiting.title.textContent = t('Waiting on you');
4551
4834
  ui.linksTileTitle.textContent = t('Connections');
4552
4835
  ui.link.textContent = t('Connect projects');
@@ -4563,6 +4846,20 @@ function translateStatic() {
4563
4846
  translateStatic();
4564
4847
  drawWork();
4565
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(); });
4566
4863
  await refresh();
4567
4864
  // Arriving from RELOAD (salas-mac.html put the curtain back): Home has read its projects, so the
4568
4865
  // bar fills and the curtain clears.
@@ -4583,6 +4880,11 @@ void loadDecisions();
4583
4880
  if (location.hash === '#welcome') { history.replaceState(null, '', location.pathname); welcomed = true; openWizard(); }
4584
4881
  // Sent here for the Macroverso (the key in BISHOP's room): straight into it, on a large screen.
4585
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');
4586
4888
  timer = setInterval(() => { if (!document.hidden && !busy.size && !pins.dropping && !pins.drag) void refresh(); }, 4000);
4587
4889
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { void refresh(); void loadAgents(); void loadWork(); void loadDecisions(); } });
4588
4890
  // The decisions are asked again every few minutes (the route itself asks the rooms no oftener).