@jossuealcala/madre 0.7.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +817 -1
  2. package/CONTRIBUTING.md +5 -1
  3. package/NOTICE +20 -1
  4. package/README.md +99 -34
  5. package/TRADEMARKS.md +65 -0
  6. package/bin/madre.mjs +30 -10
  7. package/docs/INTERNALS.md +2 -2
  8. package/docs/REFERENCE.md +159 -82
  9. package/docs/SDK.md +23 -19
  10. package/docs/madre-banner.png +0 -0
  11. package/package.json +4 -3
  12. package/public/app.js +11668 -4219
  13. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/Geist-latin.woff2 +0 -0
  15. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  16. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  17. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  18. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  19. package/public/assets/brand/OFL-Geist.txt +93 -0
  20. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  21. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  22. package/public/assets/brand/OFL-Sora.txt +93 -0
  23. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  24. package/public/assets/brand/Sora-latin.woff2 +0 -0
  25. package/public/assets/brand/madre-app-180.png +0 -0
  26. package/public/assets/brand/madre-app-192.png +0 -0
  27. package/public/assets/brand/madre-app-512.png +0 -0
  28. package/public/assets/brand/madre-app.svg +1 -0
  29. package/public/assets/brand/madre-favicon.svg +1 -0
  30. package/public/assets/news/1.0/alerts.webp +0 -0
  31. package/public/assets/news/1.0/bar.webp +0 -0
  32. package/public/assets/news/1.0/finder.webp +0 -0
  33. package/public/assets/news/1.0/github.webp +0 -0
  34. package/public/assets/news/1.0/intents.webp +0 -0
  35. package/public/assets/news/1.0/looks.webp +0 -0
  36. package/public/assets/news/1.0/modules.webp +0 -0
  37. package/public/assets/news/1.0/mother.webp +0 -0
  38. package/public/assets/news/1.0/nostromo.webp +0 -0
  39. package/public/assets/news/1.0/permissions.webp +0 -0
  40. package/public/assets/news/1.0/room.webp +0 -0
  41. package/public/assets/news/1.0/settings.webp +0 -0
  42. package/public/assets/wallpapers/biznaga.webp +0 -0
  43. package/public/assets/wallpapers/canela.webp +0 -0
  44. package/public/assets/wallpapers/jarrito.webp +0 -0
  45. package/public/assets/wallpapers/magna.webp +0 -0
  46. package/public/assets/wallpapers/oceano.webp +0 -0
  47. package/public/assets/wallpapers/paraiso.webp +0 -0
  48. package/public/assets/wallpapers/quila.webp +0 -0
  49. package/public/assets/wallpapers/tradicion.webp +0 -0
  50. package/public/assets/wallpapers/tropical.webp +0 -0
  51. package/public/assets/wallpapers/urbania.webp +0 -0
  52. package/public/assets/wallpapers/ventura.webp +0 -0
  53. package/public/assets/wallpapers/xico.webp +0 -0
  54. package/public/brands.js +28 -2
  55. package/public/editor.js +744 -0
  56. package/public/es.js +3443 -1425
  57. package/public/i18n.js +1 -1
  58. package/public/index.html +201 -175
  59. package/public/inquiry.js +11 -11
  60. package/public/loader.js +79 -0
  61. package/public/macroverso.js +1517 -0
  62. package/public/manifest.webmanifest +14 -0
  63. package/public/nostromo-engine.js +2222 -0
  64. package/public/resay.js +24 -0
  65. package/public/review-ask.js +85 -0
  66. package/public/salas-mac.css +1281 -0
  67. package/public/salas-mac.html +158 -0
  68. package/public/salas-mac.js +4593 -0
  69. package/public/salas.css +85 -0
  70. package/public/salas.html +47 -0
  71. package/public/salas.js +200 -0
  72. package/public/styles.css +6372 -426
  73. package/public/touch.js +83 -0
  74. package/public/tour-chapters.js +452 -0
  75. package/public/tour.js +546 -0
  76. package/public/troubleshooting.js +160 -141
  77. package/src/adapters/codex.mjs +46 -6
  78. package/src/adapters/gemini.mjs +30 -10
  79. package/src/adapters/madre.mjs +110 -18
  80. package/src/adapters/process.mjs +42 -2
  81. package/src/auth-probe.mjs +31 -8
  82. package/src/bridge/devices.mjs +202 -0
  83. package/src/bridge/handoff.mjs +45 -0
  84. package/src/bridge/host.mjs +82 -0
  85. package/src/bridge/network.mjs +133 -0
  86. package/src/bridge/policy.mjs +89 -0
  87. package/src/bridge/qr.mjs +287 -0
  88. package/src/bridge/server.mjs +269 -0
  89. package/src/capabilities.mjs +5 -9
  90. package/src/chats.mjs +59 -22
  91. package/src/checkpoint.mjs +137 -35
  92. package/src/choices.mjs +18 -3
  93. package/src/cli-args.mjs +12 -1
  94. package/src/commands.mjs +15 -15
  95. package/src/config.mjs +73 -9
  96. package/src/conversation-context.mjs +5 -2
  97. package/src/credentials.mjs +80 -0
  98. package/src/dataset.mjs +3 -1
  99. package/src/directives.mjs +6 -11
  100. package/src/enlaces.mjs +451 -0
  101. package/src/event-store.mjs +55 -10
  102. package/src/exam.mjs +5 -5
  103. package/src/extensions.mjs +31 -12
  104. package/src/file-ops.mjs +23 -0
  105. package/src/files.mjs +52 -2
  106. package/src/github.mjs +407 -0
  107. package/src/heartbeat.mjs +55 -0
  108. package/src/json-file.mjs +63 -0
  109. package/src/lease.mjs +2 -2
  110. package/src/lobby-work.mjs +103 -0
  111. package/src/local-ai.mjs +48 -0
  112. package/src/looks.mjs +88 -0
  113. package/src/maturity.mjs +3 -3
  114. package/src/mcp/github-server.mjs +164 -0
  115. package/src/mcp/image-server.mjs +1 -1
  116. package/src/mcp/memory-server.mjs +11 -3
  117. package/src/memory.mjs +38 -12
  118. package/src/models.mjs +127 -5
  119. package/src/modules/ahp.mjs +4 -4
  120. package/src/modules/ash.mjs +1 -1
  121. package/src/modules/bishop-plan.mjs +207 -0
  122. package/src/modules/bishop.mjs +645 -0
  123. package/src/modules/choices.mjs +2 -2
  124. package/src/modules/correo.mjs +1 -1
  125. package/src/modules/enlaces.mjs +255 -0
  126. package/src/modules/git-pulse.mjs +2 -2
  127. package/src/modules/github.mjs +322 -0
  128. package/src/modules/image-studio.mjs +3 -3
  129. package/src/modules/index.mjs +18 -4
  130. package/src/modules/intents-calibration.mjs +338 -0
  131. package/src/modules/intents-desk.mjs +481 -0
  132. package/src/modules/intents-observe.mjs +266 -0
  133. package/src/modules/intents-policy.mjs +414 -0
  134. package/src/modules/intents-try.mjs +91 -0
  135. package/src/modules/intents.mjs +145 -0
  136. package/src/modules/ollama.mjs +18 -6
  137. package/src/modules/playwright.mjs +121 -11
  138. package/src/modules/ripley.mjs +1 -1
  139. package/src/modules/runs.mjs +2 -2
  140. package/src/modules/sdk.mjs +10 -4
  141. package/src/ollama.mjs +6 -1
  142. package/src/outbound.mjs +36 -17
  143. package/src/override.mjs +100 -0
  144. package/src/ports.mjs +23 -1
  145. package/src/prices.mjs +72 -0
  146. package/src/privacy.mjs +30 -11
  147. package/src/quota-sources.mjs +0 -8
  148. package/src/request-guard.mjs +1 -1
  149. package/src/review-ask.mjs +111 -0
  150. package/src/room/archivist.mjs +55 -37
  151. package/src/room/budget.mjs +17 -5
  152. package/src/room/control.mjs +70 -7
  153. package/src/room/economy.mjs +35 -7
  154. package/src/room/escalation.mjs +3 -0
  155. package/src/room/guard.mjs +26 -2
  156. package/src/room/prompt.mjs +22 -15
  157. package/src/room/runs.mjs +84 -21
  158. package/src/room/vectors.mjs +19 -4
  159. package/src/room.mjs +411 -84
  160. package/src/router.mjs +3 -1
  161. package/src/runs.mjs +23 -5
  162. package/src/salas.mjs +313 -0
  163. package/src/sentinel-errors.mjs +1 -1
  164. package/src/server-sentinel.mjs +124 -0
  165. package/src/server.mjs +1864 -178
  166. package/src/setup.mjs +1 -0
  167. package/src/tour.mjs +174 -0
  168. package/src/updates.mjs +48 -8
  169. package/src/usage-sentinel.mjs +1 -1
  170. package/src/verdict.mjs +7 -7
  171. package/src/whats-new.mjs +160 -0
  172. package/docs/madre-banner.svg +0 -25
