@jossuealcala/madre 0.5.2 → 0.7.0

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 (47) hide show
  1. package/CHANGELOG.md +265 -1
  2. package/README.md +33 -1
  3. package/docs/SDK.md +173 -6
  4. package/docs/sdk/connector-module.mjs +186 -0
  5. package/package.json +6 -2
  6. package/public/abduction.js +384 -0
  7. package/public/app.js +3450 -540
  8. package/public/assets/abduction-cat-blink.png +0 -0
  9. package/public/assets/abduction-cat-control-left.png +0 -0
  10. package/public/assets/abduction-cat-control-right.png +0 -0
  11. package/public/assets/abduction-cat.png +0 -0
  12. package/public/es.js +354 -54
  13. package/public/index.html +65 -19
  14. package/public/routing.js +74 -0
  15. package/public/styles.css +788 -77
  16. package/src/adapters/claude.mjs +6 -2
  17. package/src/adapters/gemini.mjs +26 -5
  18. package/src/adapters/madre.mjs +5 -0
  19. package/src/choices.mjs +64 -0
  20. package/src/dataset.mjs +11 -6
  21. package/src/distiller.mjs +12 -1
  22. package/src/file-ops.mjs +45 -0
  23. package/src/lease.mjs +37 -2
  24. package/src/mcp/smtp-server.mjs +189 -0
  25. package/src/memory-dashboard.mjs +69 -0
  26. package/src/memory.mjs +222 -18
  27. package/src/modules/ahp.mjs +1 -1
  28. package/src/modules/ash-policy.mjs +67 -0
  29. package/src/modules/ash.mjs +1 -1
  30. package/src/modules/choices.mjs +34 -0
  31. package/src/modules/correo.mjs +107 -0
  32. package/src/modules/index.mjs +48 -4
  33. package/src/modules/playwright.mjs +5 -1
  34. package/src/modules/ripley.mjs +13 -1
  35. package/src/modules/sdk.mjs +44 -9
  36. package/src/outbound.mjs +37 -3
  37. package/src/ports.mjs +91 -0
  38. package/src/request-guard.mjs +26 -0
  39. package/src/room/archivist.mjs +2 -1
  40. package/src/room/economy.mjs +83 -1
  41. package/src/room/prompt.mjs +14 -2
  42. package/src/room.mjs +150 -18
  43. package/src/sentinel-errors.mjs +5 -1
  44. package/src/server.mjs +188 -8
  45. package/src/smtp.mjs +122 -0
  46. package/src/vault.mjs +100 -0
  47. package/src/whats-new.mjs +140 -0
package/src/server.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  import http from 'node:http';
2
- import { readFile, realpath, writeFile, mkdir, rm } from 'node:fs/promises';
2
+ import { readFile, realpath, writeFile, mkdir, rm, cp, rename, access } from 'node:fs/promises';
3
3
  import { rmSync } from 'node:fs';
4
4
  import { createHash } from 'node:crypto';
