@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.
@@ -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({ ...ctx, settings }, turn)) ?? []; } catch (error) { console.error(`MADRE module ${spec.id}: toolsForTurn failed: ${error.message}`); return []; }
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({ ...ctx, settings: settingsFrom(ctx.config) }, what) : null,
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({ ...ctx, settings }) : {};
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({ ...ctx, settings }, settings);
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({ ...ctx, settings }, payload);
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({ ...ctx, settings: { ...settings, enabled } }, enabled) : null;
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
- return { id: one.id, to: one.to, local: false };
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
- export function parseListening(output = '', { self = null } = {}) {
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
- return [...found.values()].sort((a, b) => a.port - b.port);
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
+ }
@@ -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 prompt = distillPrompt({ entries: batch.entries, projectName: this.#deps.projectName(), existing: this.#memory.memories({ limit: 12 }), json: local });
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 });
@@ -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}, 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, honest about what it reaches, and never store credentials.` : 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}. 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).map((scope) => SCOPE_LABELS[scope]),
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 (!['good', 'bad', 'none'].includes(rating)) throw new Error('A rating is good, bad or none.');
623
- return this.#emit('message.rated', { messageId, rating, by: 'you' });
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
- const unavailable = SCOPES.filter((scope) => !scopes[scope].capable).map((scope) => SCOPE_LABELS[scope]);
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'}${unavailable.length ? `; it cannot ${unavailable.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
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).map((scope) => SCOPE_LABELS[scope]),
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.
@@ -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 { parseListening, probePort, framable } from './ports.mjs';
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 = parseListening(stdout, { self: request.socket.localPort });
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
- return sendJson(response, 200, { ports, probed: true });
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 });