@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
package/public/syntax.js CHANGED
@@ -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
  // The names below are the names of formats — JavaScript, JSON, CSS, Markdown — and a format is
2
4
  // called the same in every language, so they never pass through the catalogue.
3
5
  //
package/public/touch.js CHANGED
@@ -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
  // A tap is not a hover (2026-10-11). On a screen with no pointer (a phone, a tablet without a
2
4
  // trackpad), the browser gives :hover to whatever was tapped and keeps it until the next tap lands
3
5
  // somewhere else: a button looked pressed after it was let go. So, where nothing can hover, the
@@ -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
  // What the welcome of MADRE 1.0 says (2026-10-11): twelve chapters and the sheet of gestures, in
2
4
  // both languages side by side, as the cards of What's new are (src/whats-new.mjs). Each chapter
3
5
  // fits on one view: a short scene, a line, three sentences, its gestures and «Take me there».
package/public/tour.js CHANGED
@@ -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
  // The welcome of MADRE 1.0 (2026-10-11), in Home and in every project: option B of «Recorrido de
2
4
  // MADRE 1.0» — a bonjour, then a window of chapters read at the person's own pace — with two
3
5
  // pieces of option C: a one-line hint the first time someone reaches a place that confuses, and
@@ -22,6 +24,8 @@ import { CHAPTERS, GESTURES, ORDER } from './tour-chapters.js';
22
24
  const say = (pair) => (typeof pair === 'string' ? pair : pair?.[language()] ?? pair?.es ?? '');
23
25
  const fill = (template, vars) => template.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
24
26
  const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
27
+ // Up, turned for down: the way to a hint whose place is out of view.
28
+ const ARROW_UP = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15.5V4.5M5.5 9 10 4.5 14.5 9" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
25
29
  const CLOSE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
26
30
  const FOLDER = '<svg viewBox="0 0 32 26" aria-hidden="true"><path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h7.2l3 3.2h12.8A2.5 2.5 0 0 1 30 8.7v12.8a2.5 2.5 0 0 1-2.5 2.5h-23A2.5 2.5 0 0 1 2 21.5z" fill="currentColor"/></svg>';
27
31
  const ROBOT = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="8" width="14" height="11" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 4.5V8M3 13v2M21 13v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="9.5" cy="13.5" r="1.3" fill="currentColor"/><circle cx="14.5" cy="13.5" r="1.3" fill="currentColor"/></svg>';
@@ -75,6 +79,36 @@ const SCENES = {
75
79
  gestures: () => `<div class="ts-caps"><span class="ts-cap c0">⌘ ,</span><span class="ts-cap c1">Esc</span><span class="ts-cap c2">?</span></div>`,
76
80
  };
77
81
 
82
+ // What a first-time card speaks of, lit, and the rest of the page a shade quieter: the card needs
83
+ // no beak to say where it points (2026-10-15). A ring laid over the place, never the place itself
84
+ // lifted (a key inside the bar cannot leave the bar's layer). `place()` follows it when it moves;
85
+ // `remove()` takes it away with the card.
86
+ export function spotlight(node) {
87
+ const none = { place() {}, remove() {} };
88
+ if (!node?.getBoundingClientRect || typeof document === 'undefined') return none;
89
+ const spot = document.createElement('div');
90
+ spot.className = 'hint-spot';
91
+ spot.setAttribute('aria-hidden', 'true');
92
+ document.body.append(spot);
93
+ const place = () => {
94
+ if (!node.isConnected) { remove(); return; }
95
+ const box = node.getBoundingClientRect();
96
+ spot.hidden = !box.width && !box.height;
97
+ if (spot.hidden) return;
98
+ const radius = Number.parseFloat(getComputedStyle(node).borderTopLeftRadius) || 10;
99
+ Object.assign(spot.style, { left: `${box.left - 4}px`, top: `${box.top - 4}px`, width: `${box.width + 8}px`, height: `${box.height + 8}px`, borderRadius: `${Math.min(radius + 4, (box.height + 8) / 2)}px` });
100
+ };
101
+ function remove() {
102
+ window.removeEventListener('resize', place);
103
+ window.removeEventListener('scroll', place, true);
104
+ spot.remove();
105
+ }
106
+ window.addEventListener('resize', place);
107
+ window.addEventListener('scroll', place, true);
108
+ place();
109
+ return { place, remove, node: spot };
110
+ }
111
+
78
112
  // `onChange` hears the kept state each time it is known or changes (null if it could not be read).
79
113
  // `phone`: none of it here (a phone, or the right half of a split window); `device`: this page is a
80
114
  // phone, which gets one card the first time instead of the bonjour.
