@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.
@@ -21,6 +21,10 @@ export function mcpServersFor({ imageStudio = null, memoryServer = null, mcpServ
21
21
  // Claude's permission names for a module's server: each tool, or the whole server when the list is open.
22
22
  export const claudeMcpTools = (server) => (server.tools?.length ? server.tools.map((tool) => `mcp__${server.name}__${tool}`) : [`mcp__${server.name}`]);
23
23
 
24
+ // Every spelling of the lease directory. One unless the path given to MADRE goes through a
25
+ // symlink, in which case the resolved one travels too — see the note in createLease.
26
+ export const leaseRoots = (lease) => [...new Set([lease?.outDir, lease?.realOutDir].filter(Boolean))];
27
+
24
28
  export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
25
29
  const tools = claudeTools({ lease, scopes });
26
30
  const mcpTools = [
@@ -34,7 +38,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
34
38
  // Claude Code grants Write only when Edit is allowed on the same paths (verified with the real
35
39
  // CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
36
40
  // restores existing files after the turn.
37
- ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
41
+ ...(lease ? leaseRoots(lease).flatMap((root) => [`Write(//${root}/**)`, `Edit(//${root}/**)`]) : []),
38
42
  ...(lease?.airlock ? ['Bash'] : []),
39
43
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
40
44
  ...mcpTools,
@@ -53,7 +57,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
53
57
  '--tools', tools.join(','),
54
58
  ...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
55
59
  // CONTROL: the whole project is writable except MADRE's forbidden zones.
56
- ...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
60
+ ...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => leaseRoots(lease).flatMap((root) => [`Write(//${root}/${glob})`, `Edit(//${root}/${glob})`])).join(',')] : []),
57
61
  // --safe-mode disables every MCP server, ours included. With a MADRE server
58
62
  // attached we drop it and instead load no setting sources at all: no user
59
63
  // hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
@@ -28,13 +28,21 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
28
28
  // whose file_path argument starts with the lease directory. Plan mode would
29
29
  // block every write regardless of policy, so a lease uses approval "default":
30
30
  // headless Gemini cannot prompt, so anything the policy does not allow fails.
