@jossuealcala/madre 1.0.2 → 1.0.4

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 (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1635 -159
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +395 -26
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +8 -8
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
package/public/tour.js CHANGED
@@ -24,6 +24,8 @@ import { CHAPTERS, GESTURES, ORDER } from './tour-chapters.js';
24
24
  const say = (pair) => (typeof pair === 'string' ? pair : pair?.[language()] ?? pair?.es ?? '');
25
25
  const fill = (template, vars) => template.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
26
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>';
27
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>';
28
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>';
29
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>';
@@ -104,7 +106,7 @@ export function spotlight(node) {
104
106
  window.addEventListener('resize', place);
105
107
  window.addEventListener('scroll', place, true);
106
108
  place();
107
- return { place, remove };
109
+ return { place, remove, node: spot };
108
110
  }
109
111
 
110
112
  // `onChange` hears the kept state each time it is known or changes (null if it could not be read).
@@ -517,15 +519,22 @@ export function startTour({ page, go = {}, ready = () => true, phone = false, de
517
519
  card.setAttribute('role', 'note');
518
520
  card.append(el('span', 'tour-hint-eyebrow', t('The first time')), el('b', null, title), el('p', null, text));
519
521
  let spot = null;
522
+ let away = null;
523
+ let follow = null;
520
524
  const done = () => {
521
525
  if (!hintNode) return;
522
526
  void keep({ hint: id });
523
527
  spot?.remove();
524
528
  card.remove();
529
+ away?.remove();
525
530
  hintNode = null;
526
531
  document.removeEventListener('keydown', onKey, true);
532
+ if (follow) { window.removeEventListener('scroll', follow, true); window.removeEventListener('resize', follow); }
527
533
  };
528
- 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(); } };
529
538
  card.append(key('tour-btn small primary', t('Got it'), done));
530
539
  hintNode = card;
531
540
  if (inside) {
@@ -541,11 +550,44 @@ export function startTour({ page, go = {}, ready = () => true, phone = false, de
541
550
  const width = Math.min(300, window.innerWidth - 24);
542
551
  card.style.width = `${width}px`;
543
552
  const height = card.getBoundingClientRect().height;
544
- const onTheRight = box.left + box.width / 2 > window.innerWidth / 2;
545
- const left = Math.max(12, Math.min(window.innerWidth - width - 12, onTheRight ? box.right - width : box.left));
546
- const below = box.bottom + 10 + height < window.innerHeight;
547
- 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
+ };
548
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;
549
591
  document.addEventListener('keydown', onKey, true);
550
592
  return true;
551
593
  }
@@ -36,6 +36,10 @@ const installAgent = {
36
36
  darwin: ['brew install opencode', '# or: npm install -g opencode-ai'],
37
37
  linux: ['curl -fsSL https://opencode.ai/install | bash', '# or: npm install -g opencode-ai']
38
38
  },
39
+ cursor: {
40
+ darwin: ['curl https://cursor.com/install -fsS | bash'],
41
+ linux: ['curl https://cursor.com/install -fsS | bash']
42
+ },
39
43
  };
40
44
 
41
45
  const loginAgent = {
@@ -43,6 +47,7 @@ const loginAgent = {
43
47
  claude: { darwin: ['claude auth login', 'claude auth status --text'], linux: ['claude auth login', 'claude auth status --text']},
44
48
  gemini: { darwin: ['gemini', '# inside gemini: /auth → "Use Gemini API key" or Google login'], linux: ['gemini', '# inside gemini: /auth']},
45
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']},
46
51
  };
47
52
 
48
53
  const same = (commands) => ({ darwin: commands, linux: commands });
@@ -706,7 +711,7 @@ export const allConditions = () => CONDITIONS.map(said);
706
711
 
707
712
  // Lowercase and without accents: «limite» finds «límite», and a person types what they type.
708
713
  const fold = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
709
- const AGENT_WORDS = new Set(['codex', 'claude', 'gemini', 'opencode', 'madre', 'ollama']);
714
+ const AGENT_WORDS = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor', 'madre', 'ollama']);
710
715
 
711
716
  export function searchConditions(query) {
712
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
+ }
@@ -0,0 +1,96 @@
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
+ // Cursor's agent CLI (`cursor-agent`, 2026.05), added 2026-10-17. It reads only: every turn runs in
4
+ // Cursor's own ask mode (`--mode ask`, «Q&A style, read-only») under a configuration made for the
5
+ // turn and removed after it (CURSOR_CONFIG_DIR), which denies every command and every write and
6
+ // keeps the sandbox off. Print mode alone would give it every tool, write and shell included, so
7
+ // neither is ever left out. Its sign-in lives in the system keychain, not in that folder, so the
8
+ // turn keeps the person's session and nothing else of their settings.
9
+ //
10
+ // Tried against the real CLI on 2026-10-17: it read and answered; asked to create a file and to run
11
+ // `touch`, it wrote nothing and said it was in Ask mode. `--sandbox enabled` was dropped: on a Mac
12
+ // where Cursor's sandbox is not available the run ends at once («Sandbox mode is enabled but not
13
+ // available on this system»). Writing (#2 and up) waits for its own trial (capabilities.mjs).
14
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { runReadonlyProcess } from './process.mjs';
18
+
19
+ // What the turn's own configuration allows: reading, and nothing else.
20
+ export const READONLY_CONFIG = {
21
+ version: 1,
22
+ permissions: { allow: [], deny: ['Shell(*)', 'Write(**)'] },
23
+ approvalMode: 'allowlist',
24
+ sandbox: { mode: 'disabled' },
25
+ };
26
+
27
+ export function buildCursorArgs({ projectRoot, prompt, model = process.env.PULSE_CURSOR_MODEL || null }) {
28
+ return [
29
+ '--print',
30
+ '--output-format', 'stream-json',
31
+ '--mode', 'ask',
32
+ // A headless run cannot answer the «trust this folder?» question; the project is the human's.
33
+ '--trust',
34
+ '--workspace', projectRoot,
35
+ ...(model && model !== 'auto' ? ['--model', model] : []),
36
+ prompt,
37
+ ];
38
+ }
39
+
40
+ export function cursorEnvironment(environment = process.env, { configDir = null } = {}) {
41
+ // A turn never opens a browser: a sign-in is the room's to start, from Settings › Agents.
42
+ return { ...environment, NO_OPEN_BROWSER: '1', ...(configDir ? { CURSOR_CONFIG_DIR: configDir } : {}) };
43
+ }
44
+
45
+ // `--output-format stream-json` writes one JSON object per line and closes with a `result` line
46
+ // that carries the whole answer and, when the CLI has it, the turn's tokens. The assistant lines
47
+ // before it are the same text in pieces: read when the result never came (the run was cut short).
48
+ export function parseCursorOutput(output) {
49
+ const pieces = [];
50
+ let result = null;
51
+ let usage = null;
52
+ let error = null;
53
+ for (const line of String(output ?? '').split('\n').filter(Boolean)) {
54
+ let event;
55
+ try { event = JSON.parse(line); } catch { continue; }
56
+ if (event?.type === 'assistant') {
57
+ for (const part of event.message?.content ?? []) if (part?.type === 'text' && typeof part.text === 'string') pieces.push(part.text);
58
+ }
59
+ if (event?.type === 'result') {
60
+ if (event.is_error) error = typeof event.result === 'string' && event.result.trim() ? event.result.trim() : 'Cursor returned an error.';
61
+ else if (typeof event.result === 'string') result = event.result;
62
+ const tokens = event.usage;
63
+ if (tokens && typeof tokens === 'object') {
64
+ const input = Number(tokens.inputTokens ?? tokens.input_tokens ?? 0);
65
+ const output = Number(tokens.outputTokens ?? tokens.output_tokens ?? 0);
66
+ const cached = Number(tokens.cacheReadTokens ?? tokens.cache_read_tokens ?? 0);
67
+ usage = { inputTokens: input, cachedInputTokens: cached, outputTokens: output, reasoningTokens: 0, totalTokens: input + output, costUsd: null, source: 'cursor-json' };
68
+ }
69
+ }
70
+ if (event?.type === 'error' && !error) error = String(event.error?.message ?? event.message ?? 'Cursor returned an error.');
71
+ }
72
+ // A run that ended in an error answers nothing, so the room reports the error, not a fragment.
73
+ const text = error ? '' : (result ?? pieces.join('')).trim();
74
+ return { text, usage, ...(error ? { error } : {}) };
75
+ }
76
+
77
+ export async function invokeCursor({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, onProgress = null }) {
78
+ const configDir = await mkdtemp(join(tmpdir(), 'madre-cursor-'));
79
+ try {
80
+ await writeFile(join(configDir, 'cli-config.json'), `${JSON.stringify(READONLY_CONFIG, null, 2)}\n`);
81
+ return await runReadonlyProcess({
82
+ onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
83
+ executable,
84
+ args: buildCursorArgs({ projectRoot, prompt, ...(model ? { model } : {}) }),
85
+ cwd: projectRoot,
86
+ env: cursorEnvironment(process.env, { configDir }),
87
+ timeoutMs,
88
+ signal,
89
+ label: 'Cursor',
90
+ parse: parseCursorOutput,
91
+ });
92
+ } finally {
93
+ // The chats and caches the CLI wrote there go with it: a turn keeps no session of its own.
94
+ await rm(configDir, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }).catch(() => {});
95
+ }
96
+ }
@@ -278,7 +278,14 @@ export function diagnoseGeminiStderr(stderr) {
278
278
  if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
279
279
  return { code: 'UNAVAILABLE', message: t('Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.'), hint: t('Try again shortly or choose another model.') };
280
280
  }
281
- if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
281
+ // Google closed Gemini Code Assist for individuals to this CLI (seen 2026-10-17): the personal
282
+ // login still holds a valid token and every request is refused before it reaches a model. Named
283
+ // apart from a bad key so the room says what to do, and with Google's own word in the message so
284
+ // Diagnóstico finds its entry (gemini-ineligible-tier) in whatever language the room speaks.
285
+ if (/IneligibleTierError|no longer supported for Gemini Code Assist|migrate to the Antigravity/i.test(text)) {
286
+ return { code: 'INELIGIBLE', message: t('Google no longer accepts this Gemini sign-in (IneligibleTierError): the free personal Google account is refused before it reaches a model.'), hint: t('Paste a Gemini API key from Google AI Studio in Settings › Agents › Gemini, or run `gemini`, type /auth and pick "Use Gemini API key".') };
287
+ }
288
+ if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid/i.test(text)) {
282
289
  return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
283
290
  }