@@ -0,0 +1,55 @@
1
+ // «Still in use»: once a day, every copy of MADRE tells madre.run it is on (2026-10-13).
2
+ //
3
+ // What goes, and nothing else: a random identifier this copy made for itself once
4
+ // (~/.pulse/install-id, from crypto.randomUUID — derived from nothing about the person or the
5
+ // machine), the MADRE version, the system (darwin, linux, win32) and Node's major version. No
6
+ // name, no email, no project, no path, no agent, nothing said in a room. madre.run keeps no IP
7
+ // (functions/api/ping.js of the landing). It is how the author knows how many people use MADRE,
8
+ // which versions they run and whether they come back — what npm's download count cannot say.
9
+ //
10
+ // It is said in docs/REFERENCE.md › «Lo que sale de la máquina» and on madre.run, and it goes
11
+ // through the same fetch every other outbound request does, so the room's own list of what left
12
+ // the machine shows it. Offline or behind a firewall it fails in silence and is tried again
13
+ // later: MADRE never waits on it. PULSE_HEARTBEAT_URL points it elsewhere, for development and
14
+ // for the test suite, which must never reach madre.run.
15
+
16
+ import { randomUUID } from 'node:crypto';
17
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+
20
+ export const HEARTBEAT_URL = 'https://madre.run/api/ping';
21
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
22
+
23
+ export async function installId(root) {
24
+ const file = join(root, 'install-id');
25
+ const kept = (await readFile(file, 'utf8').catch(() => '')).trim();
26
+ if (ID.test(kept)) return kept;
27
+ const made = randomUUID();
28
+ await mkdir(root, { recursive: true });
29
+ await writeFile(file, `${made}\n`, { mode: 0o600 });
30
+ return made;
31
+ }
32
+
33
+ // What the request carries: built here, in one place, so a test can say exactly what leaves.
34
+ export const heartbeatBody = ({ id, version, platform = process.platform, node = process.versions.node }) => ({
35
+ id, version, os: ['darwin', 'linux', 'win32'].includes(platform) ? platform : 'other', node: String(node).split('.')[0],
36
+ });
37
+
38
+ // Once per UTC day per copy, whichever of its processes gets there first; a day that failed is
39
+ // tried again on the next call.
40
+ export async function heartbeat({ root, version, fetchImpl = globalThis.fetch, url = process.env.PULSE_HEARTBEAT_URL ?? HEARTBEAT_URL, now = new Date() }) {
41
+ const day = now.toISOString().slice(0, 10);
42
+ const stamp = join(root, 'heartbeat.json');
43
+ try {
44
+ if (JSON.parse(await readFile(stamp, 'utf8')).day === day) return { sent: false, reason: 'today' };
45
+ } catch { /* never sent, or unreadable: send */ }
46
+ try {
47
+ const body = heartbeatBody({ id: await installId(root), version });
48
+ const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000) });
49
+ if (!response.ok) return { sent: false, reason: `HTTP ${response.status}` };
50
+ await writeFile(stamp, `${JSON.stringify({ day })}\n`);
51
+ return { sent: true, body };
52
+ } catch (error) {
53
+ return { sent: false, reason: error?.message ?? 'unreachable' };
54
+ }
55
+ }
@@ -0,0 +1,63 @@
1
+ // Small files that more than one thing changes at once (2026-10-12, Lote 2 of the 1.0 review): the
2
+ // tour, the review ask, a project's record of where it is, the list of a room's conversations.
3
+ // Each was read, changed and written with nothing between two changes, so the second one wrote
4
+ // over the first — a rename lost to a message arriving, a chapter read lost to a tab saying it
5
+ // held the tour — and the drafts had a name per process, so two writes from the same room fought
6
+ // over one draft and one of them failed.
7
+ //
8
+ // `writeWhole` writes a file whole: a draft with a name of its own, renamed over it, so nobody
9
+ // ever reads half of one. `oneAtATime` runs one change of a file at a time: in this process by a
10
+ // queue, and between the rooms of this Mac by a lock file beside it, as config.json is kept
11
+ // (src/config.mjs). A lock left by a process that died is stale after a few seconds.
12
+
13
+ import { randomUUID } from 'node:crypto';
14
+ import { mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises';
15
+ import { dirname } from 'node:path';
16
+
17
+ export async function writeWhole(file, text, { mode } = {}) {
18
+ await mkdir(dirname(file), { recursive: true });
19
+ const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
20
+ try {
21
+ await writeFile(draft, text, mode ? { mode } : undefined);
22
+ await rename(draft, file);
23
+ } catch (error) {
24
+ await rm(draft, { force: true }).catch(() => {});
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ const STALE_MS = 5000;
30
+ async function lockBeside(file) {
31
+ const lock = `${file}.lock`;
32
+ await mkdir(dirname(lock), { recursive: true });
33
+ for (let attempt = 0; attempt < 250; attempt += 1) {
34
+ try {
35
+ await (await open(lock, 'wx')).close();
36
+ return () => rm(lock, { force: true });
37
+ } catch (error) {
38
+ if (error.code !== 'EEXIST') throw error;
39
+ const since = await stat(lock).catch(() => null);
40
+ if (since && Date.now() - since.mtimeMs > STALE_MS) { await rm(lock, { force: true }); continue; }
41
+ await new Promise((resolve) => setTimeout(resolve, 20));
42
+ }
43
+ }
44
+ throw new Error(`${file} is being changed by another room; try again.`);
45
+ }
46
+
47
+ const queues = new Map();
48
+ export function oneAtATime(file, work) {
49
+ const run = async () => {
50
+ const release = await lockBeside(file);
51
+ try {
52
+ return await work();
53
+ } finally {
54
+ await release();
55
+ }
56
+ };
57
+ const before = queues.get(file) ?? Promise.resolve();
58
+ const turn = before.then(run, run);
59
+ const settled = turn.catch(() => {});
60
+ queues.set(file, settled);
61
+ void settled.then(() => { if (queues.get(file) === settled) queues.delete(file); });
62
+ return turn;
63
+ }
package/src/lease.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
- import { contentTypeFor } from './files.mjs';
3
+ import { declaredTypeFor } from './files.mjs';
4
4
 
5
5
  // A creation lease is the human saying, for one message (and the plan it may
6
6
  // start): "these agents may create files, here". MADRE makes a fresh
@@ -90,7 +90,7 @@ export function diffSnapshots(before, after, { relativeDir }) {
90
90
  name: name.split('/').pop(),
91
91
  path: `${relativeDir.split(sep).join('/')}/${name}`,
92
92
  size: info.size,
93
- contentType: contentTypeFor(name),
93
+ contentType: declaredTypeFor(name),
94
94
  status: previous ? 'changed' : 'created',
95
95
  });
96
96
  }
@@ -0,0 +1,103 @@
1
+ // What was worked on last on this Mac, for the lobby (2026-10-10): the newest conversations of
2
+ // every room the lobby knows, open or asleep, each with its last few lines, and the summary MADRE
3
+ // writes of them with its local model. Everything here READS: a room's list of conversations
4
+ // (chats.json) and the end of a conversation's ledger, from the room's folder, never asking the
5
+ // room and never writing to it. The summary is checked against what it was given: a line that does
6
+ // not carry the number of a conversation it was given is dropped, and the project of each line is
7
+ // put by the lobby, from that number, never taken from the model's words.
8
+
9
+ import { open } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { chatLedger, listChats } from './chats.mjs';
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+
15
+ // The last messages of one conversation, read from the end of its ledger without reading all of
16
+ // it (a ledger also carries quota readings and turns, and grows for months). A cut first line is
17
+ // dropped; a line that is not a message is passed over; delegations between agents are the
18
+ // plan's own bookkeeping, not what was said.
19
+ export async function lastMessages(file, { limit = 4, window = 512 * 1024, maxChars = 360 } = {}) {
20
+ let handle = null;
21
+ try {
22
+ handle = await open(file, 'r');
23
+ const { size } = await handle.stat();
24
+ const start = Math.max(0, size - window);
25
+ const buffer = Buffer.alloc(size - start);
26
+ await handle.read(buffer, 0, buffer.length, start);
27
+ const lines = buffer.toString('utf8').split('\n');
28
+ if (start > 0) lines.shift();
29
+ const said = [];
30
+ for (let index = lines.length - 1; index >= 0 && said.length < limit; index -= 1) {
31
+ if (!lines[index].includes('"message.created"')) continue;
32
+ let event = null;
33
+ try { event = JSON.parse(lines[index]); } catch { continue; }
34
+ const payload = event?.payload ?? {};
35
+ if (event?.type !== 'message.created' || payload.status === 'delegated') continue;
36
+ // Read as words: without the marks a message is written in (bold, code, headings, links).
37
+ const text = String(payload.text ?? '').replace(/```[\s\S]*?```/g, ' ').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/(\*\*|__|`)/g, '').replace(/^#+\s*/gm, '').replace(/\s+/g, ' ').trim();
38
+ if (!text) continue;
39
+ const person = payload.role === 'user' || payload.sender === 'human';
40
+ said.unshift({ who: person ? 'person' : String(payload.sender ?? 'agent'), text: text.slice(0, maxChars), at: event.timestamp ?? null });
41
+ }
42
+ return said;
43
+ } catch {
44
+ return [];
45
+ } finally {
46
+ await handle?.close().catch(() => {});
47
+ }
48
+ }
49
+
50
+ // The newest conversations across the rooms, at most `perRoom` from each and `limit` in all, from
51
+ // the last `days` days; each with its last lines. `rooms` is [{ id, name }] as the lobby names them.
52
+ export async function recentWork({ stateRoot, rooms, now = Date.now(), days = 14, limit = 6, perRoom = 2 }) {
53
+ const since = now - days * DAY_MS;
54
+ const found = [];
55
+ for (const room of rooms) {
56
+ const dir = join(stateRoot, 'rooms', room.id);
57
+ const { chats } = await listChats(dir).catch(() => ({ chats: [] }));
58
+ const fresh = chats.filter((chat) => chat.messages > 0 && Date.parse(chat.updatedAt ?? 0) >= since).slice(0, perRoom);
59
+ for (const chat of fresh) found.push({ room: room.id, name: room.name, chat: chat.id, title: chat.title, at: chat.updatedAt, messages: chat.messages });
60
+ }
61
+ found.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
62
+ const newest = found.slice(0, limit);
63
+ for (const item of newest) item.said = await lastMessages(chatLedger(join(stateRoot, 'rooms', item.room), item.chat));
64
+ return newest;
65
+ }
66
+
67
+ // What MADRE is told: who it is writing for, one line per numbered conversation, and that it may
68
+ // only say what that conversation says. It never names the project: the lobby puts the name to
69
+ // each line by its number, so a small local model cannot give one project's work to another.
70
+ export const WORK_SYSTEM = {
71
+ es: 'Eres MADRE. Resumes para la persona lo último que se trabajó. Recibes conversaciones numeradas (#1, #2…). Para cada una escribe UNA línea con el formato `#número: frase`, en el mismo orden. La frase, corta y en pasado, dice qué se hizo o se decidió en ESA conversación y, si lo hay, qué quedó pendiente; si no queda claro, di de qué trata. Usa solo lo que dice esa conversación: no mezcles conversaciones ni inventes nombres, cifras o conclusiones. No escribas nada más.',
72
+ en: 'You are MADRE. You summarise for the person what was worked on last. You get numbered conversations (#1, #2…). For each one write ONE line in the form `#number: sentence`, in the same order. The sentence, short and in the past tense, says what was done or decided in THAT conversation and, if there is one, what was left pending; if it is not clear, say what it is about. Use only what that conversation says: do not mix conversations or invent names, figures or conclusions. Write nothing else.',
73
+ };
74
+ export function workPrompt(items, language = 'es') {
75
+ const person = language === 'es' ? 'Persona' : 'Person';
76
+ return items.map((item, index) => {
77
+ const lines = (item.said ?? []).map((one) => ` ${one.who === 'person' ? person : `@${one.who}`}: ${one.text}`);
78
+ return [`#${index + 1} · «${item.title}»`, ...lines].join('\n');
79
+ }).join('\n\n');
80
+ }
81
+
82
+ // The answer, as lines the lobby can show: each must carry the number of a conversation it was
83
+ // given, once; the project is that conversation's, never a word of the model's.
84
+ export function parseWork(text, items, { max = 5 } = {}) {
85
+ const seen = new Set();
86
+ const bullets = [];
87
+ for (const raw of String(text ?? '').split('\n')) {
88
+ const line = raw.replace(/\*\*/g, '').replace(/^\s*(?:[-*•·]\s*)?/, '').trim();
89
+ const match = /^#?\s*(\d{1,2})\s*[:.)\-–—]\s*(.+)$/.exec(line);
90
+ if (!match) continue;
91
+ const index = Number(match[1]) - 1;
92
+ const item = items[index];
93
+ if (!item || seen.has(index)) continue;
94
+ const said = match[2].replace(/^[«"“]|[»"”]$/g, '').trim();
95
+ // A line that only says the conversation's name again says nothing about the work.
96
+ const bare = (words) => String(words ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
97
+ if (said.length < 8 || bare(said) === bare(item.title)) continue;
98
+ seen.add(index);
99
+ bullets.push({ room: item.room, name: item.name, text: said.slice(0, 280) });
100
+ if (bullets.length >= max) break;
101
+ }
102
+ return bullets;
103
+ }
@@ -0,0 +1,48 @@
1
+ // Which projects use the local AI (Ollama), decided from Home (2026-10-13).
2
+ //
3
+ // Ollama is one per computer and its switches live in ~/.pulse/config.json, which every project
4
+ // shares: `modules.ollama` says whether the local AI is on at all and what it does (search by
5
+ // meaning, summing up the memory, @madre). This adds the one thing that file could not say —
6
+ // which projects — without touching any of that:
7
+ //
8
+ // localAi.mode 'auto' every project uses it, new ones too. Home decides; projects follow.
9
+ // 'pick' only the projects switched on. New ones start without it.
10
+ // 'manual' each project decides in its own Settings. New ones start with it.
11
+ // localAi.rooms { <room id>: true | false } what was switched, from Home or from the project.
12
+ //
13
+ // With nothing written the mode is 'auto', which is what MADRE did before this existed. The
14
+ // master switch (`modules.ollama.enabled`) still wins: off there is off everywhere. Home's own
15
+ // room, the Coordinator's, is not a project and always follows the master switch.
16
+
17
+ export const LOCAL_MODES = ['auto', 'pick', 'manual'];
18
+
19
+ export function localAiConfig(config = {}) {
20
+ const raw = config.localAi ?? {};
21
+ const mode = LOCAL_MODES.includes(raw.mode) ? raw.mode : 'auto';
22
+ const rooms = {};
23
+ for (const [id, on] of Object.entries(raw.rooms ?? {})) if (typeof on === 'boolean') rooms[id] = on;
24
+ return { mode, rooms };
25
+ }
26
+
27
+ // What one project gets. `on`: whether it uses the local AI now; `chosen`: what this project's
28
+ // switch says, before the master switch; `editable`: whether its switch can be moved (not in auto).
29
+ export function localAiFor(config = {}, roomId = null, { home = false } = {}) {
30
+ const master = (config.modules?.ollama?.enabled ?? true) !== false;
31
+ const { mode, rooms } = localAiConfig(config);
32
+ if (home || !roomId) return { mode, master, chosen: true, on: master, editable: false, home: Boolean(home) };
33
+ const chosen = mode === 'auto' ? true : mode === 'pick' ? rooms[roomId] === true : rooms[roomId] ?? true;
34
+ return { mode, master, chosen, on: master && chosen, editable: mode !== 'auto' };
35
+ }
36
+
37
+ // The patch for config.json: the whole `localAi` block, because updateConfig merges one level
38
+ // down and a partial `rooms` would drop the projects it does not name.
39
+ export function localAiPatch(config = {}, { mode, rooms } = {}) {
40
+ const now = localAiConfig(config);
41
+ const next = { mode: LOCAL_MODES.includes(mode) ? mode : now.mode, rooms: { ...now.rooms } };
42
+ for (const [id, on] of Object.entries(rooms ?? {})) {
43
+ if (typeof id !== 'string' || !id || id.length > 200) continue;
44
+ if (typeof on === 'boolean') next.rooms[id] = on;
45
+ else if (on === null) delete next.rooms[id];
46
+ }
47
+ return { localAi: next };
48
+ }
package/src/looks.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // How the person wants MADRE to look, once for every page (2026-10-12). Each project is its own
2
+ // origin — another port — and Home is one more, so what a browser keeps for one is invisible to
3
+ // the others: the dark theme chosen in Home left every project light. The choices live here, in
4
+ // ~/.pulse/looks.json, beside tour.json and whats-new.json; every page reads them when it opens or
5
+ // comes back into view, and writes them when the person changes one. The browser's own copy stays,
6
+ // so a page paints in the last known look before it hears from here.
7
+ //
8
+ // Only what the pages share: the theme, the accent, the wallpaper and its dials, the bubbles and
9
+ // the text of the conversation. How Home lays out its own cards stays in Home's browser.
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { oneAtATime, writeWhole } from './json-file.mjs';
14
+
15
+ const CHOICE = /^[a-z][a-z0-9-]{0,23}$/;
16
+ const NUMBER = /^\d{1,3}$/;
17
+ export const LOOK_KEYS = {
18
+ 'pulse.theme': CHOICE,
19
+ 'pulse.accent': CHOICE,
20
+ 'pulse.wallpaper': CHOICE,
21
+ 'pulse.wallpaper.style': CHOICE,
22
+ 'pulse.wallpaper.veil': NUMBER,
23
+ 'pulse.wallpaper.blur': NUMBER,
24
+ 'pulse.bubbles': CHOICE,
25
+ 'pulse.text': CHOICE,
26
+ };
27
+ const looksFile = (stateRoot) => join(stateRoot, 'looks.json');
28
+
29
+ // A project may keep a wallpaper of its own (2026-10-13): the person said «only in this project».
30
+ // It lives beside the shared looks, under `rooms`, by the room's id; every other look stays shared.
31
+ const ROOM_ID = /^[\w .-]{1,120}$/;
32
+ const readFileLooks = (stateRoot) => readFile(looksFile(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
33
+ function roomsOf(raw) {
34
+ const rooms = {};
35
+ for (const [id, own] of Object.entries(raw?.rooms ?? {})) {
36
+ if (ROOM_ID.test(id) && typeof own?.wallpaper === 'string' && CHOICE.test(own.wallpaper)) rooms[id] = { wallpaper: own.wallpaper };
37
+ }
38
+ return rooms;
39
+ }
40
+
41
+ // What is kept, read key by key: anything else in the file, or a value of the wrong shape, is not
42
+ // a look. `null` for a key means the first value — the room as it always was. With `room`, that
43
+ // project's own wallpaper comes back too, as `own`.
44
+ export async function readLooks(stateRoot, { room = null } = {}) {
45
+ const raw = await readFileLooks(stateRoot);
46
+ const looks = {};
47
+ for (const [key, shape] of Object.entries(LOOK_KEYS)) {
48
+ const value = raw?.looks?.[key];
49
+ looks[key] = typeof value === 'string' && shape.test(value) ? value : null;
50
+ }
51
+ const answer = { looks, saved: Boolean(raw && typeof raw.looks === 'object') };
52
+ if (room) answer.own = roomsOf(raw)[room] ?? null;
53
+ return answer;
54
+ }
55
+ // Every project's own wallpaper, for Home's cards.
56
+ export async function roomLooks(stateRoot) {
57
+ return roomsOf(await readFileLooks(stateRoot));
58
+ }
59
+ // A project's own wallpaper set, or taken away (`null`): it follows the shared one again.
60
+ export function changeRoomLook(stateRoot, room, wallpaper) {
61
+ return oneAtATime(looksFile(stateRoot), async () => {
62
+ if (typeof room !== 'string' || !ROOM_ID.test(room)) return { error: 'room' };
63
+ if (wallpaper !== null && (typeof wallpaper !== 'string' || !CHOICE.test(wallpaper))) return { error: 'value' };
64
+ const raw = await readFileLooks(stateRoot);
65
+ const { looks } = await readLooks(stateRoot);
66
+ const rooms = roomsOf(raw);
67
+ if (wallpaper === null) delete rooms[room]; else rooms[room] = { wallpaper };
68
+ await writeWhole(looksFile(stateRoot), `${JSON.stringify({ looks, ...(Object.keys(rooms).length ? { rooms } : {}), at: new Date().toISOString() }, null, 2)}\n`);
69
+ return { own: rooms[room] ?? null };
70
+ });
71
+ }
72
+
73
+ // A change from a page: the keys it names, set or cleared. A key it does not know, or a value of
74
+ // the wrong shape, refuses the whole change rather than half of it.
75
+ export function changeLooks(stateRoot, patch, now = new Date()) {
76
+ return oneAtATime(looksFile(stateRoot), async () => {
77
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { error: 'looks' };
78
+ for (const [key, value] of Object.entries(patch)) {
79
+ if (!Object.hasOwn(LOOK_KEYS, key)) return { error: 'key' };
80
+ if (value !== null && (typeof value !== 'string' || !LOOK_KEYS[key].test(value))) return { error: 'value' };
81
+ }
82
+ const { looks } = await readLooks(stateRoot);
83
+ Object.assign(looks, patch);
84
+ const rooms = roomsOf(await readFileLooks(stateRoot));
85
+ await writeWhole(looksFile(stateRoot), `${JSON.stringify({ looks, ...(Object.keys(rooms).length ? { rooms } : {}), at: new Date(now).toISOString() }, null, 2)}\n`);
86
+ return { looks, saved: true };
87
+ });
88
+ }
package/src/maturity.mjs CHANGED
@@ -36,13 +36,13 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
36
36
  id: 'volume', label: t('HOW MUCH THERE IS'),
37
37
  value: round(share(pairs, VOLUME_TARGET)),
38
38
  detail: t('{pairs} of about {target} exchanges worth training on', { pairs, target: VOLUME_TARGET }),
39
- next: t('Use the room. Nothing else fills this.'),
39
+ next: t('Use the project. Nothing else fills this.'),
40
40
  },
41
41
  {
42
42
  id: 'coverage', label: t('HOW MUCH OF IT GETS USED'),
43
43
  value: round(share(recalled, standing.length)),
44
44
  detail: t('{recalled} of {total} memories have been reached for at least once', { recalled, total: standing.length }),
45
- next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the room about older decisions and see which ones answer.'),
45
+ next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the project about older decisions and see which ones answer.'),
46
46
  },
47
47
  {
48
48
  id: 'weave', label: t('HOW WOVEN IT IS'),
@@ -64,7 +64,7 @@ export function maturity({ readiness = null, notes = [], links = [], stats = nul
64
64
  // progress where there is none.
65
65
  value: pairs > 0 ? round(1 - Math.min(1, Math.max(0, (noteShare - 0.5) / 0.5))) : 0,
66
66
  detail: t('{share}% of the corpus is recall questions, {turns} exchanges are real work', { share: Math.round(noteShare * 100), turns }),
67
- next: t('Recall pairs are made from notes and cost nothing, so they pile up. Work in the room to balance them.'),
67
+ next: t('Recall pairs are made from notes and cost nothing, so they pile up. Work in the project to balance them.'),
68
68
  },
69
69
  {
70
70
  id: 'upkeep', label: t('HOW CURRENT IT IS'),
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // MADRE GITHUB: a small MCP server (stdio, JSON-RPC 2.0) that lets an agent read the GitHub side
3
+ // of this project — pull requests, issues, checks — and, in #4 AIRLOCK only, open a pull request,
4
+ // open an issue or leave a comment. There is no MADRE in the middle: this process asks gh, or
5
+ // GitHub's API with the human's token, and nothing else.
6
+ //
7
+ // Reading comes back; writing does not. A comment carries the human's name the moment it lands,
8
+ // so three things hold, each somewhere a prompt cannot reach:
9
+ //
10
+ // · The room hands the write tools to a turn only at #4 AIRLOCK (`sends` in the module, enforced
11
+ // in src/modules/index.mjs). This server checks again: unless PULSE_GITHUB_WRITES=1 it does
12
+ // not list them and refuses to run them.
13
+ // · The credential is gh's own session, which this process never sees, or a token in its
14
+ // environment. Neither is written anywhere: not in a result, not in the outbound log.
15
+ // · The repository is the one the module found in the project's remotes. A tool cannot be
16
+ // pointed at another one.
17
+ //
18
+ // Environment:
19
+ // PULSE_GITHUB_REPO required, owner/repo
20
+ // PULSE_GITHUB_ROOT the project, to read the current branch from git
21
+ // PULSE_GITHUB_AUTH 'gh' or 'token'
22
+ // PULSE_GITHUB_GH the gh executable, with PULSE_GITHUB_AUTH=gh
23
+ // PULSE_GITHUB_TOKEN the token, with PULSE_GITHUB_AUTH=token (from MADRE's vault)
24
+ // PULSE_GITHUB_WRITES '1' only in a #4 AIRLOCK turn
25
+ // PULSE_OUTBOUND_LOG where each request leaves its line
26
+
27
+ import { realpath } from 'node:fs/promises';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { OutboundLog, declareDestinations } from '../outbound.mjs';
30
+ import * as github from '../github.mjs';
31
+
32
+ const SERVER_NAME = 'pulse-github';
33
+ const SERVER_VERSION = '1.0.0';
34
+ const SELF = fileURLToPath(import.meta.url);
35
+
36
+ export const READ_TOOLS = ['github_list_prs', 'github_get_pr', 'github_ci_status', 'github_list_issues', 'github_get_issue'];
37
+ export const WRITE_TOOLS = ['github_create_pr', 'github_comment', 'github_create_issue'];
38
+
39
+ const n = { type: 'integer', minimum: 1 };
40
+ export const TOOLS = [
41
+ { name: 'github_list_prs', description: 'List the open pull requests of this project\'s GitHub repository: number, title, author, branch, base, draft.', inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many, newest first. Default 20.' } } } },
42
+ { name: 'github_get_pr', description: 'One pull request: title, state, branch and base, whether it can merge, additions/deletions, changed files, and the review state.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The pull request number.' } }, required: ['number'] } },
43
+ { name: 'github_ci_status', description: 'The checks and CI of a branch: one overall state (success, failure, pending, none), each check, and the latest workflow runs. Defaults to the branch the project is on.', inputSchema: { type: 'object', properties: { ref: { type: 'string', description: 'A branch, tag or commit. Default: the current branch.' } } } },
44
+ { name: 'github_list_issues', description: 'List the open issues (not pull requests): number, title, author, labels, comment count.', inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many, newest first. Default 20.' } } } },
45
+ { name: 'github_get_issue', description: 'One issue: title, state, author, labels and its text.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The issue number.' } }, required: ['number'] } },
46
+ { name: 'github_create_pr', description: 'Open a pull request on GitHub under the human\'s name. It cannot be undone from here: before calling it, state in one line its title, from which branch and into which. The branch must already be pushed.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string', description: 'The description, Markdown.' }, head: { type: 'string', description: 'The branch to merge from. Default: the current branch.' }, base: { type: 'string', description: 'The branch to merge into. Default: the repository\'s default branch.' } }, required: ['title'] } },
47
+ { name: 'github_comment', description: 'Post a comment on a pull request or issue under the human\'s name. It cannot be undone from here: before calling it, state in one line where it goes and what it says.', inputSchema: { type: 'object', properties: { number: { ...n, description: 'The pull request or issue number.' }, body: { type: 'string', description: 'The comment, Markdown.' } }, required: ['number', 'body'] } },
48
+ { name: 'github_create_issue', description: 'Open an issue on GitHub under the human\'s name. It cannot be undone from here: before calling it, state in one line its title and what it says.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string', description: 'The text, Markdown.' } }, required: ['title'] } },
49
+ ];
50
+
51
+ // What this process was handed, as the client wants it. The token is read here and only here.
52
+ export function contextFrom(env = process.env) {
53
+ const [owner, repo] = String(env.PULSE_GITHUB_REPO ?? '').split('/');
54
+ const auth = env.PULSE_GITHUB_AUTH === 'gh' && env.PULSE_GITHUB_GH ? { kind: 'gh', gh: env.PULSE_GITHUB_GH }
55
+ : env.PULSE_GITHUB_AUTH === 'token' && env.PULSE_GITHUB_TOKEN ? { kind: 'token', token: env.PULSE_GITHUB_TOKEN }
56
+ : null;
57
+ // gh is handed an environment without the token variable it never needed.
58
+ const { PULSE_GITHUB_TOKEN: _drop, ...clean } = env;
59
+ return { repo: owner && repo ? { owner, repo, slug: `${owner}/${repo}` } : null, root: env.PULSE_GITHUB_ROOT || null, auth, writes: env.PULSE_GITHUB_WRITES === '1', env: clean };
60
+ }
61
+
62
+ let outbound = null;
63
+ function logFor(env) {
64
+ if (!env.PULSE_OUTBOUND_LOG) return null;
65
+ // This process has its own copy of the declarations; without this, its lines would read as an
66
+ // address nothing here declares.
67
+ declareDestinations('github', github.REACHES);
68
+ outbound ??= new OutboundLog({ file: env.PULSE_OUTBOUND_LOG });
69
+ return outbound;
70
+ }
71
+
72
+ export async function callTool(name, args = {}, { env = process.env, api = github } = {}) {
73
+ const here = contextFrom(env);
74
+ if (!here.repo) throw new github.GithubError('NO_REPO', 'No repository was handed to this server.');
75
+ if (!here.auth) throw new github.GithubError('NO_CREDENTIAL', github.ERRORS.NO_SESSION);
76
+ if (WRITE_TOOLS.includes(name) && !here.writes) throw new github.GithubError('NOT_AIRLOCK', 'Writing to GitHub needs a turn in #4 AIRLOCK. Nothing was posted.');
77
+ const common = { repo: here.repo, auth: here.auth, log: logFor(env), env: here.env };
78
+ const branch = async () => (here.root ? api.currentBranch(here.root, { env: here.env }) : null);
79
+ switch (name) {
80
+ case 'github_list_prs': return { repo: here.repo.slug, pulls: await api.listPulls({ ...common, limit: args.limit }) };
81
+ case 'github_get_pr': return await api.getPull({ ...common, number: args.number });
82
+ case 'github_ci_status': {
83
+ const ref = String(args.ref ?? '').trim() || await branch();
84
+ const [ci, runs] = await Promise.all([api.ciStatus({ ...common, ref }), api.workflowRuns({ ...common, branch: ref, limit: 5 }).catch(() => [])]);
85
+ return { ...ci, runs };
86
+ }
87
+ case 'github_list_issues': return { repo: here.repo.slug, issues: await api.listIssues({ ...common, limit: args.limit }) };
88
+ case 'github_get_issue': return await api.getIssue({ ...common, number: args.number });
89
+ case 'github_create_pr': {
90
+ const head = String(args.head ?? '').trim() || await branch();
91
+ return await api.createPull({ ...common, title: args.title, body: args.body ?? '', head, base: String(args.base ?? '').trim() || null });
92
+ }
93
+ case 'github_comment': return await api.comment({ ...common, number: args.number, body: args.body });
94
+ case 'github_create_issue': return await api.createIssue({ ...common, title: args.title, body: args.body ?? '' });
95
+ default: throw new github.GithubError('UNKNOWN_TOOL', `Unknown tool: ${name}`);
96
+ }
97
+ }
98
+
99
+ export function handleRequest(message, { env = process.env, api = github } = {}) {
100
+ const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
101
+ const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
102
+ const writes = env.PULSE_GITHUB_WRITES === '1';
103
+ switch (message.method) {
104
+ case 'initialize':
105
+ return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
106
+ case 'notifications/initialized':
107
+ case 'initialized':
108
+ return Promise.resolve(null);
109
+ case 'ping':
110
+ return Promise.resolve(reply({}));
111
+ case 'tools/list':
112
+ // Below #4 the write tools are not merely refused: they are not here.
113
+ return Promise.resolve(reply({ tools: TOOLS.filter((tool) => writes || !WRITE_TOOLS.includes(tool.name)) }));
114
+ case 'tools/call': {
115
+ const { name, arguments: args = {} } = message.params ?? {};
116
+ if (!TOOLS.some((tool) => tool.name === name)) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
117
+ const writing = WRITE_TOOLS.includes(name);
118
+ return callTool(name, args, { env, api })
119
+ .then((result) => reply({ content: [{ type: 'text', text: writing ? `Posted to GitHub: ${JSON.stringify(result)}` : JSON.stringify(result) }], isError: false }))
120
+ .catch((error) => {
121
+ const detail = error.detail ? ` GitHub said: ${error.detail}` : '';
122
+ const lead = writing ? 'Nothing was posted' : 'GitHub did not answer';
123
+ return reply({ content: [{ type: 'text', text: `${lead} (${error.code ?? 'ERROR'}): ${error.message}${detail}` }], isError: true });
124
+ });
125
+ }
126
+ default:
127
+ return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
128
+ }
129
+ }
130
+
131
+ export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
132
+ let buffer = '';
133
+ let pending = 0;
134
+ let ended = false;
135
+ const finish = () => { if (ended && pending === 0) void (outbound?.drain() ?? Promise.resolve()).finally(() => process.exit(0)); };
136
+ input.setEncoding('utf8');
137
+ input.on('data', (chunk) => {
138
+ buffer += chunk;
139
+ let index;
140
+ while ((index = buffer.indexOf('\n')) >= 0) {
141
+ const line = buffer.slice(0, index).trim();
142
+ buffer = buffer.slice(index + 1);
143
+ if (!line) continue;
144
+ let message;
145
+ try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
146
+ pending += 1;
147
+ void handleRequest(message, { env })
148
+ .then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
149
+ .finally(() => { pending -= 1; finish(); });
150
+ }
151
+ });
152
+ input.on('end', () => { ended = true; finish(); });
153
+ }
154
+
155
+ // Both paths resolved before they are compared: `new URL(import.meta.url).pathname` would be
156
+ // /C:/… on Windows (WIN-004), and a path through a symlink would never match its target.
157
+ const real = async (path) => (path ? realpath(path).catch(() => path) : null);
158
+ if (process.argv[1] && (await real(process.argv[1])) === (await real(SELF))) {
159
+ if (!process.env.PULSE_GITHUB_REPO) {
160
+ process.stderr.write('PULSE_GITHUB_REPO is required.\n');
161
+ process.exit(2);
162
+ }
163
+ serve();
164
+ }
@@ -28,7 +28,7 @@ const FAKE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADU
28
28
 