5
+ import { classifyListening, probePort, framable } from './ports.mjs';
5
6
  import { basename, dirname, join, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { homedir } from 'node:os';
8
9
  import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+
12
+ const execFileAsync = promisify(execFile);
9
13
  import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
10
14
  import { EventStore } from './event-store.mjs';
11
15
  import { RoomMemory } from './memory.mjs';
@@ -44,9 +48,11 @@ import { applyConfigToEnv, loadConfig } from './config.mjs';
44
48
  import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
45
49
  import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
46
50
  import { applyKey, keyPlanFor } from './credentials.mjs';
51
+ import { forgetSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
47
52
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
48
53
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
49
54
  import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
55
+ import { bootWhatsNew, markSeen, notesFor, whatsNew } from './whats-new.mjs';
50
56
 
51
57
  // Where a person reads the module contract, in the language the room is speaking.
52
58
  const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
@@ -58,11 +64,14 @@ import { discoverModels } from './models.mjs';
58
64
  import { setImageModule } from './capabilities.mjs';
59
65
  import { resolveGeminiKey } from './image-studio.mjs';
60
66
  import { commandByName, listCommands, parseCommand } from './commands.mjs';
61
- import { listDirectory, searchFiles, readServable, storeAttachment, MAX_ATTACHMENT_BYTES } from './files.mjs';
67
+ import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, MAX_ATTACHMENT_BYTES } from './files.mjs';
68
+ import { planFileOp, CREATIONS } from './file-ops.mjs';
62
69
  import { Eyecat } from './eyecat-watch.mjs';
63
70
  import { economy } from './room/economy.mjs';
64
71
  import { maturity } from './maturity.mjs';
72
+ import { memoryDashboard } from './memory-dashboard.mjs';
65
73
  import { verdictFor } from './verdict.mjs';
74
+ import { refuseForeign } from './request-guard.mjs';
66
75
 
67
76
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
68
77
  const publicDirectory = join(sourceDirectory, '..', 'public');
@@ -140,6 +149,9 @@ export async function createPulseServer({
140
149
  ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
141
150
  }) {
142
151
  const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
152
+ // Read before this run creates anything: whether this person used MADRE before decides whether
153
+ // an update has news to show them, or this is a first install with nothing to catch up on.
154
+ const novelty = await bootWhatsNew({ root, version: PACKAGE.version }).catch(() => ({ returning: false }));
143
155
  // ~/.pulse/config.json fills in whatever the environment did not set.
144
156
  applyConfigToEnv(await loadConfig(root));
145
157
  // What language the screens speak. The prompt the agents read is not one of them, and the
@@ -196,7 +208,7 @@ export async function createPulseServer({
196
208
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
197
209
  async function versionView({ force = false } = {}) {
198
210
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
199
- return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest, roomLanguage()) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
211
+ return { project: canonicalProjectRoot, ...check, notes: Boolean(notesFor(PACKAGE.version)), name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest, roomLanguage()) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
200
212
  }
201
213
  // Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
202
214
  // happens after the screen is answered, never in front of it: at most once an hour per room,
@@ -546,8 +558,8 @@ export async function createPulseServer({
546
558
  const startupConfig = await readConfig(root);
547
559
  room.setScopes(startupConfig.scopes ?? {});
548
560
  setImageModule(startupConfig.modules?.imageStudio ?? {});
549
- // Rooms opened before Ash was renamed carry the old key; the human's switch is not lost.
550
- room.setAsh(Boolean(startupConfig.modules?.ash?.enabled ?? startupConfig.modules?.ash?.enabled));
561
+ room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
562
+ room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
551
563
  const recoveredTurns = await room.reconcile();
552
564
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
553
565
  const quotaMonitor = new QuotaMonitor({
@@ -842,14 +854,20 @@ export async function createPulseServer({
842
854
  }
843
855
 
844
856
  const server = http.createServer(async (request, response) => {
857
+ const foreign = refuseForeign(request);
858
+ if (foreign) return sendJson(response, 403, { error: t(foreign), guarded: true });
845
859
  const url = new URL(request.url, 'http://127.0.0.1');
860
+ // The project's own folder name, typed by the human. Declared at the top of the handler and
861
+ // not beside the first route that happened to need it: a const sits in its temporal dead zone
862
+ // until its own line runs, so any route above it threw instead of refusing.
863
+ const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
846
864
  try {
847
865
  if (request.method === 'GET' && url.pathname === '/') {
848
866
  const html = await readFile(join(publicDirectory, 'index.html'));
849
867
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
850
868
  return response.end(html);
851
869
  }
852
- if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js'].includes(url.pathname)) {
870
+ if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/routing.js', '/abduction.js'].includes(url.pathname)) {
853
871
  const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
854
872
  response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
855
873
  return response.end(js);
@@ -859,10 +877,28 @@ export async function createPulseServer({
859
877
  response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
860
878
  return response.end(css);
861
879
  }
880
+ const abductionAssets = new Map([
881
+ ['/assets/abduction-cat.png', 'abduction-cat.png'],
882
+ ['/assets/abduction-cat-blink.png', 'abduction-cat-blink.png'],
883
+ ['/assets/abduction-cat-control-left.png', 'abduction-cat-control-left.png'],
884
+ ['/assets/abduction-cat-control-right.png', 'abduction-cat-control-right.png'],
885
+ ]);
886
+ if (request.method === 'GET' && abductionAssets.has(url.pathname)) {
887
+ const image = await readFile(join(publicDirectory, 'assets', abductionAssets.get(url.pathname)));
888
+ response.writeHead(200, {
889
+ 'content-type': 'image/png',
890
+ 'content-length': image.length,
891
+ 'cache-control': 'public, max-age=86400',
892
+ });
893
+ return response.end(image);
894
+ }
862
895
  if (request.method === 'GET' && url.pathname === '/api/state') {
863
896
  return sendJson(response, 200, {
864
897
  projectRoot,
865
898
  platform: process.platform,
899
+ // Which MADRE this is. The page prints it in the corner: the one fact a bug report
900
+ // always needs and nobody ever remembers to ask for.
901
+ version: PACKAGE.version,
866
902
  // What language this machine set MADRE to, for a browser that has never chosen. It is
867
903
  // the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
868
904
  // wins over the config here as it does everywhere else, and reading the file again
@@ -872,6 +908,12 @@ export async function createPulseServer({
872
908
  chats: await listChats(roomDir),
873
909
  agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
874
910
  ash: { enabled: room.ashEnabled() },
911
+ choices: { enabled: room.choicesEnabled() },
912
+ // Read live, not from the config this process loaded at boot. A switch the human moves
913
+ // in MODULES while the room is open has to be the answer the room gives afterwards —
914
+ // otherwise the switch says on and the browser keeps refusing, which is how a setting
915
+ // stops being believed.
916
+ ripleyWeb: Boolean((await readConfig(root)).modules?.ripley?.web),
875
917
  ripley: { enabled: await ripleyOn() },
876
918
  softTokenBudget,
877
919
  timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
@@ -912,6 +954,37 @@ export async function createPulseServer({
912
954
  void broadcastPending();
913
955
  return;
914
956
  }
957
+ // Copying and moving, by the human's own hand. Both ends are resolved inside the project
958
+ // after following symlinks, so no path typed or pasted can reach outside it; the zones the
959
+ // room guards from agents ask for the project's designation first.
960
+ if (request.method === 'POST' && url.pathname === '/api/tree/op') {
961
+ const payload = await body(request).catch(() => ({}));
962
+ const plan = planFileOp(payload);
963
+ if (!plan.ok) return sendJson(response, 400, { error: plan.error });
964
+ if (plan.guarded && !designationOk(payload.designation)) {
965
+ return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.'), guarded: true });
966
+ }
967
+ const source = plan.from ? await resolveInside(canonicalProjectRoot, plan.from) : null;
968
+ if (plan.from && !source) return sendJson(response, 404, { error: t('Not found.') });
969
+ // The destination does not exist yet, so its PARENT is what has to be inside the project.
970
+ const targetDir = plan.to.includes('/') ? plan.to.slice(0, plan.to.lastIndexOf('/')) : '.';
971
+ const parent = await resolveInside(canonicalProjectRoot, targetDir);
972
+ if (!parent) return sendJson(response, 400, { error: t('The destination is outside this project.') });
973
+ const destination = join(parent, plan.to.slice(plan.to.lastIndexOf('/') + 1));
974
+ if (await access(destination).then(() => true, () => false)) {
975
+ return sendJson(response, 409, { error: t('There is already something at {path}.', { path: plan.to }) });
976
+ }
977
+ try {
978
+ if (plan.operation === 'new-folder') await mkdir(destination);
979
+ else if (plan.operation === 'new-file') await writeFile(destination, '', { flag: 'wx' });
980
+ else if (plan.operation === 'copy') await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
981
+ else await rename(source, destination);
982
+ } catch (error) {
983
+ return sendJson(response, 500, { error: error.message });
984
+ }
985
+ await room.record(CREATIONS.includes(plan.operation) ? 'project.file.created' : 'project.file.moved', { operation: plan.operation, from: plan.from, to: plan.to, guarded: plan.guarded });
986
+ return sendJson(response, 200, { ok: true, operation: plan.operation, from: plan.from, to: plan.to });
987
+ }
915
988
  if (request.method === 'GET' && url.pathname === '/api/tree/search') {
916
989
  const matches = await searchFiles(canonicalProjectRoot, url.searchParams.get('q') ?? '', { limit: Math.min(Number(url.searchParams.get('limit')) || 20, 50) });
917
990
  return sendJson(response, 200, { matches });
@@ -983,7 +1056,6 @@ export async function createPulseServer({
983
1056
  return sendJson(response, 200, { settings: await applySettings(await body(request)) });
984
1057
  }
985
1058
  // NOSTROMO: the archive is behind the project designation, like CONTROL.
986
- const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
987
1059
  // Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
988
1060
  if (request.method === 'GET' && url.pathname === '/api/sentinel') {
989
1061
  return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl(), formUrl: sentinel.formUrl() });
@@ -1017,6 +1089,65 @@ export async function createPulseServer({
1017
1089
  }
1018
1090
  // Where this room's tokens go: what each block of the briefing has cost, how much came back
1019
1091
  // from a CLI's own cache, and how many characters the room spends per token it is charged.
1092
+ // What this project has settled, for the strip at the top of a conversation. Not gated
1093
+ // behind the designation the archive is: these same sentences already appear in the room
1094
+ // under any answer that recalled one.
1095
+ // Open questions, for the strip under the composer. Read-only and ungated for the same
1096
+ // reason the decisions are: these sentences already appear in the room whenever one is
1097
+ // recalled. Dismissing one still happens in NOSTROMO, behind the designation.
1098
+ // What is listening on loopback that answers HTTP, for RIPLEY's address bar. Reading only:
1099
+ // lsof reports and changes nothing, nothing in the project is run, and a port that does not
1100
+ // speak HTTP never reaches the list. Starting a server is not here — that runs a command.
1101
+ // Whether an address agrees to be put behind glass. A page is entitled to refuse, and most
1102
+ // serious ones do; what RIPLEY can do is ask before framing it, so the human gets MADRE's
1103
+ // sentence and a real tab instead of the browser's bare "refused to connect".
1104
+ //
1105
+ // This is the one place the room itself reaches the web, and only after the human turned
1106
+ // the web on and typed the address. The request goes through the same watched fetch as
1107
+ // everything else, so it lands in the outbound log like any other departure.
1108
+ if (request.method === 'GET' && url.pathname === '/api/can-frame') {
1109
+ let target = null;
1110
+ try { target = new URL(url.searchParams.get('url') ?? ''); } catch { return sendJson(response, 400, { error: t('That is not an address.') }); }
1111
+ if (!/^https?:$/.test(target.protocol)) return sendJson(response, 400, { error: t('Only http and https.') });
1112
+ const local = ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname);
1113
+ if (!local && !(await readConfig(root)).modules?.ripley?.web) {
1114
+ return sendJson(response, 403, { error: t('RIPLEY reaches this computer only · turn the web on in MODULES to open an address outside it') });
1115
+ }
1116
+ try {
1117
+ const controller = new AbortController();
1118
+ const timer = setTimeout(() => controller.abort(), 6000);
1119
+ const probe = await reportFetch(target.href, { signal: controller.signal, redirect: 'follow' });
1120
+ clearTimeout(timer);
1121
+ return sendJson(response, 200, { framable: framable(probe.headers), status: probe.status });
1122
+ } catch (error) {
1123
+ return sendJson(response, 200, { framable: true, status: null, unreachable: true });
1124
+ }
1125
+ }
1126
+ if (request.method === 'GET' && url.pathname === '/api/ports') {
1127
+ if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
1128
+ let listening = { ports: [], wider: [] };
1129
+ try {
1130
+ const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 });
1131
+ listening = classifyListening(stdout, { self: request.socket.localPort });
1132
+ } catch { return sendJson(response, 200, { ports: [], wider: [], probed: false }); }
1133
+ const ports = [];
1134
+ for (const found of listening.ports.slice(0, 24)) {
1135
+ const probe = await probePort(found.port, { fetchImpl: reportFetch });
1136
+ if (probe.ok) ports.push({ ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable });
1137
+ }
1138
+ // What is listening on every interface travels too. It is not offered — this room does not
1139
+ // help anyone frame something reachable from the network — but saying nothing about it is
1140
+ // what made an empty list read as «there is nothing here» when there plainly was.
1141
+ return sendJson(response, 200, { ports, wider: listening.wider.slice(0, 8).map(({ port, command }) => ({ port, command })), probed: true });
1142
+ }
1143
+ if (request.method === 'GET' && url.pathname === '/api/questions') {
1144
+ const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
1145
+ return sendJson(response, 200, { questions: room.questions({ limit }) });
1146
+ }
1147
+ if (request.method === 'GET' && url.pathname === '/api/decisions') {
1148
+ const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
1149
+ return sendJson(response, 200, { decisions: room.decisions({ limit }) });
1150
+ }
1020
1151
  if (url.pathname === '/api/economy' && request.method === 'GET') {
1021
1152
  return sendJson(response, 200, economy(await store.readAll()));
1022
1153
  }
@@ -1142,6 +1273,15 @@ export async function createPulseServer({
1142
1273
  } catch (error) { return sendJson(response, 400, { error: error.message }); }
1143
1274
  }
1144
1275
  // Release channel: current, latest on npm, and the command for how this copy runs.
1276
+ // What this version brings, once, to someone who came from an older one.
1277
+ if (request.method === 'GET' && url.pathname === '/api/whats-new') {
1278
+ const news = await whatsNew({ root, version: PACKAGE.version, returning: novelty.returning, any: url.searchParams.get('any') === '1' });
1279
+ return sendJson(response, 200, { ...news, changes: { es: releaseUrl(PACKAGE.repository, PACKAGE.version, 'es'), en: releaseUrl(PACKAGE.repository, PACKAGE.version, 'en') } });
1280
+ }
1281
+ if (request.method === 'POST' && url.pathname === '/api/whats-new/seen') {
1282
+ await markSeen(root, PACKAGE.version);
1283
+ return sendJson(response, 200, { seen: PACKAGE.version });
1284
+ }
1145
1285
  if (request.method === 'GET' && url.pathname === '/api/version') {
1146
1286
  return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
1147
1287
  }
@@ -1206,13 +1346,19 @@ export async function createPulseServer({
1206
1346
  verdict: verdictFor({ maturity: grown, exams: exams.last, cold: research?.cold, asks: research?.ask?.length ?? 0 }),
1207
1347
  });
1208
1348
  }
1349
+ // The MEMORY dashboard: what the archive did day by day and what it is made of. Counts
1350
+ // only, no text of any memory, so it is read like /api/maturity, without the designation.
1351
+ if (request.method === 'GET' && url.pathname === '/api/memory/dashboard') {
1352
+ return sendJson(response, 200, { dashboard: memoryDashboard({ activity: room.memoryActivity(), stats: room.memoryStats() }) });
1353
+ }
1209
1354
  // Inside the core: the document MADRE writes in the human's name for the next turn, block
1210
1355
  // by block. Built on demand and stored nowhere; asking costs nothing and sends nothing.
1211
1356
  if (request.method === 'GET' && url.pathname === '/api/briefing') {
1212
1357
  const asked = url.searchParams.get('agent') ?? agents.find((agent) => agent.ready && !agent.local)?.id ?? agents[0]?.id;
1213
1358
  const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
1214
1359
  const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
1215
- const briefing = await room.briefing({ agent: asked, mode, text });
1360
+ const ash = url.searchParams.get('ash') === 'true';
1361
+ const briefing = await room.briefing({ agent: asked, mode, text, ash });
1216
1362
  if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
1217
1363
  return sendJson(response, 200, {
1218
1364
  ...briefing,
@@ -1377,6 +1523,34 @@ export async function createPulseServer({
1377
1523
  }
1378
1524
  // A provider key for a CLI that signs in from its own prompt. Local connections only: a key
1379
1525
  // pasted from another machine would travel the network. It is written where that CLI looks
1526
+ // A connector's secret. Same rule as an agent's key and for the same reason: it is accepted
1527
+ // only from this computer, never over the network. What differs is where it lands — a
1528
+ // connector has no CLI to hand it to, so MADRE holds it, in its own vault, `0600`, and never
1529
+ // in the config, the ledger or a log. The ledger records that one was set and under which
1530
+ // name; the value never leaves src/vault.mjs except to the module that owns it.
1531
+ const vaultMatch = url.pathname.match(/^\/api\/x\/([a-z0-9-]+)\/secret$/);
1532
+ if (vaultMatch && (request.method === 'POST' || request.method === 'DELETE')) {
1533
+ const id = vaultMatch[1];
1534
+ const address = request.socket.remoteAddress ?? '';
1535
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
1536
+ return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
1537
+ }
1538
+ const module = moduleById(id);
1539
+ if (!module) return sendJson(response, 404, { error: t('No module by that name is loaded.') });
1540
+ const payload = await body(request).catch(() => ({}));
1541
+ const name = String(payload.name ?? '');
1542
+ if (request.method === 'DELETE') {
1543
+ const gone = await forgetSecret(root, id, payload.name === undefined ? null : name);
1544
+ if (!gone.ok) return sendJson(response, 400, { error: gone.error });
1545
+ await room.record('module.secret.forgotten', { module: id, name: gone.forgot });
1546
+ return sendJson(response, 200, { ok: true, held: await vaultSummary(root, id) });
1547
+ }
1548
+ const kept = await writeSecret(root, id, name, payload.secret);
1549
+ if (!kept.ok) return sendJson(response, 400, { error: kept.error });
1550
+ // The name and the length, never the secret — the same way privacy counts its terms.
1551
+ await room.record('module.secret.kept', { module: id, name: kept.name, bytes: kept.bytes });
1552
+ return sendJson(response, 200, { ok: true, held: await vaultSummary(root, id) });
1553
+ }
1380
1554
  // for it and never reaches MADRE's config, its ledger or its logs.
1381
1555
  const keyMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/key$/);
1382
1556
  if (keyMatch) {
@@ -1428,6 +1602,12 @@ export async function createPulseServer({
1428
1602
  await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
1429
1603
  return sendJson(response, 200, { models });
1430
1604
  }
1605
+ // One answer, not the room. The master brake lives next door and means something else.
1606
+ if (request.method === 'POST' && url.pathname === '/api/stop-turn') {
1607
+ const payload = await body(request).catch(() => ({}));
1608
+ const stopped = await room.stopTurn(String(payload.messageId ?? ''));
1609
+ return sendJson(response, stopped ? 200 : 404, stopped ? { stopped: true } : { error: t('That turn is not running any more.') });
1610
+ }
1431
1611
  if (request.method === 'POST' && url.pathname === '/api/stop-all') {
1432
1612
  const result = await room.stopAll();
1433
1613
  return sendJson(response, 202, { stopped: true, ...result });
package/src/smtp.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // SMTP, enough of it to send one message over TLS. No dependencies, by the same rule as the rest
2
+ // of MADRE: a library that handles the human's mail password is a library somebody has to read.
3
+ //
4
+ // What it does: open a TLS socket, greet, authenticate, hand over one message, quit. What it does
5
+ // NOT do: STARTTLS on a plaintext port (the session would begin in the clear), attachments,
6
+ // multipart, or any form of queuing. One message, encrypted from the first byte, or nothing.
7
+ //
8
+ // The protocol is a dialogue of three-digit replies. A reply may span lines — `250-` continues,
9
+ // `250 ` ends — and that is the only parsing subtlety here.
10
+
11
+ import { connect } from 'node:tls';
12
+
13
+ export const SMTP_PORT = 465; // implicit TLS. Never 25, never 587 without TLS.
14
+ const CRLF = '\r\n';
15
+
16
+ // A reply ends at the first COMPLETE line whose code is followed by a space rather than a hyphen:
17
+ // `250-` continues, `250 ` ends. The line has to carry its own newline, or a chunk that split
18
+ // mid-line would be read as a finished answer.
19
+ const ENDS = /^(\d{3})(?: [^\r\n]*)?\r\n/m;
20
+
21
+ // An address, strictly enough that it cannot carry a second one. A newline in a header is how a
22
+ // message gets extra recipients nobody typed, so anything with control characters is refused
23
+ // rather than escaped.
24
+ const ADDRESS = /^[^\s<>@,;:"\\\u0000-\u001f]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
25
+ export const validAddress = (value) => ADDRESS.test(String(value ?? '').trim());
26
+
27
+ // A header value, with the one thing that must never survive: a line break. Folding is not
28
+ // attempted — a long subject travels long — because a header that folds wrongly is a header that
29
+ // injects.
30
+ export const headerSafe = (value) => String(value ?? '').replace(/[\r\n\u0000]+/g, ' ').trim();
31
+
32
+ // Anything non-ASCII in a header has to be encoded or it arrives as mojibake. RFC 2047, base64,
33
+ // whole-value: simpler than word-splitting and correct for the subjects people actually write.
34
+ export function encodeHeader(value) {
35
+ const clean = headerSafe(value);
36
+ if (!clean || /^[\x20-\x7e]*$/.test(clean)) return clean;
37
+ return `=?UTF-8?B?${Buffer.from(clean, 'utf8').toString('base64')}?=`;
38
+ }
39
+
40
+ // A line beginning with a dot ends the DATA stage. Doubling it is the protocol's own escape, and
41
+ // forgetting it is how a message gets truncated by its own body.
42
+ export const dotStuff = (body) => String(body ?? '').replace(/\r?\n/g, CRLF).replace(/^\./gm, '..');
43
+
44
+ export function buildMessage({ from, fromName = '', to, subject, body, date = new Date() }) {
45
+ const sender = fromName ? `${encodeHeader(fromName)} <${from}>` : from;
46
+ const headers = [
47
+ `From: ${sender}`,
48
+ `To: ${to.join(', ')}`,
49
+ `Subject: ${encodeHeader(subject)}`,
50
+ `Date: ${date.toUTCString()}`,
51
+ `Message-ID: <${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@${from.split('@')[1]}>`,
52
+ 'MIME-Version: 1.0',
53
+ 'Content-Type: text/plain; charset=utf-8',
54
+ 'Content-Transfer-Encoding: 8bit',
55
+ ];
56
+ return `${headers.join(CRLF)}${CRLF}${CRLF}${dotStuff(body)}`;
57
+ }
58
+
59
+ // One conversation. Every step says what it expects, so a refusal names the step it failed at
60
+ // instead of surfacing as a bare three-digit code.
61
+ export async function sendMail({ host = 'smtp.gmail.com', port = SMTP_PORT, user, password, from, fromName = '', to, subject, body, timeoutMs = 20000, connectImpl = connect, date = undefined }) {
62
+ const recipients = (Array.isArray(to) ? to : [to]).map((one) => String(one ?? '').trim()).filter(Boolean);
63
+ if (!validAddress(from)) throw Object.assign(new Error('The sending address is not an address.'), { code: 'BAD_FROM' });
64
+ if (!recipients.length) throw Object.assign(new Error('Say who it goes to.'), { code: 'NO_RECIPIENT' });
65
+ for (const one of recipients) {
66
+ if (!validAddress(one)) throw Object.assign(new Error(`That is not an address: ${one}`), { code: 'BAD_RECIPIENT' });
67
+ }
68
+ if (!password) throw Object.assign(new Error('There is no app password in the vault for this connector.'), { code: 'NO_SECRET' });
69
+
70
+ const socket = connectImpl({ host, port, servername: host });
71
+ socket.setEncoding('utf8');
72
+ socket.setTimeout(timeoutMs);
73
+
74
+ let buffer = '';
75
+ let waiting = null;
76
+ const fail = (error) => { if (waiting) { const { reject } = waiting; waiting = null; reject(error); } };
77
+ socket.on('error', (error) => fail(Object.assign(error, { code: error.code ?? 'SMTP_SOCKET' })));
78
+ socket.on('timeout', () => { socket.destroy(); fail(Object.assign(new Error(`${host} stopped answering.`), { code: 'SMTP_TIMEOUT' })); });
79
+ socket.on('close', () => fail(Object.assign(new Error(`${host} closed the connection.`), { code: 'SMTP_CLOSED' })));
80
+ socket.on('data', (chunk) => {
81
+ buffer += chunk;
82
+ const done = buffer.match(ENDS);
83
+ if (!done || !waiting) return;
84
+ const text = buffer.trim();
85
+ buffer = '';
86
+ const { resolve } = waiting;
87
+ waiting = null;
88
+ resolve({ code: Number(done[1]), text });
89
+ });
90
+
91
+ const read = () => new Promise((resolve, reject) => { waiting = { resolve, reject }; });
92
+ const say = async (line, expect, step) => {
93
+ if (line !== null) socket.write(`${line}${CRLF}`);
94
+ const reply = await read();
95
+ if (!expect.includes(reply.code)) {
96
+ socket.destroy();
97
+ throw Object.assign(new Error(`${host} refused at ${step}: ${reply.text.split(/\r?\n/)[0]}`), { code: `SMTP_${reply.code}`, step, reply: reply.text });
98
+ }
99
+ return reply;
100
+ };
101
+
102
+ try {
103
+ await say(null, [220], 'greeting');
104
+ await say(`EHLO ${from.split('@')[1]}`, [250], 'EHLO');
105
+ // AUTH LOGIN: the user and the password, each base64, each on its own line. Chosen over
106
+ // PLAIN because it is what Gmail documents for app passwords.
107
+ await say('AUTH LOGIN', [334], 'AUTH');
108
+ await say(Buffer.from(user ?? from, 'utf8').toString('base64'), [334], 'user');
109
+ await say(Buffer.from(password, 'utf8').toString('base64'), [235], 'password');
110
+ await say(`MAIL FROM:<${from}>`, [250], 'MAIL FROM');
111
+ for (const one of recipients) await say(`RCPT TO:<${one}>`, [250, 251], 'RCPT TO');
112
+ await say('DATA', [354], 'DATA');
113
+ const message = buildMessage({ from, fromName, to: recipients, subject, body, ...(date ? { date } : {}) });
114
+ const accepted = await say(`${message}${CRLF}.`, [250], 'message');
115
+ socket.write(`QUIT${CRLF}`);
116
+ socket.end();
117
+ return { ok: true, to: recipients, bytes: Buffer.byteLength(message, 'utf8'), reply: accepted.text.split(/\r?\n/)[0] };
118
+ } catch (error) {
119
+ socket.destroy();
120
+ throw error;
121
+ }
122
+ }
package/src/vault.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // The vault: where a connector's secret lives.
2
+ //
3
+ // Until now MADRE held none. A key for Gemini or OpenCode is written where THAT CLI looks for it
4
+ // and MADRE keeps no copy — which is a promise worth having, and it only works because there is a
5
+ // CLI to hand it to. A connector has none: the module itself is what talks to the service, so if
6
+ // MADRE does not hold the secret, nobody does.
7
+ //
8
+ // So it holds them, and the terms are these:
9
+ //
10
+ // · One file per module, under `<stateRoot>/credentials/`, `0600` inside a `0700` directory.
11
+ // · Never in the config, never in the ledger, never in a log, never in a prompt. The ledger
12
+ // records that a secret was SET and under which name — never the value, the same way privacy
13
+ // counts how many terms it guards and never which.
14
+ // · Written only from this computer. The server refuses a secret arriving over the network.
15
+ // · `summary()` is what every other part of MADRE is allowed to see: names and sizes, no values.
16
+ //
17
+ // Nothing here reads a secret into a turn. A module asks for its own and hands it to the server it
18
+ // spawns, through that process's environment — which is how a CLI gets one too.
19
+
20
+ import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+
23
+ const OWNER_ONLY = 0o600;
24
+ const OWNER_DIR = 0o700;
25
+ const MAX_SECRET = 8000;
26
+
27
+ export const vaultDir = (stateRoot) => join(stateRoot, 'credentials');
28
+ const fileFor = (stateRoot, moduleId) => join(vaultDir(stateRoot), `${moduleId}.json`);
29
+
30
+ // A module id and a secret name are both file-ish and both come from outside: anything that is
31
+ // not plainly a name is refused rather than sanitised, because a sanitised path is a path that
32
+ // looked wrong and was used anyway.
33
+ const NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
34
+ export const validName = (value) => NAME.test(String(value ?? ''));
35
+
36
+ async function readAll(stateRoot, moduleId) {
37
+ try { return JSON.parse(await readFile(fileFor(stateRoot, moduleId), 'utf8')); }
38
+ catch { return {}; }
39
+ }
40
+
41
+ // What a module put away, for the module itself. The only function in MADRE that returns a value.
42
+ export async function readSecret(stateRoot, moduleId, name) {
43
+ if (!validName(moduleId) || !validName(name)) return null;
44
+ const secret = (await readAll(stateRoot, moduleId))[name];
45
+ return typeof secret === 'string' && secret ? secret : null;
46
+ }
47
+
48
+ export async function writeSecret(stateRoot, moduleId, name, value) {
49
+ if (!validName(moduleId)) return { ok: false, error: 'That is not a module id.' };
50
+ if (!validName(name)) return { ok: false, error: 'That is not a secret name.' };
51
+ const secret = String(value ?? '').trim();
52
+ if (!secret) return { ok: false, error: 'Paste the secret first.' };
53
+ if (/\s/.test(secret)) return { ok: false, error: 'That does not look like a secret: it has spaces or line breaks.' };
54
+ if (secret.length > MAX_SECRET) return { ok: false, error: 'That is too long to be a secret.' };
55
+ const dir = vaultDir(stateRoot);
56
+ await mkdir(dir, { recursive: true, mode: OWNER_DIR });
57
+ // An older vault directory may predate this mode; set it either way rather than trust the
58
+ // creation flags of a directory that was already there.
59
+ await chmod(dir, OWNER_DIR).catch(() => {});
60
+ const all = await readAll(stateRoot, moduleId);
61
+ all[name] = secret;
62
+ await writeFile(fileFor(stateRoot, moduleId), JSON.stringify(all), { mode: OWNER_ONLY });
63
+ await chmod(fileFor(stateRoot, moduleId), OWNER_ONLY).catch(() => {});
64
+ return { ok: true, name, bytes: secret.length };
65
+ }
66
+
67
+ export async function forgetSecret(stateRoot, moduleId, name = null) {
68
+ if (!validName(moduleId)) return { ok: false, error: 'That is not a module id.' };
69
+ if (name === null) {
70
+ await rm(fileFor(stateRoot, moduleId), { force: true });
71
+ return { ok: true, forgot: 'all' };
72
+ }
73
+ if (!validName(name)) return { ok: false, error: 'That is not a secret name.' };
74
+ const all = await readAll(stateRoot, moduleId);
75
+ if (!(name in all)) return { ok: true, forgot: null };
76
+ delete all[name];
77
+ if (Object.keys(all).length) await writeFile(fileFor(stateRoot, moduleId), JSON.stringify(all), { mode: OWNER_ONLY });
78
+ else await rm(fileFor(stateRoot, moduleId), { force: true });
79
+ return { ok: true, forgot: name };
80
+ }
81
+
82
+ // What the room, the cards and the ledger may know: that a secret is there, what it is called and
83
+ // how long it is. Never the secret. This is the only shape that leaves this file for anyone but
84
+ // the module that owns it.
85
+ export async function summary(stateRoot, moduleId = null) {
86
+ let files = [];
87
+ try { files = (await readdir(vaultDir(stateRoot))).filter((name) => name.endsWith('.json')); }
88
+ catch { return []; }
89
+ const held = [];
90
+ for (const file of files) {
91
+ const id = file.replace(/\.json$/, '');
92
+ if (moduleId && id !== moduleId) continue;
93
+ const all = await readAll(stateRoot, id);
94
+ for (const [name, secret] of Object.entries(all)) {
95
+ if (typeof secret !== 'string') continue;
96
+ held.push({ module: id, name, bytes: secret.length });
97
+ }
98
+ }
99
+ return held.sort((a, b) => `${a.module}${a.name}`.localeCompare(`${b.module}${b.name}`));
100
+ }