31
- export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false } = {}) {
32
- const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false, alsoAt = null } = {}) {
32
+ const quote = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ // Every spelling of the same directory. When the path MADRE was given goes through a symlink,
34
+ // an agent that resolves before writing asks for a path that does not start with the one these
35
+ // rules were built from: the allow rule misses and the write dies in silence. Both go in the
36
+ // allow rule AND in the deny rule — allowing a second spelling while forbidding the zones under
37
+ // only the first would leave .git and .pulse reachable by the other name.
38
+ const roots = [...new Set([outDir, alsoAt].filter(Boolean))];
39
+ const escaped = roots.map(quote).join('|');
40
+ const anyRoot = roots.length > 1 ? `(?:${escaped})` : escaped;
33
41
  // The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
34
42
  const forbidden = control || create ? `
35
43
  [[rule]]
36
44
  toolName = ["write_file", "replace", "edit", "run_shell_command"]
37
- argsPattern = '${escaped}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
45
+ argsPattern = '${anyRoot}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
38
46
  decision = "deny"
39
47
  priority = 1100
40
48
  interactive = false
@@ -52,7 +60,7 @@ interactive = false
52
60
  return `${geminiReadonlyPolicy}${commands}
53
61
  [[rule]]
54
62
  toolName = ${tools}
55
- argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
63
+ argsPattern = '"file_path"\\s*:\\s*"${anyRoot}/'
56
64
  decision = "allow"
57
65
  priority = 1000
58
66
  interactive = false
@@ -68,7 +76,7 @@ interactive = false
68
76
  `;
69
77
 
70
78
  export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
71
- return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
79
+ return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock), alsoAt: lease.realOutDir ?? null }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
72
80
  }
73
81
 
74
82
  // Allow rules for a server's tools, by bare name and by server-prefixed name; an open tool list allows the server's prefix.
@@ -251,6 +259,19 @@ export function diagnoseGeminiStderr(stderr) {
251
259
  if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
252
260
  return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
253
261
  }
262
+ // A subagent, refused. The policy is right to refuse it — a subagent answers outside the lease
263
+ // and outside everything the room counts — but the CLI does not treat the refusal as an answer:
264
+ // it waits, and the turn dies on MADRE's clock three minutes later with nothing to show.
265
+ //
266
+ // Only this tool. A blocked `write_file` leaves the CLI working and it goes on to answer, so
267
+ // treating every refusal as fatal would cut turns that were going to finish.
268
+ if (/Unauthorized tool call:\s*'?invoke_agent'?|Blocked call:[^\n]*invoke_agent/i.test(text)) {
269
+ return {
270
+ code: 'SUBAGENT_BLOCKED',
271
+ message: t('Gemini tried to hand this turn to one of its own subagents. MADRE does not allow that: a subagent answers outside the lease, so nothing it wrote would be checked, shown or undoable.'),
272
+ hint: t('Ask again saying it should do the work itself, without subagents.'),
273
+ };
274
+ }
254
275
  return null;
255
276
  }
256
277
 
@@ -105,6 +105,11 @@ export function roundTable(text, { crew = [], maxSteps = 4 } = {}) {
105
105
  if (!step || !step[0]) break;
106
106
  rest = rest.slice(step[0].length);
107
107
  }
108
+ // Whatever punctuation the hinge left behind. The loop above eats the linking words, but the
109
+ // colon in «pregúntale al crew: …» only goes with them when another connective follows it —
110
+ // and what is left starts the question the room announces, sends to four agents and quotes
111
+ // again when it closes.
112
+ rest = rest.trim().replace(/^[\s,:;.\-–—]+/, '');
108
113
  rest = rest.trim().replace(/^[«"“'`]+|[»"”'`.!]+$/g, '').trim();
109
114
  const question = rest.length >= 6 ? rest : (es ? DEFAULT_ES : DEFAULT_EN);
110
115
  const agents = crew.slice(0, maxSteps);
package/src/dataset.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import { mkdir, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { redact } from './sentinel-errors.mjs';
11
- import { ABERRATION } from './memory.mjs';
11
+ import { ABERRATION, VERDICTS, NEGATIVE_VERDICTS, POSITIVE_VERDICTS } from './memory.mjs';
12
12
 
13
13
  const MIN_ANSWER_CHARS = 40;
14
14
 
@@ -19,16 +19,21 @@ function system(project, agent) {
19
19
  // Pairs a question with the reply that answered it (parentMessageId). The question is the
20
20
  // human's message, or an orchestrator's delegated step: both are real instructions with a
21
21
  // real answer. What never trains MADRE: @madre's own answers (the student is not the teacher),
22
- // MADRE's canned replies, replies the human rated bad, ghosts, one-liners.
22
+ // MADRE's canned replies, replies the human judged bad or never, ghosts, one-liners.
23
23
  const guard = (privacy) => (text) => (privacy ? privacy.redact(text).text : text);
24
24
  export const STUDENT = 'madre';
25
25
  export const DATASET_TARGET = 300;
26
26
 
27
+ // The ledger folded to the last word said about each reply. Read against the vocabulary and not
28
+ // against a pair of literals: for a while this knew two verdicts and treated everything else as a
29
+ // clearing, so a verdict the room had just learned to say would be deleted here on the way out,
30
+ // in silence, with the export looking perfectly healthy.
27
31
  export function ratingsFrom(events) {
28
32
  const ratings = new Map();
29
33
  for (const event of events) {
30
34
  if (event.type !== 'message.rated' || !event.payload?.messageId) continue;
31
- if (event.payload.rating === 'good' || event.payload.rating === 'bad') ratings.set(event.payload.messageId, event.payload.rating);
35
+ const verdict = event.payload.rating;
36
+ if (VERDICTS.includes(verdict) && verdict !== 'none') ratings.set(event.payload.messageId, verdict);
32
37
  else ratings.delete(event.payload.messageId);
33
38
  }
34
39
  return ratings;
@@ -51,7 +56,7 @@ export function pairsFromEvents(events, { project = 'project', home, user, priva
51
56
  continue;
52
57
  }
53
58
  if (p.sender === STUDENT || p.synthetic) continue;
54
- if (ratings.get(p.messageId) === 'bad') continue;
59
+ if (NEGATIVE_VERDICTS.includes(ratings.get(p.messageId))) continue;
55
60
  const ask = asks.get(p.parentMessageId);
56
61
  if (!ask) continue;
57
62
  const answer = (p.originalText ?? p.text).trim();
@@ -82,7 +87,7 @@ export function readiness(events, notes = [], { target = DATASET_TARGET } = {})
82
87
  for (const rating of ratings.values()) if (rating === 'bad') bad += 1;
83
88
  const turns = pairs.filter((pair) => pair.kind === 'turn').length;
84
89
  const delegated = pairs.length - turns;
85
- const good = pairs.filter((pair) => pair.rating === 'good').length;
90
+ const good = pairs.filter((pair) => POSITIVE_VERDICTS.includes(pair.rating)).length;
86
91
  // Aberrations are counted apart: they never become one of the pairs the model learns to
87
92
  // answer with, so counting them toward readiness would say the room is further along than it is.
88
93
  const standing = notes.filter((note) => note.kind !== ABERRATION && !note.refutedBy);
@@ -173,7 +178,7 @@ export async function exportDataset({ events, notes = [], dir, project = 'projec
173
178
  await writeFile(join(dir, 'preferences.jsonl'), preferences.map((pair) => JSON.stringify({ prompt: pair.prompt, chosen: pair.chosen, rejected: pair.rejected })).join('\n') + (preferences.length ? '\n' : ''));
174
179
  const byAgent = {};
175
180
  for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
176
- const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => p.rating === 'good').length, notes: pairs.filter((p) => p.kind === 'note').length, aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
181
+ const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
177
182
  await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
178
183
  return { dir, ...manifest };
179
184
  }
package/src/distiller.mjs CHANGED
@@ -11,6 +11,9 @@ export const DISTILLER_ORDER = ['ollama', 'gemini', 'opencode', 'codex', 'claude
11
11
  export const OLLAMA_ARCHIVIST = { id: 'ollama', label: 'Ollama', detected: true, ready: true, adapter: 'ollama', path: null, version: null, local: true };
12
12
  export const MAX_MEMORIES_PER_RUN = 5;
13
13
  export const MAX_MEMORY_CHARS = 240;
14
+ // A discard is matched against, not learned from, so it travels clipped: the room pays about
15
+ // half for the same effect.
16
+ export const DISCARDED_CHARS = 110;
14
17
 
15
18
  // The agent that pays for distillation: the human's pick if it is usable, else
16
19
  // the cheapest ready one that has an adapter and no turn in flight.
@@ -28,13 +31,20 @@ export function pickDistiller(agents, { preferred = null, busy = new Set(), invo
28
31
  return agents.find(usable) ?? null;
29
32
  }
30
33
 
31
- export function distillPrompt({ entries, projectName = 'the project', existing = [], json = false }) {
34
+ const shorten = (value, max) => (value.length > max ? `${value.slice(0, max - 1)}…` : value);
35
+
36
+ export function distillPrompt({ entries, projectName = 'the project', existing = [], discarded = [], json = false }) {
32
37
  const transcript = entries.map((entry) => {
33
38
  const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
34
39
  const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
35
40
  return `[#${entry.sequence} · ${who} (${entry.role})${to}] ${entry.text}`;
36
41
  }).join('\n\n');
42
+ // Two lists, because they say opposite things. What the room holds is not to be written twice;
43
+ // what the room threw out is not to be written at all — and before this the archivist was handed
44
+ // the newest notes whatever their standing, false ones included, under one heading that only
45
+ // said «do not repeat». A list with nothing in it is left out rather than printed empty.
37
46
  const known = existing.length ? `\nAlready remembered (do not repeat these):\n${existing.map((memory) => `- ${memory.text}`).join('\n')}\n` : '';
47
+ const thrownOut = discarded.length ? `\nAlready discarded — the room took these out of circulation. Do not write them again, in these words or others:\n${discarded.map((memory) => `- ${shorten(memory.text, DISCARDED_CHARS)}`).join('\n')}\n` : '';
38
48
  return [
39
49
  `You are the archivist of a MADRE project room for "${projectName}", shared by a human and several AI agents. Do not read or modify any file: everything you need is below.`,
40
50
  'Read these room exchanges (each stamped with its ledger sequence) and write only the memories worth keeping for future turns of any agent:',
@@ -50,6 +60,7 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
50
60
  json ? null : `{"kind":"${MEMORY_KINDS.join('|')}","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","correction":"only on an aberration: what is true instead","sources":[sequence numbers it comes from]}`,
51
61
  json ? null : 'If nothing durable was said, output exactly: NONE',
52
62
  known,
63
+ thrownOut,
53
64
  '<exchanges>',
54
65
  transcript,
55
66
  '</exchanges>',
package/src/lease.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, stat } from 'node:fs/promises';
1
+ import { mkdir, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { contentTypeFor } from './files.mjs';
4
4
 
@@ -24,7 +24,41 @@ export async function createLease({ projectRoot, leaseId }) {
24
24
  const relativeDir = join(LEASE_ROOT, name);
25
25
  const outDir = join(projectRoot, relativeDir);
26
26
  await mkdir(outDir, { recursive: true, mode: 0o755 });
27
- return { leaseId, outDir, relativeDir };
27
+ // The same directory said the other way, when the path given to MADRE goes through a symlink.
28
+ // On macOS `/tmp` is a link to `/private/tmp`, so an agent that resolves a path before writing
29
+ // asks to write somewhere that does not begin with the string the permission rule was built
30
+ // from. The rule misses, a headless CLI cannot stop to ask, the write fails without a word, and
31
+ // the model reports the file as created. Both spellings travel so both can be allowed — and,
32
+ // just as importantly, so the forbidden zones can be denied under either one.
33
+ const realOutDir = await realpath(outDir).catch(() => outDir);
34
+ return { leaseId, outDir, relativeDir, ...(realOutDir === outDir ? {} : { realOutDir }) };
35
+ }
36
+
37
+ // The files a reply names, as paths and not as intentions.
38
+ //
39
+ // A CLI whose write was refused in silence answers as if it had written: the room shows no
40
+ // artifacts, and the text says «Archivos creados: …». Rather than read the prose for a claim —
41
+ // which means guessing in two languages and warning wrongly when an agent merely SUGGESTS a
42
+ // path — this collects the paths it names under the project and lets the disk settle it. What
43
+ // is on disk is never mentioned; only what is named and absent.
44
+ export function namedPaths(text, { roots = [] } = {}) {
45
+ const said = String(text ?? '');
46
+ const bases = roots.filter(Boolean);
47
+ const found = new Set();
48
+ // A path, absolute or project-relative, ending in a short extension. Trailing punctuation and
49
+ // the closing of a quote, a bracket or a sentence are not part of a filename.
50
+ for (const match of said.matchAll(/(?:^|[\s(`"'«\[>])((?:\/|\.{0,2}\/)?[\w.@+-]+(?:\/[\w.@+-]+)+\.[A-Za-z][\w]{0,8})/g)) {
51
+ let path = match[1].replace(/[.,;:)\]»"'`]+$/, '');
52
+ if (!path.includes('/')) continue;
53
+ if (path.startsWith('/')) {
54
+ const under = bases.find((base) => path === base || path.startsWith(`${base}/`));
55
+ if (!under) continue; // somewhere else entirely: not ours to check
56
+ found.add(path);
57
+ continue;
58
+ }
59
+ found.add(path.replace(/^\.\//, ''));
60
+ }
61
+ return [...found];
28
62
  }
29
63
 
30
64
  // name → { size, mtimeMs } for every file under `dir`, recursively.
@@ -85,6 +119,7 @@ export function leaseInstructions({ outDir, agentId, scopes = { write: true, ima
85
119
  : canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
86
120
  : couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
87
121
  : 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
122
+ 'Do this work yourself. Do not hand it to a subagent or spawn another agent: a subagent writes outside this lease, so nothing it did would be checked, shown to the human or undoable — and the tools for it are blocked, which can leave you waiting instead of answering.',
88
123
  'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
89
124
  ].filter(Boolean).join('\n');
90
125
  }
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // MADRE CORREO: a tiny MCP server (stdio, JSON-RPC 2.0) exposing one tool, send_email, that
3
+ // hands one message to the user's own mail provider over SMTP+TLS with the user's own app
4
+ // password. There is no MADRE in the middle: this process talks to smtp.gmail.com (or whatever
5
+ // host the module was pointed at) and to nothing else.
6
+ //
7
+ // This is the first MADRE tool whose effect does not come back. A file can be undone; a sent
8
+ // message cannot. So three things hold, and each is enforced somewhere a module cannot reach:
9
+ //
10
+ // · The room only hands this server's tool to a turn at #4 AIRLOCK (toolsForTurn, `sends`),
11
+ // where the agent must already say in one line what leaves and where before it leaves.
12
+ // · The password arrives in this process's environment from the vault, and is never written
13
+ // anywhere: not in the tool result, not in the outbound log, not in the ledger.
14
+ // · If the module set a list of allowed recipients, a message to anybody else is refused here
15
+ // rather than in the prompt, because a prompt is a suggestion.
16
+ //
17
+ // Environment:
18
+ // PULSE_SMTP_FROM required, the address messages are sent from
19
+ // PULSE_SMTP_PASSWORD required, the app password (from the vault)
20
+ // PULSE_SMTP_USER the account to authenticate as; defaults to PULSE_SMTP_FROM
21
+ // PULSE_SMTP_FROM_NAME optional display name
22
+ // PULSE_SMTP_HOST default smtp.gmail.com
23
+ // PULSE_SMTP_PORT default 465 (implicit TLS)
24
+ // PULSE_SMTP_ALLOW optional comma list of addresses or @domains; empty means anyone
25
+ // PULSE_OUTBOUND_LOG where to append the one line this send leaves behind
26
+ // PULSE_SMTP_FAKE=1 write the message to PULSE_SMTP_FAKE_FILE instead of sending (tests)
27
+
28
+ import { appendFile, realpath } from 'node:fs/promises';
29
+ import { OutboundLog, declareDestinations } from '../outbound.mjs';
30
+ import { SMTP_PORT, buildMessage, sendMail, validAddress } from '../smtp.mjs';
31
+
32
+ const SERVER_NAME = 'pulse-correo';
33
+ const SERVER_VERSION = '0.1.0';
34
+ const DEFAULT_HOST = 'smtp.gmail.com';
35
+
36
+ // The four answers `src/outbound.mjs` demands of any destination. They live here rather than in
37
+ // the module because this server is a different process — `declareDestinations` in the room says
38
+ // nothing to `declareDestinations` here — and both sides must give the same answers.
39
+ //
40
+ // The id stays `smtp` whatever the host is, so re-declaring a changed host REPLACES the entry
41
+ // rather than leaving the room vouching for a server it no longer talks to.
42
+ export const reachesFor = (host = DEFAULT_HOST) => [{
43
+ id: 'smtp',
44
+ host,
45
+ to: host === DEFAULT_HOST ? 'Google · Gmail, with your own account' : `${host}, with your own account`,
46
+ what: 'The message you asked to send: its recipients, subject and body, and your app password to sign in.',
47
+ when: 'Only when an agent in #4 AIRLOCK calls send_email, and only for that one message.',
48
+ where: 'Switch off the CORREO module, or forget its app password, and nothing can leave.',
49
+ }];
50
+ export const REACHES = reachesFor();
51
+
52
+ // Who may receive. An empty list is "anyone" — deliberate, because narrowing it is a setting the
53
+ // human turns on, not a default that would quietly swallow the first thing they try to send.
54
+ export function parseAllow(raw) {
55
+ return String(raw ?? '').split(',').map((one) => one.trim().toLowerCase()).filter(Boolean);
56
+ }
57
+
58
+ export function allowed(address, allow = []) {
59
+ if (!allow.length) return true;
60
+ const one = String(address ?? '').toLowerCase();
61
+ const domain = one.slice(one.indexOf('@'));
62
+ return allow.some((rule) => (rule.startsWith('@') ? rule === domain : rule === one));
63
+ }
64
+
65
+ // A TLS socket is not `fetch`, so the wrapper that watches every other outbound call cannot see
66
+ // this one. The server writes its own line instead: the host, whether it went, how long it took
67
+ // and how many bytes. Never a recipient, never a subject, never the password — the log says that
68
+ // something left and where to, the same as it does for every other destination.
69
+ function noteSend({ env, host, ok, ms, bytes, error = null }) {
70
+ if (!env.PULSE_OUTBOUND_LOG) return;
71
+ declareDestinations('correo', reachesFor(host));
72
+ try {
73
+ new OutboundLog({ file: env.PULSE_OUTBOUND_LOG })
74
+ .record({ url: `smtps://${host}:${env.PULSE_SMTP_PORT || SMTP_PORT}/`, method: 'SMTP', ok, ms, bytes, ...(error ? { error } : {}) });
75
+ } catch { /* the log must never be the reason a message fails */ }
76
+ }
77
+
78
+ export async function send({ to, subject, body, env = process.env, send: sendImpl = sendMail }) {
79
+ const from = env.PULSE_SMTP_FROM ?? '';
80
+ if (!validAddress(from)) throw Object.assign(new Error('CORREO has no sending address configured.'), { code: 'NO_FROM' });
81
+ const recipients = (Array.isArray(to) ? to : String(to ?? '').split(',')).map((one) => String(one).trim()).filter(Boolean);
82
+ if (!recipients.length) throw Object.assign(new Error('Say who it goes to.'), { code: 'NO_RECIPIENT' });
83
+ const allow = parseAllow(env.PULSE_SMTP_ALLOW);
84
+ const refused = recipients.filter((one) => !allowed(one, allow));
85
+ if (refused.length) throw Object.assign(new Error(`CORREO is limited to ${allow.join(', ')}; it will not write to ${refused.join(', ')}.`), { code: 'NOT_ALLOWED' });
86
+ if (!String(subject ?? '').trim()) throw Object.assign(new Error('A message with no subject reads as spam. Give it one.'), { code: 'NO_SUBJECT' });
87
+ if (!String(body ?? '').trim()) throw Object.assign(new Error('There is nothing in the message.'), { code: 'NO_BODY' });
88
+
89
+ const host = env.PULSE_SMTP_HOST || DEFAULT_HOST;
90
+ const started = Date.now();
91
+
92
+ if (env.PULSE_SMTP_FAKE === '1') {
93
+ const message = buildMessage({ from, fromName: env.PULSE_SMTP_FROM_NAME ?? '', to: recipients, subject, body });
94
+ if (env.PULSE_SMTP_FAKE_FILE) await appendFile(env.PULSE_SMTP_FAKE_FILE, `${JSON.stringify({ host, from, to: recipients, message })}\n`);
95
+ noteSend({ env, host, ok: true, ms: Date.now() - started, bytes: Buffer.byteLength(message, 'utf8') });
96
+ return { to: recipients, bytes: Buffer.byteLength(message, 'utf8'), host, reply: 'fake' };
97
+ }
98
+
99
+ try {
100
+ const result = await sendImpl({
101
+ host,
102
+ port: Number(env.PULSE_SMTP_PORT) || SMTP_PORT,
103
+ user: env.PULSE_SMTP_USER || from,
104
+ password: env.PULSE_SMTP_PASSWORD ?? '',
105
+ from,
106
+ fromName: env.PULSE_SMTP_FROM_NAME ?? '',
107
+ to: recipients,
108
+ subject,
109
+ body,
110
+ });
111
+ noteSend({ env, host, ok: true, ms: Date.now() - started, bytes: result.bytes });
112
+ return { ...result, host };
113
+ } catch (error) {
114
+ noteSend({ env, host, ok: false, ms: Date.now() - started, bytes: null, error: error.code ?? 'ERROR' });
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export const TOOL = {
120
+ name: 'send_email',
121
+ description: 'Send one plain-text email from the human\'s own mail account. This cannot be undone and the message carries their name: before calling it, state in one line exactly who it goes to and what it says. Attachments, HTML and CC/BCC are not supported.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ to: { type: 'string', description: 'Recipient address. Several are allowed, separated by commas.' },
126
+ subject: { type: 'string', description: 'The subject line.' },
127
+ body: { type: 'string', description: 'The message, as plain text. Line breaks are kept.' },
128
+ },
129
+ required: ['to', 'subject', 'body'],
130
+ },
131
+ };
132
+
133
+ export function handleRequest(message, { env = process.env, sendImpl = send } = {}) {
134
+ const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
135
+ const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
136
+ switch (message.method) {
137
+ case 'initialize':
138
+ return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
139
+ case 'notifications/initialized':
140
+ case 'initialized':
141
+ return Promise.resolve(null);
142
+ case 'ping':
143
+ return Promise.resolve(reply({}));
144
+ case 'tools/list':
145
+ return Promise.resolve(reply({ tools: [TOOL] }));
146
+ case 'tools/call': {
147
+ const { name, arguments: args = {} } = message.params ?? {};
148
+ if (name !== TOOL.name) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
149
+ return sendImpl({ to: args.to, subject: args.subject, body: args.body, env })
150
+ .then((result) => reply({ content: [{ type: 'text', text: `Sent to ${result.to.join(', ')} via ${result.host} (${result.bytes} bytes).` }], isError: false }))
151
+ .catch((error) => reply({ content: [{ type: 'text', text: `Nothing was sent (${error.code ?? 'ERROR'}): ${error.message}` }], isError: true }));
152
+ }
153
+ default:
154
+ return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
155
+ }
156
+ }
157
+
158
+ export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
159
+ let buffer = '';
160
+ let pending = 0;
161
+ let ended = false;
162
+ const finish = () => { if (ended && pending === 0) process.exit(0); };
163
+ input.setEncoding('utf8');
164
+ input.on('data', (chunk) => {
165
+ buffer += chunk;
166
+ let index;
167
+ while ((index = buffer.indexOf('\n')) >= 0) {
168
+ const line = buffer.slice(0, index).trim();
169
+ buffer = buffer.slice(index + 1);
170
+ if (!line) continue;
171
+ let message;
172
+ try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
173
+ pending += 1;
174
+ void handleRequest(message, { env })
175
+ .then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
176
+ .finally(() => { pending -= 1; finish(); });
177
+ }
178
+ });
179
+ input.on('end', () => { ended = true; finish(); });
180
+ }
181
+
182
+ const invokedDirectly = process.argv[1] && (await realpath(process.argv[1]).catch(() => process.argv[1])) === (await realpath(new URL(import.meta.url).pathname).catch(() => null));
183
+ if (invokedDirectly) {
184
+ if (!process.env.PULSE_SMTP_FROM || !process.env.PULSE_SMTP_PASSWORD) {
185
+ process.stderr.write('PULSE_SMTP_FROM and PULSE_SMTP_PASSWORD are required.\n');
186
+ process.exit(2);
187
+ }
188
+ serve();
189
+ }
@@ -0,0 +1,69 @@
1
+ // What the MEMORY dashboard draws, shaped from counts the archive already keeps.
2
+ //
3
+ // Pure functions. The room hands in `RoomMemory#activity()` and the stats it already reports;
4
+ // nothing here reads a file, and nothing here ever sees the text of a memory. A day with nothing
5
+ // in it is still a day: the series are filled to the window, so a quiet week reads as quiet
6
+ // instead of collapsing out of the chart.
7
+
8
+ import { PASS } from './exam.mjs';
9
+
10
+ export const DASHBOARD_DAYS = 30;
11
+
12
+ const pad = (n) => String(n).padStart(2, '0');
13
+ // The calendar day of this machine, the same day SQLite's `localtime` names.
14
+ export const localDay = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
15
+
16
+ export function windowDays(days = DASHBOARD_DAYS, now = new Date()) {
17
+ const out = [];
18
+ for (let back = days - 1; back >= 0; back -= 1) {
19
+ const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - back);
20
+ out.push(localDay(day));
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const fill = (pairs, days) => {
26
+ const seen = new Map(pairs ?? []);
27
+ return days.map((day) => Number(seen.get(day) ?? 0));
28
+ };
29
+ const sum = (list) => list.reduce((total, n) => total + n, 0);
30
+
31
+ // This week against the one before it, from the same filled series. `null` when there is no week
32
+ // before to compare with, so a new room never shows a rise from nothing as +100%.
33
+ function week(series) {
34
+ const last = sum(series.slice(-7));
35
+ const before = sum(series.slice(-14, -7));
36
+ return { last, before, delta: last - before };
37
+ }
38
+
39
+ export function memoryDashboard({ activity = null, stats = null, days = DASHBOARD_DAYS, now = new Date() } = {}) {
40
+ if (!activity) return null;
41
+ const axis = windowDays(days, now);
42
+ const series = {
43
+ entries: fill(activity.entries, axis),
44
+ memories: fill(activity.memories, axis),
45
+ recalls: fill(activity.recalls, axis),
46
+ };
47
+ const search = Number(activity.via?.search ?? 0);
48
+ const cascade = Number(activity.via?.cascade ?? 0);
49
+ const total = Number(stats?.memories ?? sum(Object.values(activity.kinds ?? {})));
50
+ return {
51
+ days: axis,
52
+ series,
53
+ weeks: { entries: week(series.entries), memories: week(series.memories), recalls: week(series.recalls) },
54
+ totals: {
55
+ entries: Number(stats?.entries ?? 0),
56
+ memories: total,
57
+ pending: Number(stats?.pending ?? 0),
58
+ turns: Number(activity.turns ?? 0),
59
+ reached: Number(activity.reached ?? 0),
60
+ },
61
+ kinds: activity.kinds ?? {},
62
+ agents: activity.agents ?? {},
63
+ zones: activity.zones ?? {},
64
+ via: { search, cascade },
65
+ // The rate each test has to reach to pass, so a chart can draw the line it is measured against.
66
+ pass: PASS,
67
+ vectors: stats?.embeddings ? { model: stats.embeddings.model, entries: Number(stats.embeddings.entries ?? 0), memories: Number(stats.embeddings.memories ?? 0) } : null,
68
+ };
69
+ }