@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.
- package/CHANGELOG.en.md +76 -1
- package/CHANGELOG.md +76 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1635 -159
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +395 -26
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +105 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +350 -31
- package/public/styles.css +844 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +8 -8
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server-sentinel.mjs +7 -3
- package/src/server.mjs +617 -48
- 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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
+
}
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/adapters/madre.mjs
CHANGED
|
@@ -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 ? ` (${
|
|
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));
|