@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/src/enlaces.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  // which room: that limit is written down, not papered over.
18
18
 
19
19
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
20
- import { appendFile, chmod, link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
20
+ import { appendFile, chmod, link, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { withConfig } from './config.mjs';
23
23
  import { RECALLED_ZONES, STANDING_KINDS } from './memory.mjs';
@@ -40,7 +40,23 @@ export const NO_SUCH_LINK = 'No such link.';
40
40
  // `order` is one of BISHOP's orders that reached this room (fase 4b). It is counted apart from the
41
41
  // readings, each kind against the contract's own `perHour`: asking BISHOP a question, which reads,
42
42
  // never uses up the orders, and an order never uses up a reading.
43
- export const CROSSINGS = ['read', 'received', 'order'];
43
+ // `corpus` is a whole reading for the Coordinator's training (D-S15), counted apart from the
44
+ // readings of a turn and against its own ceiling; `learned` is the same crossing seen from Salas.
45
+ // `state` is the project's canonical state read across a link (D-S16), counted against `perHour`
46
+ // apart from the readings of notes. `delivered` is a note another project handed to this one, kept
47
+ // pending until this project accepts it (D-S17); `sent` is the same crossing seen from the sender.
48
+ export const CROSSINGS = ['read', 'received', 'order', 'corpus', 'learned', 'state', 'delivered', 'sent'];
49
+ // D-S15: what one training export may take from one project, and how many such exports an hour.
50
+ export const CORPUS_MAX = 5000;
51
+ export const CORPUS_PER_HOUR = 6;
52
+ // D-S17: what one delivery may carry, how long each note may be, and how many may wait at once in
53
+ // the project that receives them before it accepts or declines some.
54
+ export const DELIVER_MAX = 5;
55
+ export const DELIVER_CHARS = 600;
56
+ export const INBOX_MAX = 50;
57
+ // The permissions a link carries besides reading, each ticked only by the human from Home: D-S14
58
+ // (recall), D-S15 (train), D-S16 (state) and D-S17 (deliver). BISHOP never signs any of them.
59
+ export const LINK_FLAGS = ['recall', 'train', 'state', 'deliver'];
44
60
  // What one room may tell another about a link, by the channel: that it was signed or revoked.
45
61
  // Each lands in the other room's ledger as a line, never as a message (phase 3c).
46
62
  export const NOTICES = ['enlace.signed', 'enlace.revoked'];
@@ -128,6 +144,18 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
128
144
  // asking. Off unless the human ticks it; a reading like any other, counted against `perHour`.
129
145
  if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('automatic recall can only be on or off.');
130
146
  const recall = raw.recall === true;
147
+ // D-S15: the Coordinator may learn from this project — its notes enter the Coordinator's training
148
+ // export (src/dataset.mjs) and, once trained, its local model. Off unless the human ticks it, and
149
+ // only on a link the Coordinator reads; checked against the Coordinator below.
150
+ if (raw.train !== undefined && raw.train !== null && typeof raw.train !== 'boolean') fail('learning can only be on or off.');
151
+ const train = raw.train === true;
152
+ // D-S16: the room that reads may also read the project's canonical state (Continuity, AHP+).
153
+ if (raw.state !== undefined && raw.state !== null && typeof raw.state !== 'boolean') fail('sharing the project state can only be on or off.');
154
+ const state = raw.state === true;
155
+ // D-S17: the room that reads may hand notes back to the room that serves, where they wait until
156
+ // that project accepts them. A delivery never writes in the other project's memory by itself.
157
+ if (raw.deliver !== undefined && raw.deliver !== null && typeof raw.deliver !== 'boolean') fail('delivering notes can only be on or off.');
158
+ const deliver = raw.deliver === true;
131
159
 
132
160
  const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
133
161
  const maxMode = orders.maxMode === undefined ? 1 : Number(orders.maxMode);
@@ -140,6 +168,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
140
168
  const serves = direction === 'both' ? [a, b] : direction === 'a→b' ? [a] : [b];
141
169
  if (serves.includes(salas)) fail('The Coordinator never shares through a connection: it gathers every project and is nobody\'s source.');
142
170
  if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator at one end of the connection.');
171
+ if (train && a !== salas && b !== salas) fail('Only the Coordinator learns from a connection.');
143
172
  }
144
173
 
145
174
  // Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
@@ -151,6 +180,9 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
151
180
  const at = signed.at === undefined || signed.at === null ? new Date(now).toISOString() : isoOrNull(signed.at, 'signed.at');
152
181
  if (by === 'bishop') {
153
182
  if (allowed) fail('The Coordinator signs only connections to read; a connection with orders is signed by you.');
183
+ if (train) fail('The Coordinator never signs what it learns from: you do.');
184
+ if (state) fail('The Coordinator never signs a connection that shares the project state: you do.');
185
+ if (deliver) fail('The Coordinator never signs a connection that delivers notes: you do.');
154
186
  if (expires === null || Date.parse(expires) - Date.parse(at) > BISHOP_SIGN_DAYS * DAY_MS + 60000) fail('The Coordinator signs only connections that expire within 7 days.'); // BISHOP_SIGN_DAYS, said literally for the catalogue
155
187
  }
156
188
 
@@ -165,6 +197,9 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
165
197
  orders: { allowed, maxMode, perHour },
166
198
  signed: { at, by },
167
199
  recall,
200
+ train,
201
+ state,
202
+ deliver,
168
203
  };
