@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.
- package/CHANGELOG.md +265 -1
- package/README.md +33 -1
- package/docs/SDK.md +173 -6
- package/docs/sdk/connector-module.mjs +186 -0
- package/package.json +6 -2
- package/public/abduction.js +384 -0
- package/public/app.js +3450 -540
- package/public/assets/abduction-cat-blink.png +0 -0
- package/public/assets/abduction-cat-control-left.png +0 -0
- package/public/assets/abduction-cat-control-right.png +0 -0
- package/public/assets/abduction-cat.png +0 -0
- package/public/es.js +354 -54
- package/public/index.html +65 -19
- package/public/routing.js +74 -0
- package/public/styles.css +788 -77
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/choices.mjs +64 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- package/src/file-ops.mjs +45 -0
- package/src/lease.mjs +37 -2
- package/src/mcp/smtp-server.mjs +189 -0
- package/src/memory-dashboard.mjs +69 -0
- package/src/memory.mjs +222 -18
- package/src/modules/ahp.mjs +1 -1
- package/src/modules/ash-policy.mjs +67 -0
- package/src/modules/ash.mjs +1 -1
- package/src/modules/choices.mjs +34 -0
- package/src/modules/correo.mjs +107 -0
- package/src/modules/index.mjs +48 -4
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +91 -0
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +14 -2
- package/src/room.mjs +150 -18
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +188 -8
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- 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
|
-
|
|
550
|
-
room.
|
|
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
|
|
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
|
+
}
|