284
291
  // A subagent, refused. The policy is right to refuse it — a subagent answers outside the lease
@@ -337,7 +344,7 @@ export async function invokeGemini({
337
344
  const diagnosis = diagnoseGeminiStderr(stderr);
338
345
  if (!diagnosis) return null;
339
346
  firstSeenAt ??= Date.now();
340
- const terminal = diagnosis.code === 'CREDITS_DEPLETED' || diagnosis.code === 'AUTH';
347
+ const terminal = diagnosis.code === 'CREDITS_DEPLETED' || diagnosis.code === 'AUTH' || diagnosis.code === 'INELIGIBLE';
341
348
  if (!terminal && Date.now() - firstSeenAt < failFastMs) return null;
342
349
  return Object.assign(new Error(`${diagnosis.message} ${diagnosis.hint}`), { code: diagnosis.code, diagnosis });
343
350
  };
@@ -357,7 +364,7 @@ export async function invokeGemini({
357
364
  } catch (error) {
358
365
  const produced = parseGeminiOutput(error.partialOutput ?? '').text;
359
366
  const diagnosis = error.diagnosis ?? diagnoseGeminiStderr(error.partialStderr ?? error.stderr ?? '');
360
- if (diagnosis?.code === 'AUTH' || diagnosis?.code === 'CREDITS_DEPLETED') {
367
+ if (diagnosis?.code === 'AUTH' || diagnosis?.code === 'INELIGIBLE' || diagnosis?.code === 'CREDITS_DEPLETED') {
361
368
  error.message = `${diagnosis.message} ${diagnosis.hint}`;
362
369
  error.code = diagnosis.code;
363
370
  throw error;
@@ -6,6 +6,7 @@
6
6
  // leaves the machine. It grows as the memory grows.
7
7
 
8
8
  import { ollamaGenerate } from '../ollama.mjs';
9
+ import { inLanguage } from '../i18n.mjs';
9
10
 
10
11
  export const MADRE_AGENT_ID = 'madre';
11
12
  export const MADRE_ADAPTER = 'madre-local';
@@ -232,7 +233,7 @@ export function gist(text, max = 240) {
232
233
  export function roundBullets(round, { es = true } = {}) {
233
234
  return round.answers.map((answer) => (answer.sequence
234
235
  ? `- @${answer.agent} [#${answer.sequence}]: ${gist(answer.text)}`
235
- : `- @${answer.agent}: ${es ? 'no respondió' : 'did not answer'}${answer.error ? ` (${String(answer.error).replace(/[.\s]+$/, '')})` : ''}.`)).join('\n');
236
+ : `- @${answer.agent}: ${es ? 'no respondió' : 'did not answer'}${answer.error ? ` (${gist(inLanguage(answer.error, es ? 'es' : 'en'), 200).replace(/[.\s]+$/, '')})` : ''}.`)).join('\n');
236
237
  }
237
238
  export function comparisonHolds(line, round) {
238
239
  const spoke = new Set(round.answers.filter((answer) => answer.sequence).map((answer) => answer.agent));