29
29
  // Where a Gemini key can be, in the order the room should trust them: what this process was
30
30
  // given, then the file the CLI reads on every run — which is also the file MADRE itself writes
31
- // when a key is pasted into CONNECTIONS — and last the macOS keychain, which only exists when
31
+ // when a key is pasted into Settings › Agents — and last the macOS keychain, which only exists when
32
32
  // the CLI's own sign-in put it there. Leaving the file out meant MADRE saved a key in a place it
33
33
  // then refused to look, and on Linux, where there is no keychain, that was the only place it
34
34
  // could have been: Image Studio could never find a key there at all.
@@ -11,9 +11,12 @@
11
11
  // Gemini CLI. Without a key search is lexical only.
12
12
 
13
13
  import { readFile, readdir, stat, realpath } from 'node:fs/promises';
14
+ import { homedir } from 'node:os';
14
15
  import { join } from 'node:path';
15
16
  import { RoomMemory, formatRecall, formatMemories, MEMORY_KINDS } from '../memory.mjs';
16
17
  import { createEmbedder } from '../embeddings.mjs';
18
+ import { loadConfig } from '../config.mjs';
19
+ import { Privacy, privacySettings } from '../privacy.mjs';
17
20
  import { resolveGeminiKey } from './image-server.mjs';