169
204
  }
170
205
 
@@ -274,6 +309,46 @@ export async function recallContract(stateRoot, id, recall, { now = new Date(),
274
309
  return changed;
275
310
  }
276
311
 
312
+ // «The Coordinator may learn from this project» (D-S15), ticked or cleared by the human: the same
313
+ // one-step read and write as recall, on a link still in force. Cleared, nothing more of that
314
+ // project enters the Coordinator's next export, and Salas forgets the copy it kept for it.
315
+ export async function trainContract(stateRoot, id, train, { now = new Date(), salas = null } = {}) {
316
+ if (typeof id !== 'string' || !ID.test(id)) return null;
317
+ let changed = null;
318
+ await withConfig(stateRoot, (config) => {
319
+ if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
320
+ const next = { ...config.enlaces[id], train: train === true };
321
+ normalizeContract({ ...next, id }, { now, salas });
322
+ config.enlaces[id] = next;
323
+ changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
324
+ return config;
325
+ });
326
+ return changed;
327
+ }
328
+
329
+ // D-S16 and D-S17: «it shares its state» or «notes can be delivered to it», ticked or cleared by
330
+ // the human from Home. The same one-step read and write as recall and train, on a link in force.
331
+ export async function allowContract(stateRoot, id, field, on, { now = new Date(), salas = null } = {}) {
332
+ if (!['state', 'deliver'].includes(field)) fail('That is not something a connection can allow.');
333
+ if (typeof id !== 'string' || !ID.test(id)) return null;
334
+ let changed = null;
335
+ await withConfig(stateRoot, (config) => {
336
+ if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
337
+ const next = { ...config.enlaces[id], [field]: on === true };
338
+ normalizeContract({ ...next, id }, { now, salas });
339
+ config.enlaces[id] = next;
340
+ changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
341
+ return config;
342
+ });
343
+ return changed;
344
+ }
345
+
346
+ // The rooms `sender` may deliver notes to (D-S17): the rooms it reads from under a link that says
347
+ // `deliver`. A delivery goes against the arrow — to the project whose notes this one reads.
348
+ export function deliversTo(contracts, sender, { now = new Date() } = {}) {
349
+ return readsFrom(contracts, sender, { now }).filter(({ contract }) => contract.deliver).map(({ contract, from }) => ({ contract, to: from }));
350
+ }
351
+
277
352
  /* ---------- the key of the channel ---------- */
278
353
 
279
354
  export const channelKeyFile = (stateRoot) => join(stateRoot, 'credentials', 'enlaces.json');
@@ -373,6 +448,137 @@ export async function askEnlace({ port, contract, asking, q = '', kinds = null,
373
448
  return { contract: id, from, notes };
374
449
  }
375
450
 
451
+ // D-S15: the whole of what a project lets the Coordinator learn — every note that travels, of the
452
+ // kinds signed, cleaned by that project's privacy guard — asked by Salas when it exports its
453
+ // training data. The same doors as a reading (no Origin, no device, the key), its own route and its
454
+ // own ceiling. Returns `{ contract, from, name, notes }`, or `refused`, `outdated`, `unreachable`.
455
+ export async function askCorpus({ port, contract, asking, key, fetchImpl = globalThis.fetch, timeoutMs = 15000, record = null, roomDir = null, now = new Date() }) {
456
+ const id = typeof contract === 'string' ? contract : contract?.id;
457
+ if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
458
+ const params = new URLSearchParams({ contract: id, asking });
459
+ let response;
460
+ try {
461
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/corpus?${params}`, {
462
+ method: 'GET',
463
+ headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
464
+ signal: AbortSignal.timeout(timeoutMs),
465
+ });
466
+ } catch {
467
+ return { unreachable: true };
468
+ }
469
+ const answer = await response.json().catch(() => ({}));
470
+ if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
471
+ if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
472
+ const notes = Array.isArray(answer?.notes) ? answer.notes.slice(0, CORPUS_MAX) : [];
473
+ const from = typeof answer?.from === 'string' ? answer.from : null;
474
+ const name = typeof answer?.name === 'string' ? answer.name : from;
475
+ if (record) await record('enlace.learned', { contract: id, from, count: notes.length });
476
+ if (roomDir) await noteCrossing(roomDir, { kind: 'learned', contract: id, other: from, count: notes.length, now });
477
+ return { contract: id, from, name, notes };
478
+ }
479
+
480
+ // D-S16: the project's canonical state, asked by the room that reads under a link that says
481
+ // `state`. The same doors as a reading. Returns `{ contract, from, name, state }` — `state` is null
482
+ // when that project keeps no Continuity state — or `refused`, `outdated`, `unreachable`.
483
+ export async function askState({ port, contract, asking, key, fetchImpl = globalThis.fetch, timeoutMs = 6000, agent = null }) {
484
+ const id = typeof contract === 'string' ? contract : contract?.id;
485
+ if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
486
+ const params = new URLSearchParams({ contract: id, asking });
487
+ if (agent) params.set('agent', String(agent));
488
+ let response;
489
+ try {
490
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/state?${params}`, { method: 'GET', headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' }, signal: AbortSignal.timeout(timeoutMs) });
491
+ } catch {
492
+ return { unreachable: true };
493
+ }
494
+ const answer = await response.json().catch(() => ({}));
495
+ if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
496
+ if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
497
+ return { contract: id, from: typeof answer?.from === 'string' ? answer.from : null, name: typeof answer?.name === 'string' ? answer.name : null, state: answer?.state && typeof answer.state === 'object' ? answer.state : null };
498
+ }
499
+
500
+ // D-S17: notes handed to the project this room reads from, under a link that says `deliver`. They
501
+ // land there as pending; nothing enters that project's memory until it accepts them. Returns
502
+ // `{ accepted: n, ids }`, or `refused`, `outdated`, `unreachable`.
503
+ export async function deliverNotes({ port, contract, from, notes, key, agent = null, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {
504
+ const id = typeof contract === 'string' ? contract : contract?.id;
505
+ if (!id || !from) return { refused: NO_SUCH_LINK, status: 404 };
506
+ let response;
507
+ try {
508
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/deliver`, {
509
+ method: 'POST',
510
+ headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
511
+ body: JSON.stringify({ contract: id, from, agent, notes }),
512
+ signal: AbortSignal.timeout(timeoutMs),
513
+ });
514
+ } catch {
515
+ return { unreachable: true };
516
+ }
517
+ const answer = await response.json().catch(() => ({}));
518
+ if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
519
+ if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
520
+ return { accepted: Number(answer?.accepted) || 0, ids: Array.isArray(answer?.ids) ? answer.ids : [] };
521
+ }
522
+
523
+ // A note as a delivery may carry it: a kind that stands, a text of DELIVER_CHARS at most, and the
524
+ // reason in one line. Anything else is refused whole, so a sender never believes a note went.
525
+ export function deliverable(raw) {
526
+ const kind = String(raw?.kind ?? '');
527
+ if (!LINK_KINDS.includes(kind)) fail('A delivered note is a decision, a fact, a preference or a question.');
528
+ const text = String(raw?.text ?? '').replace(/\s+/g, ' ').trim();
529
+ if (!text || text.length > DELIVER_CHARS) fail('A delivered note has between 1 and 600 characters.'); // DELIVER_CHARS, said literally for the catalogue
530
+ const why = String(raw?.why ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) || null;
531
+ return { kind, text, why };
532
+ }
533
+
534
+ /* ---------- the inbox of delivered notes (D-S17) ---------- */
535
+
536
+ // Beside the room's record, one file: every note another project delivered here, with what became
537
+ // of it. Pending until this project accepts or declines it; accepted, it enters the memory the way
538
+ // a distilled note does (held first, like any new note); declined, it is kept as a fact of the record.
539
+ export const inboxFile = (roomDir) => join(roomDir, 'enlaces-inbox.json');
540
+ const inboxLocks = new Map();
541
+ async function changeInbox(roomDir, change) {
542
+ const file = inboxFile(roomDir);
543
+ const before = inboxLocks.get(file) ?? Promise.resolve();
544
+ let result;
545
+ const run = before.then(async () => {
546
+ let data = { notes: [] };
547
+ try { const parsed = JSON.parse(await readFile(file, 'utf8')); if (Array.isArray(parsed?.notes)) data = parsed; } catch { /* a missing or torn inbox is empty */ }
548
+ result = await change(data);
549
+ await mkdir(roomDir, { recursive: true });
550
+ const draft = `${file}.${process.pid}.tmp`;
551
+ await writeFile(draft, `${JSON.stringify(data, null, 2)}\n`, { mode: OWNER_ONLY });
552
+ await rename(draft, file);
553
+ });
554
+ inboxLocks.set(file, run.catch(() => null));
555
+ await run;
556
+ return result;
557
+ }
558
+ export async function readInbox(roomDir) {
559
+ try { const parsed = JSON.parse(await readFile(inboxFile(roomDir), 'utf8')); return Array.isArray(parsed?.notes) ? parsed.notes : []; } catch { return []; }
560
+ }
561
+ export async function receiveDelivery(roomDir, { contract, from, name = null, agent = null, notes, now = new Date() }) {
562
+ return changeInbox(roomDir, (data) => {
563
+ const pending = data.notes.filter((note) => note.status === 'pending').length;
564
+ if (pending + notes.length > INBOX_MAX) fail('This project has too many delivered notes waiting. Accept or decline some first.', 'FULL');
565
+ const at = new Date(now).toISOString();
566
+ const added = notes.map((note) => ({ id: `d-${randomBytes(4).toString('hex')}`, contract, from, name: name ?? from, agent, kind: note.kind, text: note.text, why: note.why ?? null, at, status: 'pending', decidedBy: null, decidedAt: null }));
567
+ data.notes.push(...added);
568
+ // The record keeps what was decided; only the last 500 lines stay in the file.
569
+ if (data.notes.length > 500) data.notes = [...data.notes.filter((note) => note.status === 'pending'), ...data.notes.filter((note) => note.status !== 'pending').slice(-(500 - INBOX_MAX))];
570
+ return added;
571
+ });
572
+ }
573
+ export async function decideDelivery(roomDir, id, { accept, by = 'you', now = new Date() }) {
574
+ return changeInbox(roomDir, (data) => {
575
+ const note = data.notes.find((one) => one.id === id);
576
+ if (!note || note.status !== 'pending') return null;
577
+ Object.assign(note, { status: accept ? 'accepted' : 'declined', decidedBy: by, decidedAt: new Date(now).toISOString() });
578
+ return { ...note };
579
+ });
580
+ }
581
+
376
582
  // Tells another room, by the channel, that a link of its was signed or revoked. The same call as
377
583
  // `askEnlace` makes, the other way: no `Origin`, no device, the key in its header, a short wait.
378
584
  // What comes back is `{ ok }`, `{ outdated }` (that room runs a MADRE without the route),
@@ -33,9 +33,16 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
33
33
  // `onChild` hands the caller that way in, and `flushIdleMs` emits a line that never ends in a
34
34
  // newline — a prompt like `Paste code here > ` — once the output has been still for that long.
35
35
  // Without it the prompt sits in the buffer and the room shows a sign-in that says nothing.
36
- export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
36
+ // `ci: false` leaves CI out: Gemini reads it as "no browser here" and will not open one.
37
+ // A spinner redraws its line in place (back to the first column, then the next frame) and never
38
+ // ends it: only the last frame is the line, or a sign-in that waits fifteen minutes piles up
39
+ // thousands of them in one.
40
+ const REDRAW = '\u001b[999D';
41
+ const redrawn = (text) => { const at = text.lastIndexOf(REDRAW); return at < 0 ? text : text.slice(at + REDRAW.length).replace(/^\u001b\[J/, ''); };
42
+
43
+ export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, ci = true, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
37
44
  return new Promise((resolve) => {
38
- const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
45
+ const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', ...(ci ? { CI: '1' } : {}) }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
39
46
  const started = Date.now();
40
47
  let lastOutput = started;
41
48
  let buffer = '';
@@ -55,10 +62,11 @@ export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 6
55
62
  buffer += chunk;
56
63
  let index;
57
64
  while ((index = buffer.indexOf('\n')) >= 0) {
58
- const line = buffer.slice(0, index).replace(/\r$/, '');
65
+ const line = redrawn(buffer.slice(0, index)).replace(/\r$/, '');
59
66
  buffer = buffer.slice(index + 1);
60
67
  if (line.trim()) emit(line, stream);
61
68
  }
69
+ buffer = redrawn(buffer);
62
70
  };
63
71
  child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
64
72
  child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
package/src/i18n.mjs CHANGED
@@ -38,6 +38,22 @@ export function hostWords(text) {
38
38
  .replace(/(^|[^\p{L}])(esta|tu|la|Esta|Tu|La) Mac\b/gu, '$1$2 computadora');
39
39
  }
40
40
 
41
+ // A sentence MADRE wrote in one language, quoted in the other. A failure is recorded in the
42
+ // language the room spoke when it happened, and @madre's round summary quoted it as it was: a
43
+ // Spanish summary said «(Google rejected the Gemini credentials…)» (manual tests, 2026-10-17).
44
+ // Only whole sentences of the catalogue are swapped, never a word inside one, so ids, commands and
45
+ // whatever an agent wrote stay as they are.
46
+ let sentencePairs = null;
47
+ export function inLanguage(text, lang) {
48
+ let out = String(text ?? '');
49
+ sentencePairs ??= Object.entries(ES).filter(([en, es]) => typeof es === 'string' && en !== es && en.length >= 24 && /[.!?»)]$/.test(en));
50
+ for (const [en, es] of sentencePairs) {
51
+ const [from, to] = lang === 'es' ? [en, hostWords(es)] : [hostWords(es), en];
52
+ if (out.includes(from)) out = out.split(from).join(to);
53
+ }
54
+ return out;
55
+ }
56
+
41
57
  export function t(text, vars = null) {
42
58
  if (typeof text !== 'string' || !text) return text;
43
59
  const said = hostWords(CATALOGUES[current]?.[text] ?? text);
package/src/looks.mjs CHANGED
@@ -8,9 +8,10 @@
8
8
  // so a page paints in the last known look before it hears from here.
9
9
  //
10
10
  // Only what the pages share: the theme, the accent, the wallpaper and its dials, the bubbles and
11
- // the text of the conversation. How Home lays out its own cards stays in Home's browser.
11
+ // the text of the conversation. How Home is arranged lives beside it, in home.json (below).
12
12
 
13
- import { readFile } from 'node:fs/promises';
13
+ import { createHash } from 'node:crypto';
14
+ import { readFile, rm } from 'node:fs/promises';
14
15
  import { join } from 'node:path';
15
16
  import { oneAtATime, writeWhole } from './json-file.mjs';
16
17
 
@@ -25,6 +26,8 @@ export const LOOK_KEYS = {
25
26
  'pulse.wallpaper.blur': NUMBER,
26
27
  'pulse.bubbles': CHOICE,
27
28
  'pulse.text': CHOICE,
29
+ // The maps drawn as neurons (public/neural.js): 'on', or nothing.
30
+ 'pulse.neural': CHOICE,
28
31
  };
29
32
  const looksFile = (stateRoot) => join(stateRoot, 'looks.json');
30
33
 
@@ -88,3 +91,123 @@ export function changeLooks(stateRoot, patch, now = new Date()) {
88
91
  return { looks, saved: true };
89
92
  });
90
93
  }
94
+
95
+ // HOME, ARRANGED ONCE (2026-10-16). Home is shown by whichever process answers /salas — the
96
+ // Coordinator's, or any project's — and each is another port, so the arrangement Home kept in the
97
+ // browser was lost on every other page and whenever MADRE came back on another port. What the
98
+ // person arranged lives here, in ~/.pulse/home.json: the tiles and their sizes, the order of the
99
+ // projects, the projects on the bar, and how the cards read. Kept as the page keeps them (the same
100
+ // keys, the same text), each checked for its shape before it is written.
101
+ const homeFile = (stateRoot) => join(stateRoot, 'home.json');
102
+ const HOME_TEXT = 8000;
103
+ const isIds = (value) => Array.isArray(value) && value.length <= 500 && value.every((id) => typeof id === 'string' && id.length <= 200);
104
+ export const HOME_KEYS = {
105
+ 'pulse.home.layout': (value) => { try { const raw = JSON.parse(value); return Boolean(raw) && typeof raw === 'object' && !Array.isArray(raw) && (raw.order === undefined || isIds(raw.order)); } catch { return false; } },
106
+ 'pulse.home.projects': (value) => { try { return isIds(JSON.parse(value)); } catch { return false; } },
107
+ 'pulse.lobbyBar': (value) => { try { return isIds(JSON.parse(value)); } catch { return false; } },
108
+ 'pulse.home.cards': (value) => CHOICE.test(value),
109
+ 'pulse.home.text': (value) => CHOICE.test(value),
110
+ 'pulse.home.order': (value) => CHOICE.test(value),
111
+ };
112
+ export async function readHome(stateRoot) {
113
+ const raw = await readFile(homeFile(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
114
+ const home = {};
115
+ for (const [key, valid] of Object.entries(HOME_KEYS)) {
116
+ const value = raw?.home?.[key];
117
+ home[key] = typeof value === 'string' && value.length <= HOME_TEXT && valid(value) ? value : null;
118
+ }
119
+ return { home, saved: Boolean(raw && typeof raw.home === 'object') };
120
+ }
121
+ export function changeHome(stateRoot, patch, now = new Date()) {
122
+ return oneAtATime(homeFile(stateRoot), async () => {
123
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { error: 'home' };
124
+ for (const [key, value] of Object.entries(patch)) {
125
+ if (!Object.hasOwn(HOME_KEYS, key)) return { error: 'key' };
126
+ if (value !== null && (typeof value !== 'string' || value.length > HOME_TEXT || !HOME_KEYS[key](value))) return { error: 'value' };
127
+ }
128
+ const { home } = await readHome(stateRoot);
129
+ Object.assign(home, patch);
130
+ await writeWhole(homeFile(stateRoot), `${JSON.stringify({ home, at: new Date(now).toISOString() }, null, 2)}\n`);
131
+ return { home, saved: true };
132
+ });
133
+ }
134
+
135
+ // YOUR OWN WALLPAPER (1.0.3). One picture of the person's, for every page that chooses it (the
136
+ // choice is still each page's own: `pulse.wallpaper` = 'custom'). The page reads the picture,
137
+ // fits it (no longer side than WALLPAPER_SIDE), encodes it and takes its colours for the veil, the
138
+ // mesh and the tint, the way the wallpapers that ship were made by hand; here it is kept as it
139
+ // came: the picture, and beside it what it is (`custom.json`). Only a picture is kept: its first
140
+ // bytes say WebP, JPEG or PNG, or nothing is written. Never an SVG: it can carry a script.
141
+ export const WALLPAPER_BYTES = 8 * 1024 * 1024;
142
+ const wallDir = (stateRoot) => join(stateRoot, 'wallpaper');
143
+ const wallMeta = (stateRoot) => join(wallDir(stateRoot), 'custom.json');
144
+ const PICTURES = [
145
+ { type: 'image/webp', ext: 'webp', is: (b) => b.length > 12 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP' },
146
+ { type: 'image/jpeg', ext: 'jpg', is: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
147
+ { type: 'image/png', ext: 'png', is: (b) => b.length > 8 && b.toString('latin1', 1, 4) === 'PNG' && b[0] === 0x89 },
148
+ ];
149
+ const RGB = (value) => Array.isArray(value) && value.length === 3 && value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
150
+ const SCHEME = (value) => Boolean(value) && RGB(value.veil) && RGB(value.tint) && Array.isArray(value.mesh) && value.mesh.length === 3 && value.mesh.every(RGB);
151
+ const PERCENT = (value) => Number.isFinite(value) && value >= 0 && value <= 100;
152
+ // What a page sends about the picture, checked one field at a time.
153
+ function wallShape(meta) {
154
+ if (!meta || typeof meta !== 'object') return null;
155
+ const w = Number(meta.w);
156
+ const h = Number(meta.h);
157
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1 || w > 20000 || h > 20000) return null;
158
+ if (!SCHEME(meta.palette?.dark) || !SCHEME(meta.palette?.light)) return null;
159
+ const x = Number(meta.focus?.x ?? 50);
160
+ const y = Number(meta.focus?.y ?? 50);
161
+ if (!PERCENT(x) || !PERCENT(y)) return null;
162
+ const pick = (one) => ({ veil: one.veil, tint: one.tint, mesh: one.mesh });
163
+ return { w, h, focus: { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }, palette: { dark: pick(meta.palette.dark), light: pick(meta.palette.light) } };
164
+ }
165
+ // The picture kept, as a page reads it: its version (a hash of its bytes), its type, its size, where
166
+ // it is framed and its colours. `null` when there is none, or what is there cannot be trusted.
167
+ export async function readWallpaper(stateRoot) {
168
+ const raw = await readFile(wallMeta(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
169
+ const shape = wallShape(raw);
170
+ const kind = PICTURES.find((one) => one.type === raw?.type);
171
+ if (!shape || !kind || typeof raw.v !== 'string' || !/^[0-9a-f]{8,64}$/.test(raw.v)) return null;
172
+ return { v: raw.v, type: kind.type, ...shape };
173
+ }
174
+ export async function wallpaperBytes(stateRoot) {
175
+ const meta = await readWallpaper(stateRoot);
176
+ if (!meta) return null;
177
+ const kind = PICTURES.find((one) => one.type === meta.type);
178
+ const bytes = await readFile(join(wallDir(stateRoot), `custom.${kind.ext}`)).catch(() => null);
179
+ return bytes ? { bytes, type: meta.type, v: meta.v } : null;
180
+ }
181
+ export function saveWallpaper(stateRoot, bytes, meta) {
182
+ return oneAtATime(wallMeta(stateRoot), async () => {
183
+ if (!Buffer.isBuffer(bytes) || !bytes.length) return { error: 'empty' };
184
+ if (bytes.length > WALLPAPER_BYTES) return { error: 'size' };
185
+ const kind = PICTURES.find((one) => one.is(bytes));
186
+ if (!kind) return { error: 'type' };
187
+ const shape = wallShape(meta);
188
+ if (!shape) return { error: 'meta' };
189
+ const v = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
190
+ await writeWhole(join(wallDir(stateRoot), `custom.${kind.ext}`), bytes);
191
+ for (const other of PICTURES) if (other !== kind) await rm(join(wallDir(stateRoot), `custom.${other.ext}`), { force: true });
192
+ await writeWhole(wallMeta(stateRoot), `${JSON.stringify({ v, type: kind.type, ...shape, at: new Date().toISOString() }, null, 2)}\n`);
193
+ return { custom: { v, type: kind.type, ...shape } };
194
+ });
195
+ }
196
+ // Where the picture is framed: the point of it that stays in view when the screen crops it.
197
+ export function frameWallpaper(stateRoot, focus) {
198
+ return oneAtATime(wallMeta(stateRoot), async () => {
199
+ const raw = await readFile(wallMeta(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
200
+ if (!raw) return { error: 'none' };
201
+ const shape = wallShape({ ...raw, focus });
202
+ if (!shape) return { error: 'value' };
203
+ await writeWhole(wallMeta(stateRoot), `${JSON.stringify({ ...raw, focus: shape.focus, at: new Date().toISOString() }, null, 2)}\n`);
204
+ return { custom: await readWallpaper(stateRoot) };
205
+ });
206
+ }
207
+ export function dropWallpaper(stateRoot) {
208
+ return oneAtATime(wallMeta(stateRoot), async () => {
209
+ for (const kind of PICTURES) await rm(join(wallDir(stateRoot), `custom.${kind.ext}`), { force: true });
210
+ await rm(wallMeta(stateRoot), { force: true });
211
+ return { custom: null };
212
+ });
213
+ }
@@ -0,0 +1,8 @@
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
+ // The public half of the Market's key (scripts/market-key.mjs). The catalog at madre.run/mercado/
4
+ // is signed with the private half, which lives only on the author's computer; a catalog this key
5
+ // does not verify is not read, and nothing in it is installed (src/market-remote.mjs).
6
+ export const MARKET_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
7
+ MCowBQYDK2VwAyEA4/DXYprFh2wfiOPpkJheWR0uEtJA+Wl3+AnJXE1YGt0=
8
+ -----END PUBLIC KEY-----`;