@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/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
|
-
|
|
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),
|
package/src/extensions.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
11
|
+
// the text of the conversation. How Home is arranged lives beside it, in home.json (below).
|
|
12
12
|
|
|
13
|
-
import {
|
|
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-----`;
|