18
21
 
19
22
  const SERVER_NAME = 'pulse-memory';
@@ -71,9 +74,13 @@ export const TOOLS = [
71
74
 
72
75
  function clamp(n, min, max, fallback) { const value = Number(n); return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback; }
73
76
 
74
- export async function openMemory({ dbFile = process.env.PULSE_MEMORY_DB, env = process.env, keyResolver = resolveGeminiKey } = {}) {
77
+ export async function openMemory({ dbFile = process.env.PULSE_MEMORY_DB, env = process.env, keyResolver = resolveGeminiKey, stateRoot = env.PULSE_HOME ?? join(homedir(), '.pulse'), privacy = null } = {}) {
75
78
  if (!dbFile) throw new Error('PULSE_MEMORY_DB is required.');
76
- const memory = await new RoomMemory(dbFile).initialize();
79
+ // A private term never enters a note, whoever writes it (docs/MACROVERSO.md, S-008): the guard
80
+ // the room attaches to its own index, built from the same config.json every room on this Mac
81
+ // shares, so a note an agent saves through memory_note is kept the way the archivist's are.
82
+ const guard = privacy ?? new Privacy({ ...privacySettings(await loadConfig(stateRoot).catch(() => ({})), env), home: homedir() });
83
+ const memory = await new RoomMemory(dbFile).attachPrivacy(guard).initialize();
77
84
  let embedder = null;
78
85
  if (env.PULSE_EMBED !== '0') {
79
86
  const key = env.PULSE_EMBED_FAKE === '1' ? null : await keyResolver(env).catch(() => null);
@@ -115,7 +122,8 @@ export async function callTool(name, args = {}, { memory, projectRoot = process.
115
122
  const added = memory.addMemories([{ kind, text, sources }], { agent, fromSequence: sources.length ? Math.min(...sources) : last, throughSequence: sources.length ? Math.max(...sources) : last, origin: 'noted', messageId });
116
123
  if (!added) return `Already remembered: "${text}"`;
117
124
  const [saved] = memory.notesSince(before, { agent });
118
- return `Saved memory #${saved?.id ?? '?'} (${kind}) for every future turn of this room: "${text}"`;
125
+ // Echoed as it was kept: with a private term already replaced by the marker.
126
+ return `Saved memory #${saved?.id ?? '?'} (${kind}) for every future turn of this room: "${saved?.text ?? text}"`;
119
127
  }
120
128
  case 'memory_search': {
121
129
  const query = String(args.query ?? '').trim();