@jossuealcala/madre 0.6.0 → 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 +186 -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/app.js +2342 -506
- package/public/es.js +184 -44
- package/public/index.html +17 -16
- package/public/styles.css +410 -67
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- 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/correo.mjs +107 -0
- package/src/modules/index.mjs +47 -4
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +23 -3
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/prompt.mjs +1 -1
- package/src/room.mjs +59 -9
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +67 -7
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
package/src/modules/sdk.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// context (ctx) per call and hands it in; the module never reaches for globals.
|
|
5
5
|
//
|
|
6
6
|
// ctx = { projectRoot, stateRoot, config, settings, env, agents, room, readConfig(), updateConfig(patch),
|
|
7
|
-
// record(type, payload), services: { ... what the server offers }
|
|
7
|
+
// record(type, payload), services: { ... what the server offers },
|
|
8
|
+
// vault: { get, held, keep, forget } — this module's own secrets, and only its own }
|
|
8
9
|
//
|
|
9
10
|
// A module may also hand tools to every turn: `toolsForTurn(ctx, turn)` returns MCP server
|
|
10
11
|
// specs `{ name, command, args, env, tools: [names], brief }` that MADRE attaches to the CLI for
|
|
@@ -15,9 +16,22 @@
|
|
|
15
16
|
// 'installer' writes into the project through a confirmed command.
|
|
16
17
|
|
|
17
18
|
import { readFile, stat } from 'node:fs/promises';
|
|
19
|
+
import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from '../vault.mjs';
|
|
18
20
|
|
|
19
21
|
const camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
20
22
|
|
|
23
|
+
// The vault, narrowed to one module. A connector has no CLI to hand its secret to, so MADRE
|
|
24
|
+
// holds it — and a module must not be able to read somebody else's. It never passes its own id:
|
|
25
|
+
// the id is closed over here, so asking for another module's secret is not a thing a module can
|
|
26
|
+
// express. `held()` is names and lengths; `get()` is the only door to a value, and what comes
|
|
27
|
+
// back belongs in the environment of a process the module spawns, never in a prompt or a reply.
|
|
28
|
+
const vaultFor = (id, stateRoot) => ({
|
|
29
|
+
get: (name) => readSecret(stateRoot, id, name),
|
|
30
|
+
held: () => vaultSummary(stateRoot, id),
|
|
31
|
+
keep: (name, value) => writeSecret(stateRoot, id, name, value),
|
|
32
|
+
forget: (name = null) => forgetSecret(stateRoot, id, name),
|
|
33
|
+
});
|
|
34
|
+
|
|
21
35
|
let release = null;
|
|
22
36
|
// MADRE's own release, read once from the package that is running.
|
|
23
37
|
export async function madreRelease() {
|
|
@@ -57,8 +71,8 @@ export function dependencies(runs) {
|
|
|
57
71
|
// may carry fields this one has not learned yet — so it is said out loud instead of refused.
|
|
58
72
|
export const MODULE_FIELDS = new Set([
|
|
59
73
|
'id', 'kind', 'name', 'vendor', 'package', 'version', 'tracks', 'updates', 'summary', 'creates',
|
|
60
|
-
'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes',
|
|
61
|
-
'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand',
|
|
74
|
+
'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes', 'reaches',
|
|
75
|
+
'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand', 'secrets',
|
|
62
76
|
'updatePlan', 'toolsForTurn', 'toggle', 'onToggle', 'onSettings', 'onEvent', 'toggledEvent',
|
|
63
77
|
'toggledBody', 'describe',
|
|
64
78
|
]);
|
|
@@ -92,8 +106,27 @@ export function defineModule(spec) {
|
|
|
92
106
|
if (type === 'select' && !control.options?.length) throw new Error(`Module ${spec.id}: control ${control.key} is a select with no options.`);
|
|
93
107
|
return { key: control.key, label: control.label ?? control.key.toUpperCase(), type, options: control.options ?? [], note: control.note ?? '', invert: Boolean(control.invert) };
|
|
94
108
|
}),
|
|
109
|
+
// Where this module's traffic goes, in the same four answers `src/outbound.mjs` demands of
|
|
110
|
+
// MADRE's own destinations. Without it a connector's traffic reads in the log as «an address
|
|
111
|
+
// nothing here declares», which is true and useless: the human cannot tell a connector they
|
|
112
|
+
// installed from something going wrong.
|
|
113
|
+
reaches: (spec.reaches ?? []).map((reach) => {
|
|
114
|
+
if (!reach?.host || typeof reach.host !== 'string') throw new Error(`Module ${spec.id}: a declared destination needs a host.`);
|
|
115
|
+
for (const answer of ['to', 'what', 'when']) {
|
|
116
|
+
if (!reach[answer]) throw new Error(`Module ${spec.id}: destination ${reach.host} does not say ${answer}.`);
|
|
117
|
+
}
|
|
118
|
+
return { id: reach.id ?? reach.host, host: reach.host, to: reach.to, what: reach.what, when: reach.when, where: reach.where ?? `MODULES → ${spec.name}`, ...(typeof reach.match === 'function' ? { match: reach.match } : {}) };
|
|
119
|
+
}),
|
|
120
|
+
// Secrets this module needs to do its job: declared, not drawn. MADRE renders a paste field
|
|
121
|
+
// per entry and keeps the value in its vault under this module's name. The card only ever
|
|
122
|
+
// shows that one is held and how long it is — the value goes nowhere but the module.
|
|
123
|
+
secrets: (spec.secrets ?? []).map((secret) => {
|
|
124
|
+
if (!secret?.name || !/^[a-z0-9][a-z0-9_-]*$/i.test(secret.name)) throw new Error(`Module ${spec.id}: a secret needs a plain name.`);
|
|
125
|
+
return { name: secret.name, label: secret.label ?? secret.name.toUpperCase(), note: secret.note ?? '', where: secret.where ?? '' };
|
|
126
|
+
}),
|
|
95
127
|
};
|
|
96
128
|
const settingsFrom = (config) => ({ ...defaults, ...(config?.modules?.[configKey] ?? {}) });
|
|
129
|
+
const mine = (ctx, settings) => ({ ...ctx, settings, vault: vaultFor(spec.id, ctx.stateRoot) });
|
|
97
130
|
const module = {
|
|
98
131
|
...base,
|
|
99
132
|
configKey,
|
|
@@ -113,12 +146,12 @@ export function defineModule(spec) {
|
|
|
113
146
|
toolsForTurn: spec.toolsForTurn ? async (ctx, turn) => {
|
|
114
147
|
const settings = settingsFrom(ctx.config);
|
|
115
148
|
if (kind === 'builtin' && !settings.enabled) return [];
|
|
116
|
-
try { return (await spec.toolsForTurn(
|
|
149
|
+
try { return (await spec.toolsForTurn(mine(ctx, settings), turn)) ?? []; } catch (error) { console.error(`MADRE module ${spec.id}: toolsForTurn failed: ${error.message}`); return []; }
|
|
117
150
|
} : null,
|
|
118
151
|
// How the outside thing this module drives gets a newer version onto this computer. MADRE
|
|
119
152
|
// shows the command before it runs and never runs one the human has not read. A module that
|
|
120
153
|
// cannot update what it drives returns the note that says where to get it instead.
|
|
121
|
-
updatePlan: spec.updatePlan ? async (ctx, what) => spec.updatePlan(
|
|
154
|
+
updatePlan: spec.updatePlan ? async (ctx, what) => spec.updatePlan(mine(ctx, settingsFrom(ctx.config)), what) : null,
|
|
122
155
|
// Legacy installer hooks, kept on the object so the confirm-and-run path can use them.
|
|
123
156
|
detect: spec.detect ?? null,
|
|
124
157
|
preflight: spec.preflight ?? null,
|
|
@@ -127,7 +160,7 @@ export function defineModule(spec) {
|
|
|
127
160
|
// What MODULES shows: base fields plus status, preflight, install line and whatever the module adds.
|
|
128
161
|
async describe(ctx) {
|
|
129
162
|
const settings = settingsFrom(ctx.config);
|
|
130
|
-
const own = spec.status ? await spec.status(
|
|
163
|
+
const own = spec.status ? await spec.status(mine(ctx, settings)) : {};
|
|
131
164
|
const installed = own.installed ?? (kind === 'builtin' ? Boolean(settings.enabled) : false);
|
|
132
165
|
const runs = dependencies(own.runs);
|
|
133
166
|
const tracked = base.tracks ? (runs.find((dep) => dep.name === base.tracks.name)?.version ?? null) : undefined;
|
|
@@ -140,6 +173,8 @@ export function defineModule(spec) {
|
|
|
140
173
|
versionSource: stamp.source,
|
|
141
174
|
canUpdate: Boolean(spec.updatePlan),
|
|
142
175
|
controls: base.controls.map((control) => ({ ...control, value: settings[control.key] ?? null })),
|
|
176
|
+
// What is in the vault for this module: names and lengths, never values.
|
|
177
|
+
...(base.secrets.length ? { held: await vaultSummary(ctx.stateRoot, spec.id).catch(() => []) } : {}),
|
|
143
178
|
ships: this.external ? null : await madreRelease(),
|
|
144
179
|
runs,
|
|
145
180
|
status: own.status ?? { installed, detail: own.detail ?? (installed ? 'on' : 'off') },
|
|
@@ -163,7 +198,7 @@ export function defineModule(spec) {
|
|
|
163
198
|
}
|
|
164
199
|
const settings = { ...settingsFrom(ctx.config), [key]: next };
|
|
165
200
|
await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), [key]: next } } });
|
|
166
|
-
if (spec.onSettings) await spec.onSettings(
|
|
201
|
+
if (spec.onSettings) await spec.onSettings(mine(ctx, settings), settings);
|
|
167
202
|
return { status: 200, body: { settings: Object.fromEntries(base.controls.map((known) => [known.key, settings[known.key] ?? null])) } };
|
|
168
203
|
} : null,
|
|
169
204
|
|
|
@@ -171,11 +206,11 @@ export function defineModule(spec) {
|
|
|
171
206
|
// A module may guard it (`confirm`) or replace it (`toggle`).
|
|
172
207
|
toggle: kind === 'builtin' || spec.toggle ? async (ctx, payload = {}) => {
|
|
173
208
|
const settings = settingsFrom(ctx.config);
|
|
174
|
-
if (spec.toggle) return spec.toggle(
|
|
209
|
+
if (spec.toggle) return spec.toggle(mine(ctx, settings), payload);
|
|
175
210
|
const enabled = !settings.enabled;
|
|
176
211
|
if (enabled && spec.confirm && payload.confirm !== true) return { status: 400, body: { error: spec.confirm } };
|
|
177
212
|
await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
|
|
178
|
-
const after = spec.onToggle ? await spec.onToggle(
|
|
213
|
+
const after = spec.onToggle ? await spec.onToggle(mine(ctx, { ...settings, enabled }), enabled) : null;
|
|
179
214
|
await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled, ...(spec.toggledEvent ?? {}) });
|
|
180
215
|
return { status: 200, body: { enabled, ...(after ?? {}), ...(spec.toggledBody?.(enabled) ?? {}) } };
|
|
181
216
|
} : null,
|
package/src/outbound.mjs
CHANGED
|
@@ -99,7 +99,39 @@ export const DESTINATIONS = [
|
|
|
99
99
|
];
|
|
100
100
|
|
|
101
101
|
const byId = new Map(DESTINATIONS.map((one) => [one.id, one]));
|
|
102
|
-
export const destination = (id) => byId.get(id) ?? null;
|
|
102
|
+
export const destination = (id) => byId.get(id) ?? declaredById.get(id) ?? null;
|
|
103
|
+
|
|
104
|
+
// What a module declares it reaches.
|
|
105
|
+
//
|
|
106
|
+
// The list above is the core's, written in code and not editable at runtime — that is the whole
|
|
107
|
+
// point of it. A connector, though, is a module, and a module that reaches a service nobody
|
|
108
|
+
// declared shows up in the log as «went to an address nothing here declares», which is true and
|
|
109
|
+
// useless: the human cannot tell a connector they installed from something going wrong.
|
|
110
|
+
//
|
|
111
|
+
// So a module may declare its own, in the same shape and answering the same four questions. It is
|
|
112
|
+
// held apart from the core's and MARKED, because who promised what is part of the promise: MADRE
|
|
113
|
+
// vouches for the list above and only reports the ones below. A declaration is rebuilt from the
|
|
114
|
+
// modules on every load, so uninstalling one takes its destination with it.
|
|
115
|
+
const declaredById = new Map();
|
|
116
|
+
export function declareDestinations(moduleId, reaches = []) {
|
|
117
|
+
for (const one of Array.isArray(reaches) ? reaches : []) {
|
|
118
|
+
if (!one?.host || typeof one.host !== 'string') continue;
|
|
119
|
+
const id = `${moduleId}:${one.id ?? one.host}`;
|
|
120
|
+
declaredById.set(id, {
|
|
121
|
+
id,
|
|
122
|
+
host: one.host.toLowerCase(),
|
|
123
|
+
match: typeof one.match === 'function' ? one.match : null,
|
|
124
|
+
to: String(one.to ?? one.host),
|
|
125
|
+
what: String(one.what ?? ''),
|
|
126
|
+
when: String(one.when ?? ''),
|
|
127
|
+
where: String(one.where ?? `MODULES → ${moduleId.toUpperCase()}`),
|
|
128
|
+
inside: true,
|
|
129
|
+
module: moduleId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function clearDeclaredDestinations() { declaredById.clear(); }
|
|
134
|
+
export const declaredDestinations = () => [...declaredById.values()];
|
|
103
135
|
|
|
104
136
|
// Which declaration a URL belongs to. An address nobody declared is said to be exactly that.
|
|
105
137
|
export function classify(rawUrl, { reportHost = null } = {}) {
|
|
@@ -108,10 +140,12 @@ export function classify(rawUrl, { reportHost = null } = {}) {
|
|
|
108
140
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
109
141
|
if (LOCAL.has(host)) return { id: url.port === '11434' ? 'ollama' : 'room', to: `${host}:${url.port || '80'}`, local: true };
|
|
110
142
|
if (reportHost && host === reportHost) return { id: 'reports', to: byId.get('reports').to, local: false };
|
|
111
|
-
for (const one of DESTINATIONS) {
|
|
143
|
+
for (const one of [...DESTINATIONS, ...declaredById.values()]) {
|
|
112
144
|
if (!one.host || one.host !== host) continue;
|
|
113
145
|
if (one.match && !one.match(url)) continue;
|
|
114
|
-
|
|
146
|
+
// `module` travels so the log can say who vouched for this address. MADRE's own declarations
|
|
147
|
+
// and a module's are both honest; they are not the same promise.
|
|
148
|
+
return { id: one.id, to: one.to, local: false, ...(one.module ? { module: one.module } : {}) };
|
|
115
149
|
}
|
|
116
150
|
return { id: 'unknown', to: host, local: false };
|
|
117
151
|
}
|
package/src/ports.mjs
CHANGED
|
@@ -18,8 +18,13 @@ const NOISE = /^(rapportd|sharingd|identityservicesd|controlce|remoted|launchd|m
|
|
|
18
18
|
// One `lsof -nP -iTCP -sTCP:LISTEN` table into the ports a human might want to look at.
|
|
19
19
|
// Loopback only: a line bound to * or 0.0.0.0 is reachable from the network, and this room does
|
|
20
20
|
// not help anyone frame that by accident.
|
|
21
|
-
|
|
21
|
+
// Everything listening, split by whether RIPLEY may open it. The wider half is NOT a list of
|
|
22
|
+
// things to show as openable — it is the answer to «there is nothing here», which was a lie:
|
|
23
|
+
// `node server.js` and `python -m http.server` bind every interface by default, so the most
|
|
24
|
+
// common case in the world was being discarded in silence and reported as an empty computer.
|
|
25
|
+
export function classifyListening(output = '', { self = null } = {}) {
|
|
22
26
|
const found = new Map();
|
|
27
|
+
const wider = new Map();
|
|
23
28
|
for (const line of String(output ?? '').split('\n').slice(1)) {
|
|
24
29
|
const match = line.match(/^(\S+)\s+(\d+)\s+\S+\s+\S+\s+(IPv4|IPv6)\s+\S+\s+\S+\s+\S+\s+(\S+)\s+\(LISTEN\)/);
|
|
25
30
|
if (!match) continue;
|
|
@@ -29,13 +34,28 @@ export function parseListening(output = '', { self = null } = {}) {
|
|
|
29
34
|
const host = address.slice(0, address.lastIndexOf(':'));
|
|
30
35
|
const port = Number(address.slice(address.lastIndexOf(':') + 1));
|
|
31
36
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
32
|
-
if (!['127.0.0.1', '[::1]', 'localhost'].includes(host)) continue;
|
|
33
37
|
if (SYSTEM_PORTS.has(port) || NOISE.test(command)) continue;
|
|
34
38
|
if (self && port === Number(self)) continue;
|
|
39
|
+
if (!['127.0.0.1', '[::1]', 'localhost'].includes(host)) {
|
|
40
|
+
// Bound to every interface, so reachable from the network. Not offered, but counted: the
|
|
41
|
+
// human deserves to know their server is there and why it is not on the list.
|
|
42
|
+
if (['*', '0.0.0.0', '[::]'].includes(host) && !wider.has(port)) wider.set(port, { port, pid: Number(pid), command, host });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
35
45
|
const seen = found.get(port);
|
|
36
46
|
if (!seen) found.set(port, { port, pid: Number(pid), command });
|
|
37
47
|
}
|
|
38
|
-
|
|
48
|
+
const byPort = (a, b) => a.port - b.port;
|
|
49
|
+
return {
|
|
50
|
+
ports: [...found.values()].sort(byPort),
|
|
51
|
+
// A port answering on both loopback and every interface is already openable; it is not news.
|
|
52
|
+
wider: [...wider.values()].filter((one) => !found.has(one.port)).sort(byPort),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// What RIPLEY may open, which is what nearly every caller wants.
|
|
57
|
+
export function parseListening(output = '', options = {}) {
|
|
58
|
+
return classifyListening(output, options).ports;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
// Whether a server agrees to be put behind glass. A page can refuse, and refusing is the correct
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The room listens on 127.0.0.1, which keeps other computers out but not other web pages: any
|
|
2
|
+
// site open in this browser can send a request to 127.0.0.1:4317, and an uploaded module is code
|
|
3
|
+
// MADRE runs with your permissions. Two checks, before any route, close that door.
|
|
4
|
+
//
|
|
5
|
+
// · Host has to name this computer. A page that points its own domain at 127.0.0.1 (DNS
|
|
6
|
+
// rebinding) still sends its domain as Host, so it never reaches a route — reads included.
|
|
7
|
+
// · A browser says who is asking in Origin. Anything that changes state has to come from the
|
|
8
|
+
// room's own page. A terminal, an agent's curl or MADRE's own CLI send no Origin and pass.
|
|
9
|
+
|
|
10
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
11
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
12
|
+
|
|
13
|
+
export function hostIsLocal(host) {
|
|
14
|
+
if (typeof host !== 'string' || !host) return false;
|
|
15
|
+
const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
|
|
16
|
+
return LOOPBACK.has(name.toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// null when the request may go on, otherwise the reason it may not.
|
|
20
|
+
export function refuseForeign({ method = 'GET', headers = {} } = {}) {
|
|
21
|
+
if (!hostIsLocal(headers.host)) return 'MADRE answers only when it is called by this computer\'s name (127.0.0.1 or localhost).';
|
|
22
|
+
const origin = headers.origin;
|
|
23
|
+
if (SAFE.has(method) || origin === undefined) return null;
|
|
24
|
+
if (origin === `http://${headers.host}` || origin === `https://${headers.host}`) return null;
|
|
25
|
+
return 'A page from somewhere else cannot act in this room.';
|
|
26
|
+
}
|
package/src/room/archivist.mjs
CHANGED
|
@@ -95,7 +95,8 @@ export class Archivist {
|
|
|
95
95
|
const started = Date.now();
|
|
96
96
|
try {
|
|
97
97
|
const local = agent.adapter === 'ollama';
|
|
98
|
-
const
|
|
98
|
+
const brief = this.#memory.briefing();
|
|
99
|
+
const prompt = distillPrompt({ entries: batch.entries, projectName: this.#deps.projectName(), existing: brief.standing, discarded: brief.discarded, json: local });
|
|
99
100
|
const result = await invokers[agent.adapter]({ executable: agent.path, projectRoot: this.#deps.projectRoot(), prompt, timeoutMs: local ? this.#deps.localTimeoutMs() : this.#deps.timeoutFor(agent.id), model: local ? null : this.#settings.model, json: local, attachments: [], lease: null, scopes: { web: false, imageGen: false }, imageStudio: null });
|
|
100
101
|
const memories = parseDistillation(result?.text, { fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
|
101
102
|
const added = this.#memory.addMemories(memories, { agent: agent.id, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
|
package/src/room/prompt.mjs
CHANGED
|
@@ -104,7 +104,7 @@ export function promptParts({
|
|
|
104
104
|
mcpServers.length
|
|
105
105
|
? `Tools from MADRE's modules, attached to this turn as MCP servers:\n${mcpServers.map((server) => `- ${server.name}: ${server.brief ?? (server.tools?.length ? server.tools.join(', ') : 'see its tool list')}`).join('\n')}`
|
|
106
106
|
: null },
|
|
107
|
-
{ id: 'sdk', text: lease && sdk && wantsModule(text) ? `Building a MADRE module: MADRE is modular and the human may ask you for a module (a slash command, a tool for the agents, an integration). Read the SDK guide at ${sdk.guide} and the complete example at ${sdk.example},
|
|
107
|
+
{ id: 'sdk', text: lease && sdk && wantsModule(text) ? `Building a MADRE module: MADRE is modular and the human may ask you for a module (a slash command, a tool for the agents, an integration). Read the SDK guide at ${sdk.guide} and the complete example at ${sdk.example}. If it talks to a service outside this machine, read ${sdk.connector} too: a connector is ONE file that is both the module and the MCP server it spawns, and that shape is not guessable. Then write ONE file named <id>.module.mjs (default export a plain spec object) ${lease.scratchDir ? `in ${lease.outDir}/${lease.scratchDir}` : 'in the lease'}. Do not touch ~/.pulse/modules or .madre/modules: MADRE shows the human an INSTALL card and they decide. A module runs inside MADRE with the human's permissions, so keep it small and honest about what it reaches. Never hardcode a credential or invent somewhere to keep one: declare 'secrets' and read them back with ctx.vault, which is where MADRE keeps them and the only place that is safe.` : null },
|
|
108
108
|
{ id: 'lease', text: lease ? leaseInstructions({ outDir: lease.outDir, agentId: agent.id, scopes: lease.scopes, capable: scopesFor(agent.id), imageStudio, control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock), scratchDir: lease.scratchDir ?? null }) : null },
|
|
109
109
|
{ id: 'control-held', text: !lease?.control && controlHolder && controlHolder !== agent.id ? `Heads-up: @${controlHolder} currently holds CONTROL and may be changing project files while you work; cite the state you actually read.` : null },
|
|
110
110
|
{ id: 'escalation', text: escalation ? `The human was asked to allow file creation for this step and ${escalation === 'timeout' ? 'did not answer in time' : escalation === 'stopped' ? 'stopped the plan' : 'declined'}. Answer read-only: say plainly what you would have created and what it would contain, without creating it.` : null },
|
package/src/room.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { UsageSentinel } from './usage-sentinel.mjs';
|
|
|
10
10
|
import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
|
|
11
11
|
import { turnCost, observedRate } from './room/economy.mjs';
|
|
12
12
|
import { contextFor } from './room/context.mjs';
|
|
13
|
+
import { VERDICTS } from './memory.mjs';
|
|
13
14
|
import { coldNotes, coldReading } from './cold.mjs';
|
|
14
15
|
import { questionsFor } from './asking.mjs';
|
|
15
16
|
import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
|
|
@@ -28,7 +29,7 @@ import { CODE000_STRIKES } from './mother.mjs';
|
|
|
28
29
|
import { parseDirectives } from './directives.mjs';
|
|
29
30
|
import { isValidModelName } from './models.mjs';
|
|
30
31
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
31
|
-
import { createLease, diffSnapshots, snapshot } from './lease.mjs';
|
|
32
|
+
import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
|
|
32
33
|
import { stat as statFile } from 'node:fs/promises';
|
|
33
34
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
34
35
|
import { contentTypeFor } from './files.mjs';
|
|
@@ -257,7 +258,7 @@ export class Room {
|
|
|
257
258
|
outDir: lease.relativeDir,
|
|
258
259
|
scratchDir: lease.scratchDir,
|
|
259
260
|
scopes: SCOPES.filter((scope) => enabled[scope]),
|
|
260
|
-
unavailable: SCOPES.filter((scope) => !scopes[scope].capable)
|
|
261
|
+
unavailable: SCOPES.filter((scope) => !scopes[scope].capable),
|
|
261
262
|
planId,
|
|
262
263
|
});
|
|
263
264
|
await this.#emit('mode.granted', { requestId, planId, agent: step.agent, mode, step: index + 1, scope: decision.decision, leaseId: lease.leaseId, message: decision.decision === 'plan' ? `#${mode} granted for the rest of the plan; every writable agent creates inside one lease.` : `#${mode} granted to @${step.agent} for step ${index + 1} only.` });
|
|
@@ -345,6 +346,12 @@ export class Room {
|
|
|
345
346
|
} catch { return null; }
|
|
346
347
|
}
|
|
347
348
|
|
|
349
|
+
// Counts only, for the MEMORY dashboard. See RoomMemory#activity.
|
|
350
|
+
memoryActivity({ days = 30 } = {}) {
|
|
351
|
+
if (!this.#memory) return null;
|
|
352
|
+
try { return this.#memory.activity({ days }); } catch { return null; }
|
|
353
|
+
}
|
|
354
|
+
|
|
348
355
|
/* ---------- MU/TH/UR's channel ---------- */
|
|
349
356
|
|
|
350
357
|
motherStatus() { return this.#mother ? this.#mother.status() : null; }
|
|
@@ -616,11 +623,15 @@ export class Room {
|
|
|
616
623
|
return result;
|
|
617
624
|
}
|
|
618
625
|
|
|
619
|
-
// The human's verdict on a reply: good, bad, or cleared. The dataset listens
|
|
626
|
+
// The human's verdict on a reply: good, bad, or cleared. The dataset listens, and so does the
|
|
627
|
+
// archive: what the room distilled from that reply changes standing, without a word of it being
|
|
628
|
+
// rewritten. The text of a note is what the archivist heard; where it stands is what you said.
|
|
620
629
|
async rateMessage(messageId, rating) {
|
|
621
630
|
if (typeof messageId !== 'string' || !messageId.trim()) throw new Error('Which reply? Give its messageId.');
|
|
622
|
-
if (!
|
|
623
|
-
|
|
631
|
+
if (!VERDICTS.includes(rating)) throw new Error(`A verdict is one of ${VERDICTS.join(', ')}.`);
|
|
632
|
+
const event = await this.#emit('message.rated', { messageId, rating, by: 'you' });
|
|
633
|
+
this.#memory?.judge(messageId, rating);
|
|
634
|
+
return event;
|
|
624
635
|
}
|
|
625
636
|
|
|
626
637
|
// Forgetting is recorded in the ledger like anything else the human does to the room.
|
|
@@ -881,7 +892,10 @@ export class Room {
|
|
|
881
892
|
if (create === true || standing) {
|
|
882
893
|
const scopes = this.scopesFor(parsed.target);
|
|
883
894
|
const enabled = SCOPES.filter((scope) => scopes[scope].enabled && scopes[scope].wired);
|
|
884
|
-
|
|
895
|
+
// The ids travel; the labels only build the English sentence below. A label is already a
|
|
896
|
+
// word, so the client's translator had nothing to look up and left it as it found it.
|
|
897
|
+
const unavailable = SCOPES.filter((scope) => !scopes[scope].capable);
|
|
898
|
+
const unavailableSaid = unavailable.map((scope) => SCOPE_LABELS[scope]);
|
|
885
899
|
const disabled = SCOPES.filter((scope) => scopes[scope].capable && !scopes[scope].enabled && scopes[scope].wired).map((scope) => SCOPE_LABELS[scope]);
|
|
886
900
|
if (!enabled.includes('write') || scopes.maxMode < 2) {
|
|
887
901
|
const alternatives = this.#agents.filter((agent) => agent.ready && agent.id !== parsed.target && this.scopesFor(agent.id).maxMode >= 2).map((agent) => `@${agent.id}`);
|
|
@@ -892,7 +906,7 @@ export class Room {
|
|
|
892
906
|
unavailable,
|
|
893
907
|
disabled,
|
|
894
908
|
alternatives,
|
|
895
|
-
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${
|
|
909
|
+
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
|
|
896
910
|
});
|
|
897
911
|
} else {
|
|
898
912
|
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
@@ -982,6 +996,27 @@ export class Room {
|
|
|
982
996
|
await this.#emit('room.alert', { code, message, plans: this.activePlans().length, turns: this.#turns.size });
|
|
983
997
|
}
|
|
984
998
|
|
|
999
|
+
// Human brake for ONE answer, which is a different thing from the master brake. STOPALL takes
|
|
1000
|
+
// down every plan and every agent in the room and is meant for when things have run away;
|
|
1001
|
+
// this takes down the one turn you are looking at, because an agent that has hung or started
|
|
1002
|
+
// down the wrong road should not cost the whole room to stop. The agent's process dies with the
|
|
1003
|
+
// abort and the turn records its own failure the way any other failure does.
|
|
1004
|
+
async stopTurn(messageId, reason = t('stopped by the human')) {
|
|
1005
|
+
const turn = this.#turns.get(messageId);
|
|
1006
|
+
if (!turn) return false;
|
|
1007
|
+
// Marked before the abort lands, so the failure it causes carries the mark: the turn ends
|
|
1008
|
+
// like any other failure because the process really was killed, and only this says it was
|
|
1009
|
+
// asked for.
|
|
1010
|
+
turn.stoppedByHuman = true;
|
|
1011
|
+
await this.#emit('turn.stopped', { messageId, agent: turn.agent, planId: turn.planId, reason, by: 'you' });
|
|
1012
|
+
turn.controller.abort(reason);
|
|
1013
|
+
// Waited out, the way the master brake waits: aborting only asks, and the turn still has to
|
|
1014
|
+
// kill its process and record how it ended. Returning before that leaves the caller — and
|
|
1015
|
+
// anything tidying up after it — racing a write that has not happened yet.
|
|
1016
|
+
await turn.promise?.catch(() => {});
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
985
1020
|
// Human brake: stops the remaining steps of a running plan and interrupts
|
|
986
1021
|
// the step in flight. Recorded in the log like everything else.
|
|
987
1022
|
async stopPlan(planId, reason = 'stopped by the human') {
|
|
@@ -1253,7 +1288,7 @@ export class Room {
|
|
|
1253
1288
|
outDir: lease.relativeDir,
|
|
1254
1289
|
scratchDir: lease.scratchDir,
|
|
1255
1290
|
scopes: ['write'],
|
|
1256
|
-
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable)
|
|
1291
|
+
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable),
|
|
1257
1292
|
planId,
|
|
1258
1293
|
});
|
|
1259
1294
|
}
|
|
@@ -1345,6 +1380,21 @@ export class Room {
|
|
|
1345
1380
|
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
1346
1381
|
const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
|
|
1347
1382
|
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
1383
|
+
// A turn that could write, wrote nothing, and answers naming files. That is the shape of a
|
|
1384
|
+
// write refused without a word — a headless CLI cannot stop to ask, so what the policy does
|
|
1385
|
+
// not allow simply fails and the model reports the file as created. The room does not read
|
|
1386
|
+
// the prose for a claim; it takes the paths the reply names and asks the disk. Only what is
|
|
1387
|
+
// named AND absent is said, so an agent talking about files that exist says nothing here.
|
|
1388
|
+
if (lease && !lease.control && artifacts.length === 0) {
|
|
1389
|
+
const roots = [this.#projectRoot, lease.outDir, lease.realOutDir].filter(Boolean);
|
|
1390
|
+
const named = namedPaths(result.text, { roots });
|
|
1391
|
+
const missing = [];
|
|
1392
|
+
for (const path of named.slice(0, 12)) {
|
|
1393
|
+
const full = path.startsWith('/') ? path : joinPath(this.#projectRoot, path);
|
|
1394
|
+
if (!(await statFile(full).then(() => true, () => false))) missing.push(path);
|
|
1395
|
+
}
|
|
1396
|
+
if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
|
|
1397
|
+
}
|
|
1348
1398
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
1349
1399
|
const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
|
|
1350
1400
|
const directives = mayDelegate
|
|
@@ -1421,7 +1471,7 @@ export class Room {
|
|
|
1421
1471
|
await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
|
|
1422
1472
|
}
|
|
1423
1473
|
}
|
|
1424
|
-
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error) });
|
|
1474
|
+
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error), ...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}) });
|
|
1425
1475
|
return null;
|
|
1426
1476
|
} finally {
|
|
1427
1477
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|
package/src/sentinel-errors.mjs
CHANGED
|
@@ -100,8 +100,12 @@ export class ErrorSentinel {
|
|
|
100
100
|
// A room event goes by: a failure MU/TH/UR cannot classify is worth a report.
|
|
101
101
|
async observe(event) {
|
|
102
102
|
if (event?.type !== 'message.failed') return null;
|
|
103
|
-
const { target, error } = event.payload ?? {};
|
|
103
|
+
const { target, error, stopped } = event.payload ?? {};
|
|
104
104
|
if (typeof error !== 'string' || !error.trim()) return null;
|
|
105
|
+
// A turn the human stopped is not a condition. It ends exactly like a failure because the
|
|
106
|
+
// process really was killed, but nothing went wrong and there is nothing for anyone to
|
|
107
|
+
// diagnose — filing it would be reporting the brake as a fault.
|
|
108
|
+
if (stopped) return null;
|
|
105
109
|
const known = diagnose(error, target ?? null);
|
|
106
110
|
if (known.length) return null;
|
|
107
111
|
return this.record({ kind: 'unknown', agent: target ?? null, error, at: event.timestamp });
|
package/src/server.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import http from 'node:http';
|
|
|
2
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 {
|
|
5
|
+
import { classifyListening, probePort, framable } from './ports.mjs';
|
|
6
6
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
@@ -48,9 +48,11 @@ import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
|
48
48
|
import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
|
|
49
49
|
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
50
50
|
import { applyKey, keyPlanFor } from './credentials.mjs';
|
|
51
|
+
import { forgetSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
51
52
|
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
52
53
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
53
54
|
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
|
|
55
|
+
import { bootWhatsNew, markSeen, notesFor, whatsNew } from './whats-new.mjs';
|
|
54
56
|
|
|
55
57
|
// Where a person reads the module contract, in the language the room is speaking.
|
|
56
58
|
const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
|
|
@@ -67,7 +69,9 @@ import { planFileOp, CREATIONS } from './file-ops.mjs';
|
|
|
67
69
|
import { Eyecat } from './eyecat-watch.mjs';
|
|
68
70
|
import { economy } from './room/economy.mjs';
|
|
69
71
|
import { maturity } from './maturity.mjs';
|
|
72
|
+
import { memoryDashboard } from './memory-dashboard.mjs';
|
|
70
73
|
import { verdictFor } from './verdict.mjs';
|
|
74
|
+
import { refuseForeign } from './request-guard.mjs';
|
|
71
75
|
|
|
72
76
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
73
77
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
@@ -145,6 +149,9 @@ export async function createPulseServer({
|
|
|
145
149
|
ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
|
|
146
150
|
}) {
|
|
147
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 }));
|
|
148
155
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
149
156
|
applyConfigToEnv(await loadConfig(root));
|
|
150
157
|
// What language the screens speak. The prompt the agents read is not one of them, and the
|
|
@@ -201,7 +208,7 @@ export async function createPulseServer({
|
|
|
201
208
|
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
202
209
|
async function versionView({ force = false } = {}) {
|
|
203
210
|
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
204
|
-
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 };
|
|
205
212
|
}
|
|
206
213
|
// Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
|
|
207
214
|
// happens after the screen is answered, never in front of it: at most once an hour per room,
|
|
@@ -847,6 +854,8 @@ export async function createPulseServer({
|
|
|
847
854
|
}
|
|
848
855
|
|
|
849
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 });
|
|
850
859
|
const url = new URL(request.url, 'http://127.0.0.1');
|
|
851
860
|
// The project's own folder name, typed by the human. Declared at the top of the handler and
|
|
852
861
|
// not beside the first route that happened to need it: a const sits in its temporal dead zone
|
|
@@ -1116,17 +1125,20 @@ export async function createPulseServer({
|
|
|
1116
1125
|
}
|
|
1117
1126
|
if (request.method === 'GET' && url.pathname === '/api/ports') {
|
|
1118
1127
|
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
|
|
1119
|
-
let listening = [];
|
|
1128
|
+
let listening = { ports: [], wider: [] };
|
|
1120
1129
|
try {
|
|
1121
1130
|
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 });
|
|
1122
|
-
listening =
|
|
1123
|
-
} catch { return sendJson(response, 200, { ports: [], probed: false }); }
|
|
1131
|
+
listening = classifyListening(stdout, { self: request.socket.localPort });
|
|
1132
|
+
} catch { return sendJson(response, 200, { ports: [], wider: [], probed: false }); }
|
|
1124
1133
|
const ports = [];
|
|
1125
|
-
for (const found of listening.slice(0, 24)) {
|
|
1134
|
+
for (const found of listening.ports.slice(0, 24)) {
|
|
1126
1135
|
const probe = await probePort(found.port, { fetchImpl: reportFetch });
|
|
1127
1136
|
if (probe.ok) ports.push({ ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable });
|
|
1128
1137
|
}
|
|
1129
|
-
|
|
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 });
|
|
1130
1142
|
}
|
|
1131
1143
|
if (request.method === 'GET' && url.pathname === '/api/questions') {
|
|
1132
1144
|
const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
|
|
@@ -1261,6 +1273,15 @@ export async function createPulseServer({
|
|
|
1261
1273
|
} catch (error) { return sendJson(response, 400, { error: error.message }); }
|
|
1262
1274
|
}
|
|
1263
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
|
+
}
|
|
1264
1285
|
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
1265
1286
|
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
1266
1287
|
}
|
|
@@ -1325,6 +1346,11 @@ export async function createPulseServer({
|
|
|
1325
1346
|
verdict: verdictFor({ maturity: grown, exams: exams.last, cold: research?.cold, asks: research?.ask?.length ?? 0 }),
|
|
1326
1347
|
});
|
|
1327
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
|
+
}
|
|
1328
1354
|
// Inside the core: the document MADRE writes in the human's name for the next turn, block
|
|
1329
1355
|
// by block. Built on demand and stored nowhere; asking costs nothing and sends nothing.
|
|
1330
1356
|
if (request.method === 'GET' && url.pathname === '/api/briefing') {
|
|
@@ -1497,6 +1523,34 @@ export async function createPulseServer({
|
|
|
1497
1523
|
}
|
|
1498
1524
|
// A provider key for a CLI that signs in from its own prompt. Local connections only: a key
|
|
1499
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
|
+
}
|
|
1500
1554
|
// for it and never reaches MADRE's config, its ledger or its logs.
|
|
1501
1555
|
const keyMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/key$/);
|
|
1502
1556
|
if (keyMatch) {
|
|
@@ -1548,6 +1602,12 @@ export async function createPulseServer({
|
|
|
1548
1602
|
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
|
|
1549
1603
|
return sendJson(response, 200, { models });
|
|
1550
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
|
+
}
|
|
1551
1611
|
if (request.method === 'POST' && url.pathname === '/api/stop-all') {
|
|
1552
1612
|
const result = await room.stopAll();
|
|
1553
1613
|
return sendJson(response, 202, { stopped: true, ...result });
|