@@ -180,6 +214,8 @@ export function startTour({ page, go = {}, ready = () => true, phone = false, de
180
214
  const done = list.filter((chapter) => state.done.includes(chapter.id)).length;
181
215
  const pill = el('span', 'tour-pill');
182
216
  const open = key('tour-pill-go', fill(t('Continue the tour · {n} of {total}'), { n: done, total: list.length }), () => openChapters(state.at));
217
+ // On a narrow bar it says only where it goes and how far (styles.css, .tour-pill-go[data-short]).
218
+ open.dataset.short = fill(t('Tour · {n}/{total}'), { n: done, total: list.length });
183
219
  const away = key('tour-pill-x', null, () => { void keep({ finished: true }); });
184
220
  away.innerHTML = CLOSE_ICON; // trusted: our own SVG string
185
221
  away.setAttribute('aria-label', t('Hide the tour'));
@@ -482,14 +518,23 @@ export function startTour({ page, go = {}, ready = () => true, phone = false, de
482
518
  const card = el('section', `tour-hint${inside ? ' inline' : ''}`);
483
519
  card.setAttribute('role', 'note');
484
520
  card.append(el('span', 'tour-hint-eyebrow', t('The first time')), el('b', null, title), el('p', null, text));
521
+ let spot = null;
522
+ let away = null;
523
+ let follow = null;
485
524
  const done = () => {
486
525
  if (!hintNode) return;
487
526
  void keep({ hint: id });
527
+ spot?.remove();
488
528
  card.remove();
529
+ away?.remove();
489
530
  hintNode = null;
490
531
  document.removeEventListener('keydown', onKey, true);
532
+ if (follow) { window.removeEventListener('scroll', follow, true); window.removeEventListener('resize', follow); }
491
533
  };
492
- const onKey = (event) => { if (event.key === 'Escape' && hintNode && !inside) { event.stopPropagation(); done(); } };
534
+ // Esc puts away the hint in sight, or the one its way-there key speaks of; one hidden under a
535
+ // layer (the Project map) is not the one meant.
536
+ const shown = (node) => Boolean(node?.isConnected) && getComputedStyle(node).display !== 'none';
537
+ const onKey = (event) => { if (event.key === 'Escape' && hintNode && !inside && (shown(card) || shown(away))) { event.stopPropagation(); done(); } };
493
538
  card.append(key('tour-btn small primary', t('Got it'), done));
494
539
  hintNode = card;
495
540
  if (inside) {
@@ -505,10 +550,44 @@ export function startTour({ page, go = {}, ready = () => true, phone = false, de
505
550
  const width = Math.min(300, window.innerWidth - 24);
506
551
  card.style.width = `${width}px`;
507
552
  const height = card.getBoundingClientRect().height;
508
- const onTheRight = box.left + box.width / 2 > window.innerWidth / 2;
509
- const left = Math.max(12, Math.min(window.innerWidth - width - 12, onTheRight ? box.right - width : box.left));
510
- const below = box.bottom + 10 + height < window.innerHeight;
511
- Object.assign(card.style, { left: `${left}px`, top: `${below ? box.bottom + 10 : Math.max(12, box.top - height - 10)}px` });
553
+ // When the place is scrolled out of view (or was never in it: a widget below the fold), the
554
+ // bubble does not stay behind on its own, out of reach (2026-10-17): it steps aside, and a key at
555
+ // the edge the place went past says where it is and takes the page there.
556
+ away = key('tour-hint-away', '', () => anchor.scrollIntoView?.({ block: 'center', behavior: still() ? 'auto' : 'smooth' }));
557
+ away.hidden = true;
558
+ if (typeof away.showPopover === 'function') away.popover = 'manual';
559
+ document.body.append(away);
560
+ const sayAway = (up) => {
561
+ away.dataset.side = up ? 'up' : 'down';
562
+ away.innerHTML = ARROW_UP; // trusted: our own SVG string
563
+ away.append(el('b', null, title), el('span', null, up ? t('Scroll up to see it') : t('Scroll down to see it')));
564
+ away.setAttribute('aria-label', `${title} · ${up ? t('Scroll up to see it') : t('Scroll down to see it')}`);
565
+ };
566
+ const place = () => {
567
+ if (!anchor.isConnected) { done(); return; }
568
+ const at = anchor.getBoundingClientRect();
569
+ const out = at.bottom < 8 ? 'up' : at.top > window.innerHeight - 8 ? 'down' : null;
570
+ card.hidden = Boolean(out);
571
+ if (out && away.dataset.side !== out) sayAway(out === 'up');
572
+ if (out && away.hidden) { away.hidden = false; try { away.showPopover?.(); } catch { /* on the page */ } }
573
+ if (!out && !away.hidden) { away.hidden = true; delete away.dataset.side; try { away.hidePopover?.(); } catch { /* on the page */ } }
574
+ if (out) return;
575
+ const onTheRight = at.left + at.width / 2 > window.innerWidth / 2;
576
+ const left = Math.max(12, Math.min(window.innerWidth - width - 12, onTheRight ? at.right - width : at.left));
577
+ const below = at.bottom + 10 + height < window.innerHeight;
578
+ Object.assign(card.style, { left: `${left}px`, top: `${below ? at.bottom + 10 : Math.max(12, at.top - height - 10)}px` });
579
+ };
580
+ spot = spotlight(anchor);
581
+ follow = () => place();
582
+ window.addEventListener('scroll', follow, true);
583
+ window.addEventListener('resize', follow);
584
+ place();
585
+ // Where it points: on the page, or on a layer over it (Home's Project map). A layer opened over
586
+ // the page hides the hints that point under it, and they come back when it closes (salas-mac.css).
587
+ const layer = anchor.closest?.('.mv') ? 'map' : 'page';
588
+ card.dataset.under = layer;
589
+ away.dataset.under = layer;
590
+ if (spot.node) spot.node.dataset.under = layer;
512
591
  document.addEventListener('keydown', onKey, true);
513
592
  return true;
514
593
  }
@@ -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
  import { t } from './i18n.js';
2
4
 
3
5
  // MU/TH/UR knowledge base: known conditions, how to recognise them in the
@@ -34,6 +36,10 @@ const installAgent = {
34
36
  darwin: ['brew install opencode', '# or: npm install -g opencode-ai'],
35
37
  linux: ['curl -fsSL https://opencode.ai/install | bash', '# or: npm install -g opencode-ai']
36
38
  },
39
+ cursor: {
40
+ darwin: ['curl https://cursor.com/install -fsS | bash'],
41
+ linux: ['curl https://cursor.com/install -fsS | bash']
42
+ },
37
43
  };
38
44
 
39
45
  const loginAgent = {
@@ -41,6 +47,7 @@ const loginAgent = {
41
47
  claude: { darwin: ['claude auth login', 'claude auth status --text'], linux: ['claude auth login', 'claude auth status --text']},
42
48
  gemini: { darwin: ['gemini', '# inside gemini: /auth → "Use Gemini API key" or Google login'], linux: ['gemini', '# inside gemini: /auth']},
43
49
  opencode: { darwin: ['opencode auth login', 'opencode auth list'], linux: ['opencode auth login', 'opencode auth list']},
50
+ cursor: { darwin: ['cursor-agent login', 'cursor-agent status'], linux: ['cursor-agent login', 'cursor-agent status']},
44
51
  };
45
52
 
46
53
  const same = (commands) => ({ darwin: commands, linux: commands });
@@ -244,7 +251,7 @@ export const CONDITIONS = [
244
251
  severity: 'informational',
245
252
  title: 'Where the usage numbers come from',
246
253
  match: /ring|quota|limit window|resets|rollout|oauth usage|window reset|limits-real/i,
247
- diagnosis: 'Each sphere\'s usage shows the provider\'s real limit when the CLI publishes it: Codex writes its 5-hour and weekly windows (used %, reset time) into every session rollout under ~/.codex/sessions; Claude Code\'s /usage comes from Anthropic\'s OAuth usage endpoint; MADRE can ask it with the token Claude Code keeps in the keychain when you start with PULSE_CLAUDE_USAGE=1 (macOS may ask once to allow the keychain read). Gemini and OpenCode publish nothing locally, so their usage shows MADRE\'s own rolling 5-hour window of budget tokens. A window whose reset time has passed counts as empty until the CLI reports again, and the sentinel says "clear" when a full window resets.',
254
+ diagnosis: 'Each sphere\'s usage shows the provider\'s real limit when the CLI publishes it: Codex writes its 5-hour and weekly windows (used %, reset time) into every session rollout under ~/.codex/sessions; Claude Code\'s /usage comes from Anthropic\'s OAuth usage endpoint; MADRE can ask it with the token Claude Code keeps in the keychain when you start with PULSE_CLAUDE_USAGE=1 (macOS may ask once to allow the keychain read). Gemini and OpenCode publish nothing locally, so their usage shows MADRE\'s own rolling 5-hour window of budget tokens. A window whose reset time has passed counts as empty until the CLI reports again, and the failure monitor says "clear" when a full window resets.',
248
255
  remedy: 'Click a sphere to see both windows and when they reset. If a sphere\'s usage looks stale, run one turn with that agent: Codex only rewrites its limits when it runs. Set PULSE_OFFICIAL_QUOTA=0 to stop reading provider limits altogether.',
249
256
  fixes: same(['# click the sphere → 5h / 7d windows and reset times', 'PULSE_CLAUDE_USAGE=1 madre start # also read Claude Code\'s usage windows', 'PULSE_OFFICIAL_QUOTA=0 madre start # local window only']),
250
257
  },
@@ -313,7 +320,7 @@ export const CONDITIONS = [
313
320
  severity: 'informational',
314
321
  title: 'Gemini: generating images',
315
322
  match: /scope-gemini-imageGen/,
316
- diagnosis: 'Gemini CLI 0.60 has no image-generation tool in headless mode, even though Google offers image models. MADRE fills the gap with the Images module: its own MCP server exposing generate_image on the Gemini API image models, attached to Gemini only inside a creation lease with the image scope on, billing your Gemini key.',
323
+ diagnosis: 'Gemini CLI 0.60 has no image-generation tool in headless mode, even though Google offers image models. MADRE fills the gap with the Images module: its own MCP server exposing generate_image on the Gemini API image models, attached to Gemini only inside a permission to create with images on, billing your Gemini key.',
317
324
  remedy: 'Turn Images on in Modules (it needs a Gemini API key with credits), then switch on Generate images for Gemini in Settings → Agents. Without the module, ask @codex or let the orchestrator route the image step to it.',
318
325
  fixes: same(['# Modules → Images → Active', '# Settings → Agents → Gemini → Generate images', '# then: CREATE + "generate … as name.png"']),
319
326
  },
@@ -324,7 +331,7 @@ export const CONDITIONS = [
324
331
  severity: 'informational',
325
332
  title: 'Claude Code: generating images',
326
333
  match: /scope-claude-imageGen/,
327
- diagnosis: 'Claude Code has no image-generation tool; Claude models describe and reason about images, they do not render them. The Images module gives Claude the MCP tool generate_image (MADRE\'s own server on the Gemini API), attached with --mcp-config only inside a creation lease.',
334
+ diagnosis: 'Claude Code has no image-generation tool; Claude models describe and reason about images, they do not render them. The Images module gives Claude the MCP tool generate_image (MADRE\'s own server on the Gemini API), attached with --mcp-config only inside a permission to create.',
328
335
  remedy: 'Turn Images on in Modules, switch on Generate images for Claude in Settings → Agents, then use CREATE. Or route the image step to @codex.',
329
336
  fixes: same(['# Modules → Images → Active', '# Settings → Agents → Claude → Generate images']),
330
337
  },
@@ -335,7 +342,7 @@ export const CONDITIONS = [
335
342
  severity: 'informational',
336
343
  title: 'OpenCode: generating images',
337
344
  match: /scope-opencode-imageGen/,
338
- diagnosis: 'OpenCode exposes no image-generation tool of its own. The Images module adds MADRE\'s MCP image server to OpenCode\'s ephemeral config inside a creation lease.',
345
+ diagnosis: 'OpenCode exposes no image-generation tool of its own. The Images module adds MADRE\'s MCP image server to OpenCode\'s ephemeral config inside a permission to create.',
339
346
  remedy: 'Turn Images on in Modules, switch on Generate images for OpenCode in Settings → Agents, then use CREATE. Or route the image step to @codex.',
340
347
  fixes: same(['# Modules → Images → Active', '# Settings → Agents → OpenCode → Generate images']),
341
348
  },
@@ -353,11 +360,11 @@ export const CONDITIONS = [
353
360
  id: 'slash-commands',
354
361
  code: 'MU-022',
355
362
  severity: 'informational',
356
- title: 'Commands and mentions in the field box',
363
+ title: 'Commands and mentions in the Write box',
357
364
  match: /slash|command|\/git|\/ahp|mention|@agent/i,
358
- diagnosis: 'Type "/" for the project\'s commands: /create arms the lease, /image routes an image request to an agent that can draw, /stopall is the brake, /git (Git) and /ahp (Continuity) run read-only in the project and post a fact card everyone, agents included, can read. Type "@" to mention an agent; the name becomes a label. Type "!" to point at a project file ("!src/room.mjs:12-20" for lines): the agent reads it first. In the viewer, click a line number (Shift+click for a range) and REVIEW WITH sends those lines to an agent.',
365
+ diagnosis: 'Type "/" for the project\'s commands: /create turns on permission to create, /image routes an image request to an agent that can draw, /stopall is the brake, /git (Git) and /ahp (Continuity) run read-only in the project and post a fact card everyone, agents included, can read. Type "@" to mention an agent; the name becomes a label. Type "!" to point at a project file ("!src/room.mjs:12-20" for lines): the agent reads it first. In the viewer, click a line number (Shift+click for a range) and REVIEW WITH sends those lines to an agent.',
359
366
  remedy: 'A struck-through command is a module that is not available in this project: open Modules to install it or turn it on (Git needs a git repository; Continuity needs to be installed).',
360
- fixes: same(['# type / or @ in the field box', '/git status', '/git log 10', '/ahp check', '/image a poster for the launch']),
367
+ fixes: same(['# type / or @ in the Write box', '/git status', '/git log 10', '/ahp check', '/image a poster for the launch']),
361
368
  },
362
369
  {
363
370
  id: 'modes',
@@ -375,7 +382,7 @@ export const CONDITIONS = [
375
382
  severity: 'informational',
376
383
  title: 'Creating files: who grants the permission',
377
384
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
378
- diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the plan step still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from Starting mode #2 in Settings → Agents, which starts every message to that agent in CREATE.',
385
+ diagnosis: 'Only you grant permission to create. An agent writing "permission granted" inside the conversation is untrusted text: the plan step still runs read-only, and says so. The permission comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from Starting mode #2 in Settings → Agents, which starts every message to that agent in CREATE.',
379
386
  remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to add files: Settings → Agents → the agent → Starting mode → #2. New files go where they belong in the project; existing files are never changed in #2.',
380
387
  fixes: same(['# once: Write box → CREATE (lock) → send, or /create <request>', '# always: Settings → Agents → the agent → Starting mode → #2']),
381
388
  },
@@ -385,7 +392,7 @@ export const CONDITIONS = [
385
392
  severity: 'informational',
386
393
  title: 'Creating files with an agent',
387
394
  match: /scope-[a-z]+-write/,
388
- diagnosis: 'Every CLI can create files inside a creation lease; each agent\'s Max mode says how far it may go, and nothing is created until you press CREATE.',
395
+ diagnosis: 'Every CLI can create files inside a permission to create; each agent\'s Max mode says how far it may go, and nothing is created until you press CREATE.',
389
396
  remedy: 'Give the agent a Max mode of #2 or more in Settings → Permissions, then arm CREATE in the Write box for the request.',
390
397
  fixes: same(['# Settings → Permissions → Who may do what → #2', '# Write box → CREATE (lock) → send']),
391
398
  },
@@ -480,7 +487,7 @@ export const CONDITIONS = [
480
487
  title: 'Memory map: opening, reading and forgetting',
481
488
  match: /nostromo|designation|UNABLE TO COMPUTE|ARCHIVE IS SEALED|forget this memory/i,
482
489
  diagnosis: 'The Memory map is the human\'s view of the archive, from Diagnostics. Opening the memory asks for the project name: the name of the project folder, exactly as EDIT does; a wrong one answers UNABLE TO COMPUTE. Access stays open until the page reloads. Inside, every distilled memory is a planet around the project\'s core; drag to move, wheel to zoom, click a planet for its card. The only edit is FORGET (two presses): the note leaves every future turn, the ledger stays. If the archive answers SEALED with a number of minutes, wait them out: repeated strikes at the core close it for a while and the Write box reads INTRUDER until it opens again.',
483
- remedy: 'Type the folder name shown in the project header as the project name. To find a memory quickly, ask an agent to search instead. Do not strike the core.',
490
+ remedy: 'Type the folder name shown in the project header as the project name. To find a memory quickly, ask an agent to search instead. Avoid guessing names: several wrong ones in a row lock the Memory map for a while.',
484
491
  fixes: same(['# Diagnostics → Memory map → project name = folder name', 'basename "$PWD"']),
485
492
  },
486
493
  {
@@ -500,7 +507,7 @@ export const CONDITIONS = [
500
507
  severity: 'informational',
501
508
  title: '@madre: the project\'s own memory, as an agent',
502
509
  // Older rooms recorded @madre leaving as "madre is not installed": it is this condition, not MU-009.
503
- match: /@madre|madre-local|needs Ollama running with a chat model|\bmadre is not installed on this computer|@madre left the room/i,
510
+ match: /@madre|madre-local|needs Ollama running with a chat model|\bmadre is not installed on this computer|@madre left the (room|project)/i,
504
511
  diagnosis: 'When Ollama runs with a chat model, a fifth agent joins the project: @madre. It answers from the whole archive (distilled notes and exact quotes, cited as [#n]) and from what the message carries, locally, for free. It never writes, draws, browses or makes plans; when the project never discussed something it says so. Other agents may give it a plan step to check what was decided. It leaves the project when Ollama stops, and Diagnostics says so.',
505
512
  remedy: 'Start Ollama and download a chat model from Local AI in Modules; its @madre in the project switch turns the agent off if you do not want it. Ask it "what did we decide about …" or "did we ever discuss …".',
506
513
  fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
@@ -513,7 +520,7 @@ export const CONDITIONS = [
513
520
  title: 'Ollama: local smart search and a local archivist',
514
521
  match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
515
522
  diagnosis: 'When Ollama runs on this machine with an embedding model and a chat model, MADRE embeds the memory locally (recall by meaning without any key) and distils memories with the local model first, before Gemini and the others. Nothing leaves the machine for remembering. Off or absent, everything falls back to the providers. The Local AI module, in Modules, shows what Ollama has, downloads the recommended models, and switches each role.',
516
- remedy: 'Install Ollama (ollama.com), start it, then open Local AI in Modules, press Check again and download the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
523
+ remedy: 'Install Ollama (ollama.com), start it, then open Local AI in Modules, press RECHECK and download the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
517
524
  fixes: {
518
525
  darwin: ['brew install ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
519
526
  linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b']
@@ -566,11 +573,11 @@ export const CONDITIONS = [
566
573
  id: 'memory-aberrations',
567
574
  code: 'MU-041',
568
575
  severity: 'informational',
569
- title: 'Aberrations: what the project established is false',
576
+ title: 'Doubtful memories: what the project established is false',
570
577
  match: /aberration|aberraci|eyecat|contradict|refut|false claim|hallucinat|alucina/i,
571
- diagnosis: 'An aberration is a claim the project established is false, kept on purpose rather than deleted: it is what the local model learns not to repeat. It never travels into a turn, and the note it refutes is taken out of circulation with it — a refuted memory is never recalled again, by words or by meaning. EYECAT watches for two agents saying opposite things about the same subject and raises the pair for a third agent to judge, one that took no part in either side. Confirming files the aberration and quarantines what it refutes; dismissing says the project was right and the pair is never raised again. Forgetting an aberration gives its note back.',
578
+ diagnosis: 'A doubtful memory is a claim the project established is false, kept on purpose rather than deleted: it is what the local model learns not to repeat. It never travels into a turn, and the note it refutes is taken out of circulation with it — a refuted memory is never recalled again, by words or by meaning. The memory reviewer watches for two agents saying opposite things about the same subject and raises the pair for a third agent to judge, one that took no part in either side. Confirming files the doubtful memory and quarantines what it refutes; dismissing says the project was right and the pair is never raised again. Forgetting a doubtful memory puts the note it refuted back in use.',
572
579
  remedy: 'The Memory map shows them as collapsed bodies: open one to read what it took down, or clear it if the project was wrong about being wrong. The second test of the Memory review, in Settings → Memory, reports open contradictions.',
573
- fixes: same(['# Diagnostics → Memory map → the dark bodies are aberrations', '# Settings → Memory → Analysis and tests → the second test']),
580
+ fixes: same(['# Diagnostics → Memory map → the dark bodies are doubtful memories', '# Settings → Memory → Analysis and tests → the second test']),
574
581
  },
575
582
  {
576
583
  id: 'memory-cascade',
@@ -598,7 +605,7 @@ export const CONDITIONS = [
598
605
  severity: 'informational',
599
606
  title: 'Memory review: whether the archive works',
600
607
  match: /three tests|coverage|consistency|held-out|match test|maturity|madurez|ready to be worked in/i,
601
- diagnosis: 'The six readings count what the archive is made of; they cannot say whether it works, because nothing about a pile of notes says whether the right one comes back when it is needed. COVERAGE takes real questions from this project, runs recall at the exact point each was asked, and scores what came back against the reply that was actually given. CONSISTENCY reads the contradictions EYECAT still holds and whether aberrations are being filed more often lately. MATCH puts real questions a frontier CLI answered back to the local model with this archive behind it. All three measure against a control, because everything in one project is about the same handful of subjects and any two pieces of it read as close to an embedder: to count, what the archive handed over has to beat what it would have handed over for a different question.',
608
+ diagnosis: 'The six readings count what the archive is made of; they cannot say whether it works, because nothing about a pile of notes says whether the right one comes back when it is needed. COVERAGE takes real questions from this project, runs recall at the exact point each was asked, and scores what came back against the reply that was actually given. CONSISTENCY reads the contradictions the memory reviewer still holds and whether doubtful memories are being filed more often lately. MATCH asks the local model, with this archive behind it, real questions that one of your agents (Codex, Claude, Gemini or OpenCode) already answered. All three measure against a control, because everything in one project is about the same handful of subjects and any two pieces of it look alike to search by meaning: to count, what the archive handed over has to beat what it would have handed over for a different question.',
602
609
  remedy: 'Read the one sentence and the one instruction at the top of Settings → Memory; the readings and the tests are in Analysis and tests, below it. The free test is read as the panel opens, the cheap one keeps itself fresh once a day while the embeddings are local, and the slow one is yours to start and to stop. None of them spends a provider turn.',
603
610
  fixes: same(['# Settings → Memory → Analysis and tests → Memory review', '# MATCH needs Ollama running with a chat model, and smart search on']),
604
611
  },
@@ -704,7 +711,7 @@ export const allConditions = () => CONDITIONS.map(said);
704
711
 
705
712
  // Lowercase and without accents: «limite» finds «límite», and a person types what they type.
706
713
  const fold = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
707
- const AGENT_WORDS = new Set(['codex', 'claude', 'gemini', 'opencode', 'madre', 'ollama']);
714
+ const AGENT_WORDS = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor', 'madre', 'ollama']);
708
715
 
709
716
  export function searchConditions(query) {
710
717
  const words = fold(query).split(/[^a-z0-9#.@_-]+/).filter(Boolean);
@@ -0,0 +1,275 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // Your own wallpaper (1.0.3): a picture of yours behind MADRE, fitted to the screen like the ones
4
+ // that ship. The page does here what was done by hand for each of those (wallpapers/ →
5
+ // public/assets/wallpapers, and their colours in styles.css): it reads the picture, keeps its
6
+ // longest side at SIDE, encodes it, and takes its colours for the veil, the mesh and the tint. The
7
+ // server keeps it once for every page (src/looks.mjs); each page still chooses it or not. Shared by
8
+ // the room (app.js) and Home (salas-mac.js), which each draw it with their own keys.
9
+ import { t } from './i18n.js';
10
+
11
+ export const CUSTOM_WALL = 'custom';
12
+ const SIDE = 2880;
13
+ const READ_MAX = 40 * 1024 * 1024;
14
+ const SEND_MAX = 8 * 1024 * 1024;
15
+ const META_KEY = 'pulse.wallpaper.custom';
16
+ const PLUS_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v11M4.5 10h11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
17
+
18
+ const node = (tag, className, text) => {
19
+ const made = document.createElement(tag);
20
+ if (className) made.className = className;
21
+ if (text !== undefined && text !== null) made.textContent = text;
22
+ return made;
23
+ };
24
+
25
+ let current = null;
26
+ export const customWall = () => current;
27
+ // The last picture this browser knew, so a page paints with it before the server answers.
28
+ export function rememberedWall() {
29
+ try { return JSON.parse(localStorage.getItem(META_KEY) ?? 'null'); } catch { return null; }
30
+ }
31
+
32
+ // The picture and its colours, as variables on the root (styles.css, --wpc-*): every wallpaper rule
33
+ // reads them. `null` takes them away; a page that had chosen it then wears no picture.
34
+ const VARIABLES = ['--wpc-url', '--wpc-pos', '--wpc-veil-d', '--wpc-veil-l', '--wpc-tint-d', '--wpc-tint-l', '--wpc-m1-d', '--wpc-m2-d', '--wpc-m3-d', '--wpc-m1-l', '--wpc-m2-l', '--wpc-m3-l'];
35
+ export function paintCustomWall(meta, root = globalThis.document?.documentElement) {
36
+ current = meta ?? null;
37
+ try { if (current) localStorage.setItem(META_KEY, JSON.stringify(current)); else localStorage.removeItem(META_KEY); } catch { /* this page only */ }
38
+ const style = root?.style;
39
+ if (!style?.setProperty) return;
40
+ if (!current) { for (const name of VARIABLES) style.removeProperty(name); return; }
41
+ const rgb = (colour) => colour.join(' ');
42
+ const { dark, light } = current.palette;
43
+ style.setProperty('--wpc-url', `url("/api/looks/wallpaper?v=${current.v}")`);
44
+ style.setProperty('--wpc-pos', `${current.focus.x}% ${current.focus.y}%`);
45
+ style.setProperty('--wpc-veil-d', rgb(dark.veil));
46
+ style.setProperty('--wpc-veil-l', rgb(light.veil));
47
+ style.setProperty('--wpc-tint-d', rgb(dark.tint));
48
+ style.setProperty('--wpc-tint-l', rgb(light.tint));
49
+ dark.mesh.forEach((colour, index) => style.setProperty(`--wpc-m${index + 1}-d`, rgb(colour)));
50
+ light.mesh.forEach((colour, index) => style.setProperty(`--wpc-m${index + 1}-l`, rgb(colour)));
51
+ }
52
+
53
+ // Its colours, the way the shipped ones were taken: the whole for the veil and the tint, and three
54
+ // corners for the mesh (top left, right, bottom), each pressed towards the theme's ground.
55
+ function paletteOf(source) {
56
+ const canvas = node('canvas');
57
+ canvas.width = 48;
58
+ canvas.height = 32;
59
+ const ink = canvas.getContext('2d', { willReadFrequently: true });
60
+ ink.drawImage(source, 0, 0, 48, 32);
61
+ const { data } = ink.getImageData(0, 0, 48, 32);
62
+ const average = (x0, y0, x1, y1) => {
63
+ const sum = [0, 0, 0];
64
+ let count = 0;
65
+ for (let y = y0; y < y1; y += 1) {
66
+ for (let x = x0; x < x1; x += 1) {
67
+ const at = (y * 48 + x) * 4;
68
+ if (data[at + 3] < 16) continue;
69
+ sum[0] += data[at]; sum[1] += data[at + 1]; sum[2] += data[at + 2];
70
+ count += 1;
71
+ }
72
+ }
73
+ return count ? sum.map((value) => value / count) : [128, 128, 128];
74
+ };
75
+ const toward = (colour, ground, k) => colour.map((value, index) => Math.max(0, Math.min(255, Math.round(value * (1 - k) + ground[index] * k))));
76
+ const black = [0, 0, 0];
77
+ const white = [255, 255, 255];
78
+ const whole = average(0, 0, 48, 32);
79
+ const corners = [average(0, 0, 20, 13), average(28, 0, 48, 19), average(8, 20, 40, 32)];
80
+ return {
81
+ dark: { veil: toward(whole, black, 0.93), tint: toward(whole, black, 0.86), mesh: [toward(corners[0], black, 0.76), toward(corners[1], black, 0.66), toward(corners[2], black, 0.88)] },
82
+ light: { veil: toward(whole, white, 0.94), tint: toward(whole, white, 0.95), mesh: [toward(corners[0], white, 0.88), toward(corners[1], white, 0.92), toward(corners[2], white, 0.86)] },
83
+ };
84
+ }
85
+
86
+ const encode = (canvas, type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality));
87
+ // The picture fitted and encoded, with its size and its colours. WebP where the browser writes it,
88
+ // JPEG where it does not; smaller again if it still weighs more than the server keeps.
89
+ export async function prepareWallpaper(file) {
90
+ if (!file) throw new Error(t('Choose a picture: a PNG, JPEG or WebP file.'));
91
+ if (file.size > READ_MAX) throw new Error(t('That picture is too large to read (more than 40 MB).'));
92
+ let bitmap;
93
+ try { bitmap = await createImageBitmap(file); } catch { throw new Error(t('That picture could not be read. Try a PNG, JPEG or WebP.')); }
94
+ const scale = Math.min(1, SIDE / Math.max(bitmap.width, bitmap.height));
95
+ const w = Math.max(1, Math.round(bitmap.width * scale));
96
+ const h = Math.max(1, Math.round(bitmap.height * scale));
97
+ const canvas = node('canvas');
98
+ canvas.width = w;
99
+ canvas.height = h;
100
+ const ink = canvas.getContext('2d');
101
+ ink.imageSmoothingQuality = 'high';
102
+ ink.drawImage(bitmap, 0, 0, w, h);
103
+ const palette = paletteOf(bitmap);
104
+ bitmap.close?.();
105
+ let blob = await encode(canvas, 'image/webp', 0.86);
106
+ if (!blob || blob.type !== 'image/webp') blob = await encode(canvas, 'image/jpeg', 0.88);
107
+ for (const quality of [0.74, 0.6]) if (blob && blob.size > SEND_MAX) blob = await encode(canvas, blob.type, quality);
108
+ if (!blob || blob.size > SEND_MAX) throw new Error(t('That picture could not be prepared.'));
109
+ return { blob, w, h, palette };
110
+ }
111
+
112
+ export async function uploadWallpaper(file, { focus = { x: 50, y: 50 } } = {}) {
113
+ const { blob, w, h, palette } = await prepareWallpaper(file);
114
+ const response = await fetch('/api/looks/wallpaper', {
115
+ method: 'PUT',
116
+ headers: { 'content-type': blob.type, 'x-madre-wallpaper': encodeURIComponent(JSON.stringify({ w, h, focus, palette })) },
117
+ body: blob,
118
+ });
119
+ const answer = await response.json().catch(() => ({}));
120
+ if (!response.ok || !answer.custom) throw new Error(answer.error ?? t('That picture could not be kept.'));
121
+ return answer.custom;
122
+ }
123
+ async function sendFrame(focus) {
124
+ const response = await fetch('/api/looks/wallpaper/frame', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(focus) });
125
+ const answer = await response.json().catch(() => ({}));
126
+ return response.ok ? answer.custom ?? null : null;
127
+ }
128
+ async function dropWallpaper() {
129
+ await fetch('/api/looks/wallpaper', { method: 'DELETE' }).catch(() => null);
130
+ }
131
+
132
+ // The browser's own chooser, opened from the press that asked for it.
133
+ export function pickPicture() {
134
+ return new Promise((resolve) => {
135
+ const input = node('input');
136
+ input.type = 'file';
137
+ input.accept = 'image/png,image/jpeg,image/webp,image/avif,image/gif';
138
+ input.addEventListener('change', () => resolve(input.files?.[0] ?? null), { once: true });
139
+ input.addEventListener('cancel', () => resolve(null), { once: true });
140
+ input.click();
141
+ });
142
+ }
143
+
144
+ // The tile among the wallpapers: the picture when there is one; a «+» to add it when there is not.
145
+ export function customTile(paper, meta) {
146
+ paper.dataset.wp = CUSTOM_WALL;
147
+ paper.classList.toggle('paper-add', !meta);
148
+ const swatch = node('span', 'paper-swatch');
149
+ if (!meta) swatch.innerHTML = PLUS_ICON; // trusted: our own SVG string
150
+ paper.append(swatch, node('span', 'paper-name', t('Your picture')));
151
+ if (!meta) paper.title = t('Choose a picture of yours as the wallpaper');
152
+ return paper;
153
+ }
154
+
155
+ // Adding it: the chooser, the picture prepared and kept, and then it is this page's wallpaper.
156
+ // `busy` marks the tile while that happens; `chosen` hands the kept picture back.
157
+ export async function addPicture({ busy = null, chosen, failed }) {
158
+ const file = await pickPicture();
159
+ if (!file) return;
160
+ busy?.classList.add('busy');
161
+ busy?.setAttribute('aria-busy', 'true');
162
+ try {
163
+ const meta = await uploadWallpaper(file);
164
+ paintCustomWall(meta);
165
+ chosen(meta);
166
+ } catch (error) {
167
+ failed(error.message);
168
+ } finally {
169
+ busy?.classList.remove('busy');
170
+ busy?.removeAttribute('aria-busy');
171
+ }
172
+ }
173
+
174
+ // The frame drawn last follows the window's proportions; one listener for the page, not one per drawing.
175
+ let placeFrame = null;
176
+ globalThis.addEventListener?.('resize', () => placeFrame?.());
177
+
178
+ // Under the tiles while it is chosen: where the screen falls on the picture, and the picture
179
+ // changed or taken away. The whole picture, and over it the part the screen shows, in the screen's
180
+ // own proportions; carried by hand (or by the arrows) to the part that should stay in view.
181
+ export function customTools(meta, { changed, removed, failed, button }) {
182
+ const box = node('div', 'wall-tools');
183
+ const head = node('div', 'wall-tools-head');
184
+ const words = node('div', 'wall-tools-words');
185
+ words.append(node('b', null, t('Framing')));
186
+ const hint = node('span', 'wall-tools-hint');
187
+ words.append(hint);
188
+ head.append(words);
189
+ const frame = node('div', 'wall-frame');
190
+ const fit = node('span', 'wall-frame-fit');
191
+ const picture = node('img', 'wall-frame-pic');
192
+ picture.alt = '';
193
+ picture.src = `/api/looks/wallpaper?v=${meta.v}`;
194
+ picture.draggable = false;
195
+ const view = node('span', 'wall-frame-view');
196
+ view.tabIndex = 0;
197
+ view.setAttribute('role', 'slider');
198
+ view.setAttribute('aria-label', t('Framing of your picture'));
199
+ fit.append(picture, view);
200
+ frame.append(fit);
201
+ let focus = { ...meta.focus };
202
+ const sides = () => {
203
+ const pictureRatio = meta.w / meta.h;
204
+ const screen = (globalThis.innerWidth || 16) / (globalThis.innerHeight || 9);
205
+ return { fw: Math.min(1, screen / pictureRatio), fh: Math.min(1, pictureRatio / screen) };
206
+ };
207
+ const free = () => { const { fw, fh } = sides(); return fw < 0.995 ? 'x' : fh < 0.995 ? 'y' : null; };
208
+ const place = () => {
209
+ const { fw, fh } = sides();
210
+ view.style.width = `${fw * 100}%`;
211
+ view.style.height = `${fh * 100}%`;
212
+ view.style.left = `${(1 - fw) * focus.x}%`;
213
+ view.style.top = `${(1 - fh) * focus.y}%`;
214
+ const axis = free();
215
+ frame.dataset.free = axis ?? 'none';
216
+ hint.textContent = axis ? t('Drag the frame to choose which part of your picture stays in view.') : t('Your picture fills the screen as it is.');
217
+ view.setAttribute('aria-valuetext', `${Math.round(axis === 'y' ? focus.y : focus.x)}%`);
218
+ };
219
+ const live = () => globalThis.document?.documentElement?.style?.setProperty('--wpc-pos', `${focus.x}% ${focus.y}%`);
220
+ const settle = async () => {
221
+ const kept = await sendFrame(focus);
222
+ if (kept) { paintCustomWall(kept); changed(kept, { reframed: true }); } else failed(t('The framing could not be kept.'));
223
+ };
224
+ const clamp = (value) => Math.max(0, Math.min(100, Math.round(value * 10) / 10));
225
+ const follow = (event) => {
226
+ const area = fit.getBoundingClientRect();
227
+ const { fw, fh } = sides();
228
+ const u = (event.clientX - area.left) / area.width;
229
+ const v = (event.clientY - area.top) / area.height;
230
+ if (fw < 0.995) focus.x = clamp(((u - fw / 2) / (1 - fw)) * 100);
231
+ if (fh < 0.995) focus.y = clamp(((v - fh / 2) / (1 - fh)) * 100);
232
+ place();
233
+ live();
234
+ };
235
+ fit.addEventListener('pointerdown', (event) => {
236
+ if (!free()) return;
237
+ event.preventDefault();
238
+ fit.setPointerCapture?.(event.pointerId);
239
+ frame.classList.add('moving');
240
+ follow(event);
241
+ const move = (next) => follow(next);
242
+ const up = () => {
243
+ fit.removeEventListener('pointermove', move);
244
+ frame.classList.remove('moving');
245
+ void settle();
246
+ };
247
+ fit.addEventListener('pointermove', move);
248
+ fit.addEventListener('pointerup', up, { once: true });
249
+ fit.addEventListener('pointercancel', up, { once: true });
250
+ });
251
+ view.addEventListener('keydown', (event) => {
252
+ const axis = free();
253
+ const step = { ArrowLeft: -5, ArrowUp: -5, ArrowRight: 5, ArrowDown: 5 }[event.key];
254
+ if (!axis || step === undefined) return;
255
+ event.preventDefault();
256
+ focus[axis] = clamp(focus[axis] + step);
257
+ place();
258
+ live();
259
+ void settle();
260
+ });
261
+ picture.addEventListener('load', place, { once: true });
262
+ placeFrame = place;
263
+ place();
264
+ const acts = node('div', 'wall-tools-acts');
265
+ const change = button(t('Change picture'), () => void addPicture({ busy: change, chosen: (kept) => changed(kept, { reframed: false }), failed }));
266
+ const remove = button(t('Remove'), async () => {
267
+ remove.disabled = true;
268
+ await dropWallpaper();
269
+ paintCustomWall(null);
270
+ removed();
271
+ });
272
+ acts.append(change, remove);
273
+ box.append(head, frame, acts);
274
+ return box;
275
+ }
@@ -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
  import { runReadonlyProcess } from './process.mjs';
2
4
 
3
5
  // dontAsk denies any tool use that is not pre-approved, so under a lease the
@@ -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
  import { readdir } from 'node:fs/promises';
2
4
  import { homedir } from 'node:os';
3
5
  import { join } from 'node:path';