@jossuealcala/madre 0.3.2 → 0.4.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/CONTRIBUTING.md +6 -1
  3. package/README.md +67 -183
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3880 -849
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +95 -14
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +626 -68
  19. package/public/troubleshooting.js +173 -51
  20. package/src/adapters/claude.mjs +13 -6
  21. package/src/adapters/codex.mjs +17 -13
  22. package/src/adapters/gemini.mjs +27 -16
  23. package/src/adapters/opencode.mjs +16 -12
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/capabilities.mjs +4 -3
  28. package/src/chats.mjs +193 -0
  29. package/src/checkpoint.mjs +1 -1
  30. package/src/cold.mjs +56 -0
  31. package/src/commands.mjs +31 -3
  32. package/src/conversation-context.mjs +35 -3
  33. package/src/credentials.mjs +145 -0
  34. package/src/dataset.mjs +56 -4
  35. package/src/distiller.mjs +12 -5
  36. package/src/event-store.mjs +14 -8
  37. package/src/exam.mjs +240 -0
  38. package/src/extensions.mjs +3 -2
  39. package/src/eyecat-watch.mjs +100 -0
  40. package/src/eyecat.mjs +169 -0
  41. package/src/i18n.mjs +47 -0
  42. package/src/image-studio.mjs +2 -0
  43. package/src/launch.mjs +61 -0
  44. package/src/lease.mjs +5 -3
  45. package/src/maturity.mjs +94 -0
  46. package/src/mcp/image-server.mjs +12 -1
  47. package/src/mcp/memory-server.mjs +1 -1
  48. package/src/memory.mjs +325 -17
  49. package/src/modules/ahp.mjs +9 -7
  50. package/src/modules/ash.mjs +36 -0
  51. package/src/modules/git-pulse.mjs +6 -4
  52. package/src/modules/helpers.mjs +31 -0
  53. package/src/modules/image-studio.mjs +9 -4
  54. package/src/modules/index.mjs +143 -5
  55. package/src/modules/ollama.mjs +66 -10
  56. package/src/modules/playwright.mjs +90 -0
  57. package/src/modules/ripley.mjs +5 -3
  58. package/src/modules/sdk.mjs +104 -2
  59. package/src/modules/updates.mjs +81 -0
  60. package/src/ollama.mjs +5 -2
  61. package/src/outbound.mjs +292 -0
  62. package/src/privacy.mjs +54 -7
  63. package/src/room/context.mjs +4 -4
  64. package/src/room/control.mjs +4 -4
  65. package/src/room/economy.mjs +161 -0
  66. package/src/room/prompt.mjs +118 -43
  67. package/src/room.mjs +465 -58
  68. package/src/runtime-detection.mjs +27 -8
  69. package/src/server.mjs +724 -68
  70. package/src/setup.mjs +1 -1
  71. package/src/updates.mjs +17 -2
  72. package/src/usage-sentinel.mjs +13 -8
  73. package/src/verdict.mjs +74 -0
  74. package/src/ashcode.mjs +0 -64
  75. package/src/modules/ashcode.mjs +0 -28
package/src/server.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import http from 'node:http';
2
- import { readFile, realpath } from 'node:fs/promises';
2
+ import { readFile, realpath, writeFile, mkdir, rm } from 'node:fs/promises';
3
+ import { rmSync } from 'node:fs';
3
4
  import { createHash } from 'node:crypto';
4
5
  import { basename, dirname, join, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { homedir } from 'node:os';
7
8
  import { execFile } from 'node:child_process';
8
- import { detectAgents } from './runtime-detection.mjs';
9
+ import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
9
10
  import { EventStore } from './event-store.mjs';
10
11
  import { RoomMemory } from './memory.mjs';
11
12
  import { createEmbedder } from './embeddings.mjs';
@@ -13,9 +14,11 @@ import { memoryServerFor } from './memory-tools.mjs';
13
14
  import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
14
15
  import { ErrorSentinel } from './sentinel-errors.mjs';
15
16
  import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
16
- import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
17
+ import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
17
18
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
18
19
  import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
20
+ import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
21
+ import { setLanguage as setRoomLanguage, t } from './i18n.mjs';
19
22
 
20
23
  const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
21
24
  let crashHandlersInstalled = false;
@@ -34,22 +37,29 @@ export function injectRipleyBridge(body) {
34
37
  return Buffer.from(RIPLEY_BRIDGE + text);
35
38
  }
36
39
  // The author's collector: SEND and AUTO-REPORT are available out of the box; AUTO-REPORT stays off until the human turns it on.
37
- const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
38
40
  import { QuotaMonitor } from './quota-monitor.mjs';
39
41
  import { defaultQuotaSources } from './quota-sources.mjs';
40
42
  import { Room } from './room.mjs';
41
43
  import { applyConfigToEnv, loadConfig } from './config.mjs';
42
- import { extensionById, runInstaller } from './extensions.mjs';
43
- import { loginPlanFor, probeAll } from './auth-probe.mjs';
44
+ import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
45
+ import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
46
+ import { applyKey, keyPlanFor } from './credentials.mjs';
44
47
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
45
48
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
46
- import { checkForUpdate, detectInstall, updateCommand, releaseUrl } from './updates.mjs';
49
+ import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
50
+ import { moduleUpdate } from './modules/updates.mjs';
51
+ import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat, MAIN_CHAT } from './chats.mjs';
52
+ import { spawn } from 'node:child_process';
47
53
  import { totalmem } from 'node:os';
48
54
  import { discoverModels } from './models.mjs';
49
55
  import { setImageModule } from './capabilities.mjs';
50
56
  import { resolveGeminiKey } from './image-studio.mjs';
51
57
  import { commandByName, listCommands, parseCommand } from './commands.mjs';
52
58
  import { listDirectory, searchFiles, readServable, storeAttachment, MAX_ATTACHMENT_BYTES } from './files.mjs';
59
+ import { Eyecat } from './eyecat-watch.mjs';
60
+ import { economy } from './room/economy.mjs';
61
+ import { maturity } from './maturity.mjs';
62
+ import { verdictFor } from './verdict.mjs';
53
63
 
54
64
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
55
65
  const publicDirectory = join(sourceDirectory, '..', 'public');
@@ -68,7 +78,7 @@ async function body(request) {
68
78
  return JSON.parse(text || '{}');
69
79
  }
70
80
 
71
- function openUrl(url) {
81
+ export function openUrl(url) {
72
82
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
73
83
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
74
84
  execFile(command, args, () => {});
@@ -114,6 +124,11 @@ export async function createPulseServer({
114
124
  // Tests substitute the real installers with local scripts.
115
125
  installers = {},
116
126
  loginRunners = {},
127
+ // Looking again for the CLIs on this computer. A test that hands a fixed roster also hands
128
+ // this, or the room keeps the roster it was given.
129
+ detect = null,
130
+ // Where a pasted provider key is written: each CLI's own place under the human's home.
131
+ credentialHome = homedir(),
117
132
  probe = probeAll,
118
133
  imageKey = resolveGeminiKey,
119
134
  // The sentinel's outbound channel; tests hand in a fake.
@@ -124,14 +139,51 @@ export async function createPulseServer({
124
139
  const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
125
140
  // ~/.pulse/config.json fills in whatever the environment did not set.
126
141
  applyConfigToEnv(await loadConfig(root));
142
+ // What language the screens speak. The prompt the agents read is not one of them, and the
143
+ // environment wins over the file, as it does for every other setting here.
144
+ setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
127
145
  // RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
128
146
  const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
129
147
  agentTimeouts ??= agentTimeoutsFromEnv();
130
- const agents = providedAgents ?? await detectAgents();
148
+ const detector = detect ?? (providedAgents ? null : detectAgents);
149
+ const agents = providedAgents ?? await detector();
131
150
  const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
132
151
  const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
133
- const store = await new EventStore(join(roomDir, 'events.jsonl')).initialize();
134
- const historicalEvents = await store.readAll();
152
+ // What left this machine. Every fetch this process makes — MADRE's own and any a module makes,
153
+ // because a module runs in here and cannot opt out — goes through one wrapper and lands in a
154
+ // line beside the ledger. Bodies, headers and query values are never in it. One room per
155
+ // process holds the watch; a second would only log what the first already logged.
156
+ const outbound = await new OutboundLog({ file: join(roomDir, 'outbound.jsonl') }).load();
157
+ const unwatchOutbound = outbound.install();
158
+ process.env.PULSE_OUTBOUND_LOG = join(roomDir, 'outbound.jsonl');
159
+ reportFetch = outbound.watch(reportFetch);
160
+
161
+ // Attachments live beside the room log, never inside the project, and belong to the project
162
+ // rather than to one conversation.
163
+ const attachmentsRoot = join(roomDir, 'attachments');
164
+ // One server per project, and now it is said out loud. A project numbers its exchanges once
165
+ // across every conversation, so two servers writing different conversations of the same project
166
+ // would hand out the same number twice and the archive would quietly keep one of them. The CLI
167
+ // already looked for a room nearby; a room that was started on a distant port walked past that
168
+ // check. This is the room itself saying it is taken.
169
+ const openMark = join(roomDir, 'open.json');
170
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
171
+ const held = await readFile(openMark, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
172
+ if (held && Number.isInteger(held.pid) && held.pid !== process.pid && alive(held.pid)) {
173
+ const error = new Error(`MADRE is already open for this project at http://127.0.0.1:${held.port} (process ${held.pid}). Close that room first, or open another conversation inside it.`);
174
+ error.code = 'ROOM_IN_USE';
175
+ throw error;
176
+ }
177
+ const markOpen = async (port) => { await mkdir(roomDir, { recursive: true }).catch(() => {}); await writeFile(openMark, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() })).catch(() => {}); };
178
+ const releaseOpen = () => { try { rmSync(openMark, { force: true }); } catch { /* it was already gone */ } };
179
+ // The human's own modules, from ~/.pulse/modules and <project>/.madre/modules.
180
+ await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot }).catch((error) => console.error(`MADRE modules: ${error.message}`));
181
+ // A project has one memory and many conversations; one of them is open at a time, and the
182
+ // numbering is the project's, so a citation means the same thing in all of them.
183
+ await adoptStrays(roomDir).catch(() => null);
184
+ let chatId = (await chatIndex(roomDir)).active;
185
+ let store = await new EventStore(chatLedger(roomDir, chatId), { floor: await projectFloor(roomDir, { except: chatId }) }).initialize();
186
+ let historicalEvents = await store.readAll();
135
187
  // The room's memory: derived from the ledger, rebuilt if missing or stale,
136
188
  // and never a reason for the room not to open.
137
189
  // Release channel: one registry read a day, cached for every room on this machine. On by
@@ -141,13 +193,25 @@ export async function createPulseServer({
141
193
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
142
194
  async function versionView({ force = false } = {}) {
143
195
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
144
- return { ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
196
+ return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
145
197
  }
198
+ // Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
199
+ // happens after the screen is answered, never in front of it: at most once an hour per room,
200
+ // only while the release channel is on, and each check still holds to its own day-long cache.
201
+ let warmedAt = 0;
202
+ async function warmModuleUpdates(items, { now = Date.now() } = {}) {
203
+ if (!updatesEnabled() || now - warmedAt < 60 * 60 * 1000) return;
204
+ warmedAt = now;
205
+ const context = await moduleContext().catch(() => null);
206
+ if (!context) return;
207
+ for (const item of items) await context.services.moduleUpdate(item, { cacheOnly: false }).catch(() => null);
208
+ }
209
+
146
210
  // Privacy: the terms that never travel through this room, from config.json and the environment.
147
211
  // config.json is shared by every room on this machine, so the list is re-read before each
148
212
  // human message and whenever MU/TH/UR looks: a term named in one room guards them all.
149
- const privacy = new Privacy(privacySettings(await readConfig(root)));
150
- const refreshPrivacy = async () => { const { terms, marker } = privacySettings(await readConfig(root)); privacy.set(terms, marker); return privacy; };
213
+ const privacy = new Privacy({ ...privacySettings(await readConfig(root)), home: homedir() });
214
+ const refreshPrivacy = async () => { const { terms, marker, secrets, paths } = privacySettings(await readConfig(root)); privacy.set(terms, marker).setGuards({ secrets, paths }); return privacy; };
151
215
  const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
152
216
  .catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
153
217
  // Meaning-aware recall through the user's own Gemini key, when there is one;
@@ -187,11 +251,24 @@ export async function createPulseServer({
187
251
  if (room) {
188
252
  room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
189
253
  if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
254
+ // That a local model is running is not the same as it being of use on this project, and
255
+ // the second thing is the one a person needs told. Said once per model, in the room, with
256
+ // the one button that settles it — the crew line above fires only when the crew CHANGES,
257
+ // and a room whose Ollama was already up when it opened would never have heard it.
258
+ if (fifth.ready && memory && memory.metaGet('local.announced') !== fifth.version) {
259
+ let checked = null;
260
+ try { checked = JSON.parse(memory.metaGet('exams') ?? '{}').match ?? null; } catch { checked = null; }
261
+ memory.metaSet('local.announced', fifth.version);
262
+ await room.record('local.present', {
263
+ model: fifth.version,
264
+ checked: checked?.ran ? { passed: Boolean(checked.passed), matched: checked.matched ?? 0, n: checked.n ?? 0, at: checked.at ?? null } : null,
265
+ });
266
+ }
190
267
  }
191
268
  return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
192
269
  }
193
270
  // Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
194
- const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, ...((await readConfig(root)).memory ?? {}) });
271
+ const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, cascade: true, ...((await readConfig(root)).memory ?? {}) });
195
272
  const embedEnvFor = (cfg) => {
196
273
  const view = { ...process.env };
197
274
  if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
@@ -207,14 +284,19 @@ export async function createPulseServer({
207
284
  const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
208
285
  const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
209
286
  const startupMemory = await memoryConfig();
210
- room = new Room({
287
+ let listening = null;
288
+ const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
289
+ const roomOptions = () => ({
211
290
  store,
212
291
  agents,
213
292
  projectRoot,
214
293
  privacy,
294
+ // Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
295
+ toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
215
296
  softTokenBudget,
216
297
  contextMaxChars,
217
298
  recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
299
+ cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' : startupMemory.cascade !== false,
218
300
  distill: {
219
301
  every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
220
302
  idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
@@ -231,6 +313,7 @@ export async function createPulseServer({
231
313
  delegation,
232
314
  maxPlanSteps,
233
315
  });
316
+ room = new Room(roomOptions());
234
317
  await wireOllama({ probe: false });
235
318
  const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
236
319
  embedKick?.unref?.();
@@ -261,7 +344,7 @@ export async function createPulseServer({
261
344
  capabilities: room.capabilities(),
262
345
  memory: memorySettingsView(),
263
346
  updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
264
- privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins },
347
+ privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins, ...privacy.guards },
265
348
  };
266
349
  }
267
350
  // What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
@@ -269,7 +352,7 @@ export async function createPulseServer({
269
352
  const distill = room.distillSettings();
270
353
  const candidates = [...(distill.ollama ? [{ id: 'ollama', label: `Ollama · ${ollama.chatModel ?? 'local'}`, local: true }] : []), ...agents.filter((agent) => agent.detected && !agent.local).map((agent) => ({ id: agent.id, label: agent.label, local: false }))];
271
354
  return {
272
- enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare,
355
+ enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare, cascade: distill.cascade,
273
356
  candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
274
357
  ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
275
358
  stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
@@ -311,9 +394,10 @@ export async function createPulseServer({
311
394
  if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
312
395
  if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
313
396
  if (Number.isFinite(Number(m.recallShare))) next.recallShare = Math.min(0.6, Math.max(0, Number(m.recallShare)));
397
+ if (typeof m.cascade === 'boolean') next.cascade = m.cascade;
314
398
  if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
315
399
  config.memory = next;
316
- room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
400
+ room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, cascade: next.cascade !== false, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
317
401
  live.memory = true;
318
402
  }
319
403
  if (patch.scopes && typeof patch.scopes === 'object') {
@@ -324,7 +408,7 @@ export async function createPulseServer({
324
408
  config.scopes[agentId] = { ...(current[agentId] ?? {}) };
325
409
  for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
326
410
  // MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
327
- if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
411
+ if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
328
412
  if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
329
413
  }
330
414
  room.setScopes(config.scopes);
@@ -372,12 +456,78 @@ export async function createPulseServer({
372
456
  return { status: 202, body: { accepted: true, command: plan.display } };
373
457
  }
374
458
 
375
- // Attachments live beside the room log, never inside the project.
376
- const roomDirectory = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
377
- const attachmentsRoot = join(roomDirectory, 'attachments');
378
- room.restoreAttachments(historicalEvents
379
- .filter((event) => event.type === 'attachment.stored')
380
- .map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
459
+ // A CLI installed from the room appears without restarting: detection runs again, the roster
460
+ // is updated in place (the room holds the same array) and the change is announced.
461
+ async function redetectAgents({ announce = true } = {}) {
462
+ if (!detector) return { changed: false, agents };
463
+ let changed = false;
464
+ for (const next of await detector()) {
465
+ const index = agents.findIndex((item) => item.id === next.id);
466
+ if (index === -1) { agents.push(next); changed = true; continue; }
467
+ const before = agents[index];
468
+ if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path) {
469
+ agents[index] = { ...before, ...next };
470
+ changed = true;
471
+ }
472
+ }
473
+ await refreshSessions();
474
+ if (changed && announce) {
475
+ await room.record('agents.updated', {
476
+ agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })),
477
+ removed: [],
478
+ reason: 'the room looked again for the agents on this computer',
479
+ });
480
+ }
481
+ return { changed, agents };
482
+ }
483
+
484
+ // The bridge installs a CLI with its own package manager, streaming into the room like any
485
+ // other install, and looks again when it finishes. Nothing is written into the project.
486
+ async function installAgent(id) {
487
+ const agent = agents.find((item) => item.id === id);
488
+ if (!agent) return { status: 404, body: { error: `Unknown agent: ${id}.` } };
489
+ if (agent.detected) return { status: 409, body: { error: `${agent.label} is already installed.` } };
490
+ const plan = installPlanFor(agent, { prefix: toolsPrefix() });
491
+ if (!plan) return { status: 404, body: { error: `MADRE does not know how to install ${id}.` } };
492
+ if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
493
+ installing = `agent:${id}`;
494
+ await room.record('connection.install.started', { agent: id, label: agent.label, command: plan.display });
495
+ void (async () => {
496
+ const lines = [];
497
+ const attempt = (step) => (installers[id] ?? runInstaller)({
498
+ command: step.command,
499
+ args: step.args,
500
+ projectRoot: canonicalProjectRoot,
501
+ timeoutMs: 600000,
502
+ onLine: (line) => { lines.push(line); void room.record('connection.install.output', { agent: id, line: line.slice(0, 500) }); },
503
+ });
504
+ let result = await attempt(plan);
505
+ let where = 'system';
506
+ // Walled out of the system folders: install into MADRE's own, where it will be found.
507
+ if (result.code !== 0 && plan.fallback && looksLikeAdminProblem(`${lines.join('\n')}\n${result.error ?? ''}`)) {
508
+ await room.record('connection.install.output', { agent: id, line: `npm cannot write to this computer's system folder without an administrator. Installing into MADRE's own folder instead: ${toolsPrefix()}` });
509
+ result = await attempt(plan.fallback);
510
+ where = 'madre';
511
+ }
512
+ await redetectAgents();
513
+ const found = agents.find((item) => item.id === id);
514
+ await room.record('connection.install.finished', {
515
+ agent: id,
516
+ label: agent.label,
517
+ where,
518
+ prefix: where === 'madre' ? toolsPrefix() : null,
519
+ code: result.code ?? null,
520
+ error: result.error ?? null,
521
+ detected: Boolean(found?.detected),
522
+ version: found?.version ?? null,
523
+ session: sessions[id] ?? null,
524
+ tail: lines.slice(-6),
525
+ });
526
+ installing = null;
527
+ })();
528
+ return { status: 202, body: { accepted: true, command: plan.display } };
529
+ }
530
+
381
531
 
382
532
  async function rawBody(request, limit) {
383
533
  const chunks = [];
@@ -393,7 +543,8 @@ export async function createPulseServer({
393
543
  const startupConfig = await readConfig(root);
394
544
  room.setScopes(startupConfig.scopes ?? {});
395
545
  setImageModule(startupConfig.modules?.imageStudio ?? {});
396
- room.setAshCode(Boolean(startupConfig.modules?.ashCode?.enabled));
546
+ // Rooms opened before Ash was renamed carry the old key; the human's switch is not lost.
547
+ room.setAsh(Boolean(startupConfig.modules?.ash?.enabled ?? startupConfig.modules?.ash?.enabled));
397
548
  const recoveredTurns = await room.reconcile();
398
549
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
399
550
  const quotaMonitor = new QuotaMonitor({
@@ -451,7 +602,7 @@ export async function createPulseServer({
451
602
  })();
452
603
  return inFlight;
453
604
  }
454
- const unsubscribe = room.subscribe(() => { void broadcastPending(); });
605
+ let unsubscribe = () => {};
455
606
 
456
607
  // The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
457
608
  // redacted reports in the ledger. Sending them anywhere is the human's call.
@@ -464,8 +615,8 @@ export async function createPulseServer({
464
615
  emit: (type, payload) => room.record(type, payload),
465
616
  save: async (settings) => { const current = await readConfig(root); await updateConfig(root, { telemetry: { ...(current.telemetry ?? {}), ...settings } }); },
466
617
  });
467
- sentinel.seed(historicalEvents);
468
- const unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
618
+ outbound.watchReportUrl(sentinel.settings().reportUrl);
619
+ let unsubscribeSentinel = () => {};
469
620
  if (!testMode && !crashHandlersInstalled) {
470
621
  crashHandlersInstalled = true;
471
622
  for (const origin of ['uncaughtException', 'unhandledRejection']) {
@@ -475,7 +626,79 @@ export async function createPulseServer({
475
626
  });
476
627
  }
477
628
  }
478
- const unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
629
+ // EYECAT: the watcher that asks whether the room still believes what it wrote down. It is not
630
+ // a module and takes no turn: it subscribes from outside, the way the error sentinel does, and
631
+ // everything it finds is a question for the human rather than an entry in the archive.
632
+ const eyecat = new Eyecat({
633
+ enabled: () => process.env.PULSE_EYECAT !== '0',
634
+ research: () => room.memoryResearch(),
635
+ entriesFor: (notes) => (memory ? memory.entriesAt(notes.flatMap((note) => note.sources ?? [])) : []),
636
+ bench: () => room.bench(),
637
+ emit: (type, payload) => room.record(type, payload),
638
+ });
639
+ let unsubscribeEyecat = () => {};
640
+
641
+ let unsubscribeGhost = () => {};
642
+ let unsubscribeLive = () => {};
643
+ let unsubscribeChats = () => {};
644
+ // Writes to the conversation list are fire-and-forget, so the last one has to be waited for
645
+ // when the room closes: a file that reappears while its folder is being removed is a test that
646
+ // fails for a reason that has nothing to do with it.
647
+ let chatWrites = Promise.resolve();
648
+
649
+ // Everything that listens to the open conversation, attached in one place so that opening
650
+ // another attaches exactly the same things to it. Live readings go straight to whoever is
651
+ // watching and are never stored, so a page that opens later simply never sees them.
652
+ function wireRoom() {
653
+ unsubscribe = room.subscribe(() => { void broadcastPending(); });
654
+ unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
655
+ unsubscribeEyecat = room.subscribe((event) => { void eyecat.observe(event).catch(() => null); });
656
+ unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
657
+ unsubscribeLive = room.subscribeLive((event) => { for (const client of clients.keys()) writeEvent(client, event); });
658
+ // What the list of conversations shows, kept as it happens. A conversation with no name of
659
+ // its own takes the first thing the human said in it.
660
+ unsubscribeChats = room.subscribe((event) => {
661
+ if (event?.type !== 'message.created' || event.ghost) return;
662
+ const human = event.payload?.role === 'user';
663
+ chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? event.payload.text : null, counts: human })).catch(() => null);
664
+ });
665
+ sentinel.seed(historicalEvents);
666
+ eyecat.seed(historicalEvents);
667
+ room.restoreAttachments(historicalEvents
668
+ .filter((event) => event.type === 'attachment.stored')
669
+ .map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
670
+ }
671
+ function unwireRoom() {
672
+ for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats]) { try { off(); } catch { /* already gone */ } }
673
+ unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = () => {};
674
+ }
675
+
676
+ // Opening another conversation. The project keeps its memory, its crew, its modules, its
677
+ // privacy list and its numbering; what changes is the record being written and read. One
678
+ // conversation drives the crew at a time — two threads editing the same working tree at once
679
+ // is not a feature, it is a way to lose work — so a turn in flight is a reason to refuse.
680
+ async function mountChat(id) {
681
+ if (!isChatId(id)) return { error: `No conversation "${id}".` };
682
+ if (room && room.working()) return { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') };
683
+ const opened = await openChatIndex(roomDir, id);
684
+ if (!opened) return { error: `No conversation "${id}".` };
685
+ unwireRoom();
686
+ await chatWrites.catch(() => null);
687
+ await room?.shutdown().catch(() => null);
688
+ chatId = id;
689
+ store = await new EventStore(chatLedger(roomDir, id), { floor: await projectFloor(roomDir, { except: id }) }).initialize();
690
+ historicalEvents = await store.readAll();
691
+ room = new Room(roomOptions());
692
+ room.setInvoker(MADRE_ADAPTER, madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }));
693
+ wireRoom();
694
+ // Whoever is watching is watching the wrong record now: their stream ends and they come back
695
+ // to the one that is open.
696
+ lastBroadcastSequence = historicalEvents.at(-1)?.sequence ?? 0;
697
+ tailOffset = (await store.tail(0)).offset;
698
+ for (const client of [...clients.keys()]) { clients.delete(client); try { client.end(); } catch { /* already gone */ } }
699
+ return { chat: opened };
700
+ }
701
+ wireRoom();
479
702
  const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
480
703
  poller.unref();
481
704
 
@@ -491,9 +714,48 @@ export async function createPulseServer({
491
714
  services: {
492
715
  imageKey,
493
716
  setImageModule,
717
+ // What a card knows about being up to date. Reading is free: it answers from the day's
718
+ // cache. The button on a card is what forces a look outside.
719
+ moduleUpdate: (item, { force = false, cacheOnly = !force } = {}) => moduleUpdate(item, {
720
+ stateRoot: root, madre: { name: PACKAGE.name, version: PACKAGE.version },
721
+ fetchImpl: reportFetch, enabled: updatesEnabled(), force, cacheOnly,
722
+ }),
494
723
  ollama: {
495
724
  state: () => ollama,
496
725
  wire: (options) => wireOllama(options),
726
+ // Getting Ollama onto this computer, with the command in plain sight, and waking it.
727
+ install: async () => {
728
+ if (installing) return { ok: false, error: `Another install is running (${installing}).` };
729
+ const { ollamaInstallPlan } = await import('./modules/ollama.mjs');
730
+ const plan = ollamaInstallPlan({ brew: await findOnPath('brew') });
731
+ if (!plan.command) return { ok: false, error: plan.note, download: plan.download };
732
+ installing = 'ollama';
733
+ await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: plan.display, platforms: [], alreadyInstalled: false });
734
+ void (async () => {
735
+ const lines = [];
736
+ const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { lines.push(line); void room.record('extension.install.output', { id: 'ollama', lines: [line.slice(0, 500)] }); } });
737
+ const status = await wireOllama();
738
+ await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: result.code === 0, installed: Boolean(await findOnPath('ollama')), detail: result.code === 0 ? 'Ollama installed · START it to wake it' : (result.error ?? `exit ${result.code}`), ollama: status });
739
+ installing = null;
740
+ })();
741
+ return { ok: true, command: plan.display };
742
+ },
743
+ start: async () => {
744
+ if (ollama.running) return { ok: true, already: true };
745
+ const binary = await findOnPath('ollama');
746
+ if (!binary) return { ok: false, error: t('Ollama is not on this computer yet.') };
747
+ await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: 'ollama serve', platforms: [], alreadyInstalled: true });
748
+ const child = spawn(binary, ['serve'], { detached: process.platform !== 'win32', windowsHide: true, stdio: 'ignore' });
749
+ child.unref();
750
+ // It answers in a moment or it does not: poll its own port rather than guess.
751
+ let status = ollama;
752
+ for (let attempt = 0; attempt < 20 && !status.running; attempt += 1) {
753
+ await new Promise((resolve) => setTimeout(resolve, 500));
754
+ status = await wireOllama();
755
+ }
756
+ await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: status.running, installed: true, detail: status.running ? `running · ${status.chatModel ? `@madre with ${status.chatModel}` : 'no chat model yet'}` : 'Ollama did not answer; open the Ollama app and press RECHECK.', ollama: status });
757
+ return status.running ? { ok: true } : { ok: false, error: t('Ollama did not answer. Open the Ollama app, then press RECHECK.') };
758
+ },
497
759
  // Pulls stream into the room like a module install; one at a time.
498
760
  pull: async (model) => {
499
761
  if (installing) return { ok: false, error: `Another install is running (${installing}).` };
@@ -526,7 +788,7 @@ export async function createPulseServer({
526
788
  if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
527
789
  // Built-ins are switches the module itself defines.
528
790
  if (extension.toggle) return extension.toggle(await moduleContext(), payload);
529
- if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
791
+ if (confirm !== true) return { status: 400, body: { error: t('Installing a module writes into the project; send { "confirm": true } to proceed.') } };
530
792
  if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
531
793
  const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
532
794
  if (!preflight.ok && !installers[id]) {
@@ -584,7 +846,7 @@ export async function createPulseServer({
584
846
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
585
847
  return response.end(html);
586
848
  }
587
- if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js'].includes(url.pathname)) {
849
+ if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js'].includes(url.pathname)) {
588
850
  const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
589
851
  response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
590
852
  return response.end(js);
@@ -597,8 +859,13 @@ export async function createPulseServer({
597
859
  if (request.method === 'GET' && url.pathname === '/api/state') {
598
860
  return sendJson(response, 200, {
599
861
  projectRoot,
600
- agents,
601
- ashCode: { enabled: room.ashCodeEnabled() },
862
+ platform: process.platform,
863
+ // What language this machine set MADRE to, for a browser that has never chosen.
864
+ language: (await readConfig(root)).language === 'en' ? 'en' : 'es',
865
+ // Which conversation this page is looking at, and the others it could open.
866
+ chats: await listChats(roomDir),
867
+ agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
868
+ ash: { enabled: room.ashEnabled() },
602
869
  ripley: { enabled: await ripleyOn() },
603
870
  softTokenBudget,
604
871
  timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
@@ -669,7 +936,7 @@ export async function createPulseServer({
669
936
  // resolve to the same route.
670
937
  const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
671
938
  if (previewMatch) {
672
- if (!(await ripleyOn())) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
939
+ if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
673
940
  const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
674
941
  const relative = decodeURIComponent(previewMatch[2]);
675
942
  const file = await readServable(which, relative);
@@ -704,7 +971,7 @@ export async function createPulseServer({
704
971
  }
705
972
  if (request.method === 'GET' && url.pathname === '/api/settings') {
706
973
  await refreshPrivacy();
707
- return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent) })) });
974
+ return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
708
975
  }
709
976
  if (request.method === 'POST' && url.pathname === '/api/settings') {
710
977
  return sendJson(response, 200, { settings: await applySettings(await body(request)) });
@@ -717,13 +984,15 @@ export async function createPulseServer({
717
984
  }
718
985
  if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
719
986
  const patch = await body(request).catch(() => ({}));
720
- return sendJson(response, 200, { settings: await sentinel.setSettings(patch) });
987
+ const saved = await sentinel.setSettings(patch);
988
+ outbound.watchReportUrl(saved.reportUrl);
989
+ return sendJson(response, 200, { settings: saved });
721
990
  }
722
991
  const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
723
992
  if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
724
993
  if (sentinelMatch[2] === 'issue') {
725
994
  const issue = sentinel.issueUrl(sentinelMatch[1]);
726
- return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: 'No such report, or no repository to file it in.' });
995
+ return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: t('No such report, or no repository to file it in.') });
727
996
  }
728
997
  const outcome = await sentinel.send(sentinelMatch[1]);
729
998
  return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
@@ -735,23 +1004,99 @@ export async function createPulseServer({
735
1004
  const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
736
1005
  return sendJson(response, result.status ?? 200, result.body ?? {});
737
1006
  }
1007
+ // Where this room's tokens go: what each block of the briefing has cost, how much came back
1008
+ // from a CLI's own cache, and how many characters the room spends per token it is charged.
1009
+ if (url.pathname === '/api/economy' && request.method === 'GET') {
1010
+ return sendJson(response, 200, economy(await store.readAll()));
1011
+ }
1012
+
1013
+ // What left this machine: every address MADRE may reach, whether it is on today, and the
1014
+ // last requests this process actually made. The declaration is checked against the log.
1015
+ if (url.pathname === '/api/outbound' && request.method === 'GET') {
1016
+ const embedder = memory?.embedder?.model ?? null;
1017
+ const modules = (await readConfig(root)).modules ?? {};
1018
+ return sendJson(response, 200, outboundView({
1019
+ log: outbound,
1020
+ agents,
1021
+ state: {
1022
+ crew: agents.some((agent) => agent.detected && !agent.local),
1023
+ embeddings: Boolean(embedder && /^gemini/i.test(embedder)),
1024
+ image: Boolean(modules['image-studio']?.enabled),
1025
+ npm: updatesEnabled(),
1026
+ github: updatesEnabled(),
1027
+ reports: Boolean(sentinel.settings().autoReport),
1028
+ anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
1029
+ ollama: Boolean(ollama.running),
1030
+ },
1031
+ }));
1032
+ }
1033
+
1034
+ // EYECAT: what it is holding, and the two answers a person can give it. Confirming writes
1035
+ // the aberration and takes the note it refutes out of circulation; dismissing says the room
1036
+ // was right all along and the pair is never raised again.
1037
+ if (url.pathname === '/api/eyecat' && request.method === 'GET') {
1038
+ return sendJson(response, 200, { findings: eyecat.findings(), settings: eyecat.settings(), aberrations: memory ? memory.aberrations({ limit: 100 }) : [] });
1039
+ }
1040
+ if (url.pathname === '/api/eyecat/sweep' && request.method === 'POST') {
1041
+ const payload = await body(request).catch(() => ({}));
1042
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1043
+ const raised = await eyecat.sweep({ reason: 'asked' }).catch(() => null);
1044
+ return sendJson(response, 200, { raised: raised?.length ?? 0, findings: eyecat.findings() });
1045
+ }
1046
+ const eyecatVerdict = request.method === 'POST' && url.pathname.match(/^\/api\/eyecat\/(confirm|dismiss)$/);
1047
+ if (eyecatVerdict) {
1048
+ const payload = await body(request).catch(() => ({}));
1049
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1050
+ if (!memory) return sendJson(response, 503, { error: t('The room has no memory.') });
1051
+ const finding = eyecat.findings().find((item) => item.key === payload.key);
1052
+ if (!finding) return sendJson(response, 404, { error: t('EYECAT is not holding that one.') });
1053
+ const settled = eyecat.settle(finding.key, { verdict: eyecatVerdict[1] === 'confirm' ? 'aberration' : 'dismissed' });
1054
+ let flagged = null;
1055
+ if (eyecatVerdict[1] === 'confirm') {
1056
+ flagged = memory.flagAberration({
1057
+ text: finding.claim.text,
1058
+ correction: finding.correction,
1059
+ contradicts: finding.claim.id,
1060
+ sources: [],
1061
+ agent: finding.judge,
1062
+ detector: 'eyecat',
1063
+ confidence: finding.confidence,
1064
+ });
1065
+ }
1066
+ await room.record('eyecat.settled', { key: finding.key, verdict: settled?.settledAs ?? 'dismissed', aberration: flagged?.id ?? null, refuted: flagged?.refuted ?? null });
1067
+ return sendJson(response, 200, { settled: settled?.settledAs ?? 'dismissed', aberration: flagged, findings: eyecat.findings() });
1068
+ }
1069
+
738
1070
  // The dataset behind MADRE AI: exported on demand next to the ledger.
739
1071
  if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
740
1072
  const dir = join(roomDir, 'dataset');
741
1073
  const notes = memory ? memory.memories({ limit: 5000 }) : [];
742
1074
  if (request.method === 'GET') {
743
1075
  const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
744
- return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(await store.readAll(), notes), training: trainingInfo() });
1076
+ const ready = datasetReadiness(await store.readAll(), notes);
1077
+ const research = room.memoryResearch();
1078
+ return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: ready, maturity: maturity({ readiness: ready, notes, links: research?.links ?? [], stats: research?.stats ?? null }), training: trainingInfo() });
745
1079
  }
746
1080
  const events = await store.readAll();
747
1081
  const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
748
1082
  await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
749
- return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: datasetReadiness(events, notes), training: trainingInfo() });
1083
+ const after = datasetReadiness(events, notes);
1084
+ const drawn = room.memoryResearch();
1085
+ return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: after, maturity: maturity({ readiness: after, notes, links: drawn?.links ?? [], stats: drawn?.stats ?? null }), training: trainingInfo() });
750
1086
  }
751
1087
  // PRIVACY: the terms live in config.json only; the ledger records counts, never words.
1088
+ // Which language MADRE speaks. The page keeps its own copy so the first paint needs no
1089
+ // round trip; this is the durable one, shared by every browser on this machine.
1090
+ if (request.method === 'POST' && url.pathname === '/api/language') {
1091
+ const payload = await body(request).catch(() => ({}));
1092
+ const language = payload.language === 'en' ? 'en' : 'es';
1093
+ await updateConfig(root, { language });
1094
+ setRoomLanguage(process.env.PULSE_LANGUAGE ?? language);
1095
+ return sendJson(response, 200, { language });
1096
+ }
752
1097
  if (request.method === 'GET' && url.pathname === '/api/privacy') {
753
1098
  await refreshPrivacy();
754
- return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
1099
+ return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
755
1100
  }
756
1101
  if (request.method === 'POST' && url.pathname === '/api/privacy') {
757
1102
  const patch = await body(request).catch(() => ({}));
@@ -759,16 +1104,19 @@ export async function createPulseServer({
759
1104
  const next = { ...current };
760
1105
  if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
761
1106
  if (typeof patch.marker === 'string' && patch.marker.trim()) next.marker = patch.marker.trim().slice(0, 40);
1107
+ if ('secrets' in patch) next.secrets = Boolean(patch.secrets);
1108
+ if ('paths' in patch) next.paths = Boolean(patch.paths);
762
1109
  await updateConfig(root, { privacy: next });
763
- privacy.set(next.terms ?? [], next.marker);
764
- await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker });
765
- return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
1110
+ await refreshPrivacy();
1111
+ // The room records that the setting changed and what it is now, never the words.
1112
+ await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker, ...privacy.guards });
1113
+ return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
766
1114
  }
767
1115
  if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
768
1116
  const payload = await body(request).catch(() => ({}));
769
- if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
1117
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
770
1118
  await refreshPrivacy();
771
- if (!privacy.enabled) return sendJson(response, 412, { error: 'No private terms are set. Write them first.' });
1119
+ if (!privacy.enabled) return sendJson(response, 412, { error: t('No private terms are set. Write them first.') });
772
1120
  const result = await room.purgePrivate();
773
1121
  // The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
774
1122
  tailOffset = (await store.tail(0)).offset;
@@ -786,6 +1134,28 @@ export async function createPulseServer({
786
1134
  if (request.method === 'GET' && url.pathname === '/api/version') {
787
1135
  return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
788
1136
  }
1137
+ // One click: install the newer version and come back on the same port. The room records it,
1138
+ // closes its listener so the port is free, hands the command to a detached shell and exits.
1139
+ if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
1140
+ const info = await versionView();
1141
+ if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
1142
+ const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
1143
+ if (!command) return sendJson(response, 412, { error: t('This copy runs from source: pull the repository and start it again.') });
1144
+ if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: t('Agents are still working. STOPALL or wait, then update.') });
1145
+ await room.record('room.updating', { from: info.current, to: info.latest, install, command });
1146
+ sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
1147
+ setTimeout(() => {
1148
+ server.close(() => {
1149
+ // Each system has its own shell: the command is one line either way.
1150
+ const shell = process.platform === 'win32' ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', command] } : { file: '/bin/sh', args: ['-c', command] };
1151
+ const child = spawn(shell.file, shell.args, { cwd: canonicalProjectRoot, detached: process.platform !== 'win32', windowsHide: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
1152
+ child.unref();
1153
+ setTimeout(() => process.exit(0), 200);
1154
+ });
1155
+ for (const client of clients.keys()) { try { client.end(); } catch { /* gone */ } }
1156
+ }, 300);
1157
+ return undefined;
1158
+ }
789
1159
  if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
790
1160
  const patch = await body(request).catch(() => ({}));
791
1161
  if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
@@ -796,27 +1166,236 @@ export async function createPulseServer({
796
1166
  }
797
1167
  if (request.method === 'POST' && url.pathname === '/api/mother/code000') {
798
1168
  const payload = await body(request).catch(() => ({}));
799
- if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
1169
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
800
1170
  const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
801
- return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: 'MOTHER is silent.' });
1171
+ return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('MOTHER is silent.') });
802
1172
  }
803
1173
  if (request.method === 'GET' && url.pathname === '/api/memory') {
804
- if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
1174
+ if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
805
1175
  const sealed = room.motherStatus()?.lockedForMs ?? 0;
806
1176
  if (sealed > 0) return sendJson(response, 423, { error: `CODE000. THE ARCHIVE IS SEALED FOR ${Math.ceil(sealed / 60000)} MORE MINUTE${Math.ceil(sealed / 60000) === 1 ? '' : 'S'}.`, lockedForMs: sealed });
807
1177
  const research = room.memoryResearch();
808
- return research ? sendJson(response, 200, research) : sendJson(response, 503, { error: 'The room has no memory.' });
1178
+ if (!research) return sendJson(response, 503, { error: t('The room has no memory.') });
1179
+ // How grown this archive is, read from the same notes the map draws.
1180
+ const grown = maturity({ readiness: datasetReadiness(await store.readAll(), research.memories), notes: research.memories, links: research.links, stats: research.stats });
1181
+ return sendJson(response, 200, { ...research, maturity: grown });
1182
+ }
1183
+ // Where this room stands, in one place: what the archive is made of, whether it works, and
1184
+ // the one thing worth doing about it. Nothing here waits on a test — the free one is read
1185
+ // as it is asked for, the cheap one keeps itself fresh behind the screen, and the slow one
1186
+ // is the human's to start.
1187
+ if (request.method === 'GET' && url.pathname === '/api/maturity') {
1188
+ const research = room.memoryResearch();
1189
+ const notes = research?.memories ?? [];
1190
+ const grown = research ? maturity({ readiness: datasetReadiness(await store.readAll(), notes), notes, links: research.links, stats: research.stats }) : null;
1191
+ const exams = await room.exams({ refresh: true, findings: eyecat.findings() });
1192
+ void room.freshenCoverage();
1193
+ return sendJson(response, 200, {
1194
+ maturity: grown, exams, cold: research?.cold ?? null, asks: research?.ask?.length ?? 0,
1195
+ verdict: verdictFor({ maturity: grown, exams: exams.last, cold: research?.cold, asks: research?.ask?.length ?? 0 }),
1196
+ });
1197
+ }
1198
+ // Inside the core: the document MADRE writes in the human's name for the next turn, block
1199
+ // by block. Built on demand and stored nowhere; asking costs nothing and sends nothing.
1200
+ if (request.method === 'GET' && url.pathname === '/api/briefing') {
1201
+ const asked = url.searchParams.get('agent') ?? agents.find((agent) => agent.ready && !agent.local)?.id ?? agents[0]?.id;
1202
+ const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
1203
+ const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
1204
+ const briefing = await room.briefing({ agent: asked, mode, text });
1205
+ if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
1206
+ return sendJson(response, 200, {
1207
+ ...briefing,
1208
+ launch: await room.launch({ agent: asked, mode }),
1209
+ agents: agents.filter((agent) => agent.detected).map((agent) => ({ id: agent.id, label: agent.label, ready: agent.ready })),
1210
+ });
1211
+ }
1212
+ // The conversations of this project. One memory, one crew, one numbering; many records.
1213
+ if (request.method === 'GET' && url.pathname === '/api/chats') {
1214
+ return sendJson(response, 200, await listChats(roomDir));
1215
+ }
1216
+ if (request.method === 'POST' && url.pathname === '/api/chats') {
1217
+ if (room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
1218
+ const payload = await body(request).catch(() => ({}));
1219
+ const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null });
1220
+ const mounted = await mountChat(made.id);
1221
+ if (mounted.error) return sendJson(response, 409, mounted);
1222
+ return sendJson(response, 200, { ...(await listChats(roomDir)), opened: made.id });
1223
+ }
1224
+ const chatMatch = url.pathname.match(/^\/api\/chats\/([a-z0-9]+)$/);
1225
+ if (chatMatch && request.method === 'POST') {
1226
+ const mounted = await mountChat(chatMatch[1]);
1227
+ if (mounted.error) return sendJson(response, 409, mounted);
1228
+ return sendJson(response, 200, { ...(await listChats(roomDir)), opened: chatMatch[1] });
1229
+ }
1230
+ if (chatMatch && request.method === 'PATCH') {
1231
+ const payload = await body(request).catch(() => ({}));
1232
+ const named = await renameChat(roomDir, chatMatch[1], payload.title);
1233
+ return named ? sendJson(response, 200, { ...(await listChats(roomDir)) }) : sendJson(response, 404, { error: t('No such conversation.') });
1234
+ }
1235
+ if (chatMatch && request.method === 'DELETE') {
1236
+ if (chatMatch[1] === chatId && room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation.') });
1237
+ const gone = await deleteChat(roomDir, chatMatch[1]);
1238
+ if (!gone) return sendJson(response, 404, { error: t('No such conversation.') });
1239
+ if (gone.error) return sendJson(response, 409, gone);
1240
+ // What the archivist distilled from it is the project's memory and stays where it is.
1241
+ if (chatMatch[1] === chatId) { const mounted = await mountChat(gone.active); if (mounted.error) return sendJson(response, 409, mounted); }
1242
+ return sendJson(response, 200, { ...(await listChats(roomDir)), deleted: gone.deleted });
1243
+ }
1244
+ // The three tests. Reading is free; running one is the human's call, and the slow one says
1245
+ // where it is while it works.
1246
+ if (request.method === 'GET' && url.pathname === '/api/maturity/exams') {
1247
+ return sendJson(response, 200, await room.exams({ refresh: true, findings: eyecat.findings() }));
1248
+ }
1249
+ if (request.method === 'POST' && url.pathname === '/api/maturity/exam') {
1250
+ const payload = await body(request).catch(() => ({}));
1251
+ if (payload.stop) return sendJson(response, 200, room.stopExam());
1252
+ const result = await room.runExam(String(payload.which ?? ''), { findings: eyecat.findings() });
1253
+ return sendJson(response, result?.error ? 409 : 200, { ...result, exams: await room.exams() });
1254
+ }
1255
+ // Somebody's own module, handed to MADRE from the panel. It runs inside MADRE with the
1256
+ // human's permissions, so it goes through the same door as everything else: written to a
1257
+ // scratch copy, imported there, checked against the house rules, and only then installed.
1258
+ if (request.method === 'POST' && url.pathname === '/api/extensions/upload') {
1259
+ const payload = await body(request).catch(() => ({}));
1260
+ const text = String(payload.text ?? '');
1261
+ const name = String(payload.name ?? 'module.mjs');
1262
+ if (!text.trim()) return sendJson(response, 400, { error: t('That file is empty.') });
1263
+ if (text.length > 400_000) return sendJson(response, 413, { error: t('A module file that big is not a module. Keep it under 400 KB.') });
1264
+ if (!/\.m?js$/.test(name)) return sendJson(response, 400, { error: t('A module is a .mjs file.') });
1265
+ try {
1266
+ const installed = await installModuleText({
1267
+ text, name, scope: payload.scope === 'project' ? 'project' : 'user',
1268
+ stateRoot: root, projectRoot: canonicalProjectRoot,
1269
+ // A browser hands over the bytes, not a path: there is nowhere to go back to. A
1270
+ // module that wants to be updatable says so itself, with updates: { url }.
1271
+ source: null,
1272
+ });
1273
+ await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, file: installed.file, version: installed.version, by: 'upload' });
1274
+ return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
1275
+ } catch (error) {
1276
+ // The author reads exactly what failed: a module that will not load is not installed.
1277
+ return sendJson(response, 422, { error: error.message });
1278
+ }
1279
+ }
1280
+ // A newer copy of somebody's own module, from where they publish it or from the file it was
1281
+ // installed from. Asked for, never automatic: fetching means running what comes back.
1282
+ const refreshMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/refresh$/);
1283
+ if (refreshMatch) {
1284
+ const module = moduleById(refreshMatch[1]);
1285
+ if (!module?.external) return sendJson(response, 404, { error: t('Only a module you installed yourself can be refreshed.') });
1286
+ const origin = await moduleOrigin(module);
1287
+ if (!origin) return sendJson(response, 412, { error: `${module.name} does not say where a newer copy would come from. Declare updates: { url } in the module, or install it again from its file.` });
1288
+ const payload = await body(request).catch(() => ({}));
1289
+ let text;
1290
+ try {
1291
+ text = origin.kind === 'url'
1292
+ ? await reportFetch(origin.from, { signal: AbortSignal.timeout(10000) }).then(async (answer) => { if (!answer.ok) throw new Error(`HTTP ${answer.status}`); return answer.text(); })
1293
+ : await readFile(origin.from, 'utf8');
1294
+ } catch (error) { return sendJson(response, 502, { error: `Could not read ${origin.from}: ${error.message}` }); }
1295
+ if (!payload.confirm) {
1296
+ // What would be installed, checked before it is offered. Checking installs nothing.
1297
+ try {
1298
+ const candidate = await verifyModuleText({ text, name: `${module.id}.mjs` });
1299
+ return sendJson(response, 200, { origin, current: module.version ?? null, candidate: candidate.version ?? null, same: (candidate.version ?? null) === (module.version ?? null) });
1300
+ } catch (error) { return sendJson(response, 422, { error: error.message, origin }); }
1301
+ }
1302
+ try {
1303
+ const installed = await installModuleText({ text, name: `${module.id}.mjs`, scope: module.origin === 'project' ? 'project' : 'user', stateRoot: root, projectRoot: canonicalProjectRoot, source: origin });
1304
+ await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, file: installed.file, version: installed.version, by: 'refresh' });
1305
+ return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
1306
+ } catch (error) { return sendJson(response, 422, { error: error.message }); }
1307
+ }
1308
+ // Updating what a module drives. Two calls: the first asks what would run and answers with
1309
+ // the command, the second runs exactly that. MADRE never runs a command the human has not
1310
+ // read, and the room carries the output line by line like any other install.
1311
+ const updateRunMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/update$/);
1312
+ if (updateRunMatch) {
1313
+ const module = moduleById(updateRunMatch[1]);
1314
+ if (!module?.updatePlan) return sendJson(response, 405, { error: `${updateRunMatch[1]} cannot update what it drives from here.` });
1315
+ const payload = await body(request).catch(() => ({}));
1316
+ const context = await moduleContext();
1317
+ const item = await module.describe(context);
1318
+ const known = await context.services.moduleUpdate(item, { force: Boolean(payload.check) });
1319
+ const plan = await module.updatePlan(context, { latest: known?.latest ?? null, current: item.version ?? null });
1320
+ if (!plan?.command) return sendJson(response, 412, { error: plan?.note ?? 'There is no way to update this from here.', download: plan?.download ?? null });
1321
+ if (payload.confirm !== true) return sendJson(response, 200, { plan: { display: plan.display, note: plan.note ?? '' }, update: known });
1322
+ if (installing) return sendJson(response, 409, { error: `Another install is running (${installing}).` });
1323
+ installing = module.id;
1324
+ await room.record('extension.install.started', { id: module.id, name: item.name, command: plan.display, platforms: [], alreadyInstalled: true });
1325
+ void (async () => {
1326
+ const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { void room.record('extension.install.output', { id: module.id, lines: [String(line).slice(0, 500)] }); } });
1327
+ await plan.after?.().catch(() => null);
1328
+ const after = await module.describe(await moduleContext()).catch(() => null);
1329
+ await room.record('extension.install.finished', {
1330
+ id: module.id, name: item.name, ok: result.code === 0, installed: Boolean(after?.version),
1331
+ detail: result.code === 0 ? `${item.name} now runs ${after?.version ?? 'what it found'}` : (result.error ?? `exit ${result.code}`),
1332
+ });
1333
+ installing = null;
1334
+ })();
1335
+ return sendJson(response, 202, { running: true, command: plan.display });
1336
+ }
1337
+ // A question the human has no use for. It stops being offered; nothing else changes.
1338
+ if (request.method === 'POST' && url.pathname === '/api/memory/ask/dismiss') {
1339
+ const payload = await body(request).catch(() => ({}));
1340
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1341
+ const done = room.dismissAsk(String(payload.id ?? '').slice(0, 80));
1342
+ return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The room has no memory.') });
1343
+ }
1344
+ // One memory's own traffic: who it keeps arriving with, who asked for it, what a
1345
+ // refutation did. Read while its card is open, so the card is alive rather than a snapshot.
1346
+ const trafficMatch = request.method === 'GET' && url.pathname.match(/^\/api\/memory\/(\d+)\/traffic$/);
1347
+ if (trafficMatch) {
1348
+ if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1349
+ const sealed = room.motherStatus()?.lockedForMs ?? 0;
1350
+ if (sealed > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.'), lockedForMs: sealed });
1351
+ const traffic = room.memoryTraffic(Number(trafficMatch[1]));
1352
+ return traffic ? sendJson(response, 200, traffic) : sendJson(response, 404, { error: t('No such memory.') });
809
1353
  }
810
1354
  const forgetMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/memory\/(\d+)$/);
811
1355
  if (forgetMatch) {
812
1356
  const payload = await body(request).catch(() => ({}));
813
- if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
814
- if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: 'CODE000. THE ARCHIVE IS SEALED.' });
1357
+ if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1358
+ if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.') });
815
1359
  const row = await room.forgetMemory(forgetMatch[1]);
816
- return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: 'No such memory.' });
1360
+ return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: t('No such memory.') });
817
1361
  }
818
1362
  if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
819
- return sendJson(response, 200, { sessions: await refreshSessions(), sessionsAt });
1363
+ // Look for the binaries again too: a CLI installed a moment ago must appear now.
1364
+ await redetectAgents();
1365
+ return sendJson(response, 200, { sessions, sessionsAt, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
1366
+ }
1367
+ // A provider key for a CLI that signs in from its own prompt. Local connections only: a key
1368
+ // pasted from another machine would travel the network. It is written where that CLI looks
1369
+ // for it and never reaches MADRE's config, its ledger or its logs.
1370
+ const keyMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/key$/);
1371
+ if (keyMatch) {
1372
+ const id = keyMatch[1];
1373
+ const address = request.socket.remoteAddress ?? '';
1374
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
1375
+ return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
1376
+ }
1377
+ const agent = agents.find((item) => item.id === id);
1378
+ if (!agent?.detected) return sendJson(response, 412, { error: `${agent?.label ?? id} is not installed on this computer.` });
1379
+ if (!TAKES_KEY.has(id)) return sendJson(response, 400, { error: `${agent.label} signs in with a click, not with a key.` });
1380
+ const payload = await body(request).catch(() => ({}));
1381
+ const result = await applyKey({
1382
+ agent: id,
1383
+ key: payload.key,
1384
+ provider: payload.provider ?? null,
1385
+ home: credentialHome,
1386
+ executable: agent.path ?? id,
1387
+ probe: () => probeAgentAuth(agent),
1388
+ });
1389
+ if (!result.ok) return sendJson(response, 422, { error: result.error });
1390
+ await refreshSessions();
1391
+ // What the room remembers: that a key was set, and for which provider. Never the key.
1392
+ await room.record('connection.key.set', { agent: id, label: agent.label, provider: payload.provider ?? null, detail: result.detail ?? null });
1393
+ return sendJson(response, 200, { ok: true, session: sessions[id] ?? null });
1394
+ }
1395
+ const installAgentMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/install$/);
1396
+ if (installAgentMatch) {
1397
+ const result = await installAgent(installAgentMatch[1]);
1398
+ return sendJson(response, result.status, result.body);
820
1399
  }
821
1400
  const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
822
1401
  if (loginMatch) {
@@ -833,7 +1412,7 @@ export async function createPulseServer({
833
1412
  }
834
1413
  if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
835
1414
  const opencode = agents.find((agent) => agent.id === 'opencode');
836
- if (!opencode?.detected) return sendJson(response, 404, { error: 'OpenCode is not installed.' });
1415
+ if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
837
1416
  const models = [];
838
1417
  await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
839
1418
  return sendJson(response, 200, { models });
@@ -845,24 +1424,93 @@ export async function createPulseServer({
845
1424
  const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
846
1425
  if (stopMatch) {
847
1426
  const stopped = await room.stopPlan(stopMatch[1]);
848
- return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: 'No running plan with that id.' });
1427
+ return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: t('No running plan with that id.') });
849
1428
  }
850
1429
  if (request.method === 'GET' && url.pathname === '/api/commands') {
851
- return sendJson(response, 200, { commands: await listCommands({ projectRoot: canonicalProjectRoot }) });
1430
+ const ctx = await moduleContext();
1431
+ const fromModules = await Promise.all(moduleCommands().map(async (command) => {
1432
+ const settings = command.module.settingsFrom(ctx.config);
1433
+ const on = command.module.kind !== 'builtin' || Boolean(settings.enabled);
1434
+ let available = on;
1435
+ if (on && command.available) { try { available = Boolean(await command.available({ ...ctx, settings })); } catch { available = false; } }
1436
+ return { name: command.name, module: command.module.id, title: command.title, usage: command.usage, summary: command.summary, available };
1437
+ }));
1438
+ return sendJson(response, 200, { commands: [...(await listCommands({ projectRoot: canonicalProjectRoot })), ...fromModules] });
852
1439
  }
853
1440
  if (request.method === 'POST' && url.pathname === '/api/commands') {
854
1441
  const { text } = await body(request);
855
1442
  const parsed = parseCommand(text);
856
- if (!parsed) return sendJson(response, 400, { error: 'Not a command. Commands start with "/" followed by a name.' });
857
- const command = commandByName(parsed.name);
858
- if (!command) return sendJson(response, 404, { error: `Unknown command /${parsed.name}.` });
859
- if (!(await command.available({ projectRoot: canonicalProjectRoot }))) return sendJson(response, 412, { error: `/${parsed.name} is not available in this project (${command.title}).` });
860
- const result = await command.execute({ projectRoot: canonicalProjectRoot, args: parsed.args });
1443
+ if (!parsed) return sendJson(response, 400, { error: t('Not a command. Commands start with "/" followed by a name.') });
1444
+ let command = commandByName(parsed.name);
1445
+ let result;
1446
+ if (command) {
1447
+ if (!(await command.available({ projectRoot: canonicalProjectRoot }))) return sendJson(response, 412, { error: `/${parsed.name} is not available in this project (${command.title}).` });
1448
+ result = await command.execute({ projectRoot: canonicalProjectRoot, args: parsed.args });
1449
+ } else {
1450
+ // A module's command: runs with the module's ctx and settings, only while the module is on.
1451
+ const own = moduleCommands().find((item) => item.name === parsed.name);
1452
+ if (!own) return sendJson(response, 404, { error: `Unknown command /${parsed.name}.` });
1453
+ const ctx = await moduleContext();
1454
+ const settings = own.module.settingsFrom(ctx.config);
1455
+ if (own.module.kind === 'builtin' && !settings.enabled) return sendJson(response, 412, { error: `/${parsed.name} belongs to ${own.module.name}, which is off. Enable it in MODULES.` });
1456
+ if (own.available && !(await own.available({ ...ctx, settings }).catch(() => false))) return sendJson(response, 412, { error: `/${parsed.name} is not available here (${own.title}).` });
1457
+ try {
1458
+ const answer = await own.execute({ ...ctx, settings }, parsed.args);
1459
+ result = { ok: answer?.ok !== false, title: answer?.title ?? own.title, text: String(answer?.text ?? '') };
1460
+ } catch (error) { result = { ok: false, title: own.title, text: `/${parsed.name} failed: ${error.message}` }; }
1461
+ command = own;
1462
+ }
861
1463
  const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
862
1464
  return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
863
1465
  }
864
1466
  if (request.method === 'GET' && url.pathname === '/api/extensions') {
865
- return sendJson(response, 200, { installing, extensions: await describeModules(await moduleContext()) });
1467
+ const extensions = await describeModules(await moduleContext());
1468
+ void warmModuleUpdates(extensions);
1469
+ return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: 'https://github.com/jossuealcacao-exe/madre/blob/main/docs/SDK.md' });
1470
+ }
1471
+ // The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
1472
+ if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
1473
+ const payload = await body(request).catch(() => ({}));
1474
+ const relative = String(payload.path ?? '').replace(/^\/+/, '');
1475
+ if (!relative || relative.includes('..')) return sendJson(response, 400, { error: t('Give the project-relative path of a <id>.module.mjs file.') });
1476
+ try {
1477
+ const installed = await installModuleFile({ source: join(canonicalProjectRoot, relative), scope: payload.scope === 'project' ? 'project' : 'user', stateRoot: root, projectRoot: canonicalProjectRoot, roomDir });
1478
+ await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, from: relative, by: 'you' });
1479
+ return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
1480
+ } catch (error) { return sendJson(response, 422, { error: error.message }); }
1481
+ }
1482
+ const removeMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)$/);
1483
+ if (removeMatch) {
1484
+ try {
1485
+ const removed = await removeExternalModule({ id: removeMatch[1], stateRoot: root, projectRoot: canonicalProjectRoot });
1486
+ await room.record('extension.removed', { id: removed.id, name: removed.name, by: 'you' });
1487
+ return sendJson(response, 200, { removed, extensions: await describeModules(await moduleContext()) });
1488
+ } catch (error) { return sendJson(response, moduleById(removeMatch[1]) ? 403 : 404, { error: error.message }); }
1489
+ }
1490
+ // The human edited or added a module file: load it again without restarting the room.
1491
+ if (request.method === 'POST' && url.pathname === '/api/extensions/reload') {
1492
+ const outcome = await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot });
1493
+ await room.record('extension.reloaded', { loaded: outcome.loaded.map((module) => module.id), failures: outcome.failures.length });
1494
+ return sendJson(response, 200, { loaded: outcome.loaded.map((module) => ({ id: module.id, name: module.name, origin: module.origin, file: module.file })), failures: outcome.failures, extensions: await describeModules(await moduleContext()) });
1495
+ }
1496
+ // The button beside a version: look now, outside the day's cache. It answers for this card
1497
+ // alone, and what it asks about is the name of a package or a repository, nothing else.
1498
+ const updateMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/updates$/);
1499
+ if (updateMatch) {
1500
+ const context = await moduleContext();
1501
+ const module = moduleById(updateMatch[1]);
1502
+ if (!module) return sendJson(response, 404, { error: `No module "${updateMatch[1]}".` });
1503
+ const item = await module.describe(context);
1504
+ const update = await context.services.moduleUpdate(item, { force: true });
1505
+ return sendJson(response, 200, { id: item.id, version: item.version, update });
1506
+ }
1507
+ // One setting from a card's own floor. The module declared it; MADRE saves it.
1508
+ const settingMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/settings$/);
1509
+ if (settingMatch) {
1510
+ const module = moduleById(settingMatch[1]);
1511
+ if (!module?.setControl) return sendJson(response, 404, { error: `No settings on "${settingMatch[1]}".` });
1512
+ const result = await module.setControl(await moduleContext(), await body(request));
1513
+ return sendJson(response, result.status, result.body);
866
1514
  }
867
1515
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
868
1516
  if (installMatch) {
@@ -900,30 +1548,36 @@ export async function createPulseServer({
900
1548
  });
901
1549
  return sendJson(response, 200, { emitted: Boolean(warning), warning });
902
1550
  }
903
- sendJson(response, 404, { error: 'Not found.' });
1551
+ sendJson(response, 404, { error: t('Not found.') });
904
1552
  } catch (error) {
905
1553
  sendJson(response, 400, { error: error.message });
906
1554
  }
907
1555
  });
1556
+ listening = server;
908
1557
  // `server.close()` only resolves once every connection has ended, so the
909
1558
  // long-lived SSE responses must be ended before the native close runs.
910
1559
  const nativeClose = server.close.bind(server);
911
1560
  server.close = (callback) => {
912
1561
  clearInterval(poller);
913
1562
  clearTimeout(embedKick);
1563
+ unwatchOutbound();
914
1564
  // In-flight agent processes are killed and their turns recorded as failed
915
1565
  // before the SSE clients go away, so open pages see the outcome.
916
1566
  const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
917
1567
  let result;
918
1568
  shutdown.then(() => {
919
- void broadcastPending().then(() => {
1569
+ void broadcastPending().then(() => chatWrites).then(() => {
920
1570
  unsubscribe();
921
1571
  unsubscribeSentinel();
1572
+ unsubscribeEyecat();
1573
+ unsubscribeLive();
922
1574
  unsubscribeGhost();
923
1575
  for (const client of clients.keys()) client.end();
924
1576
  clients.clear();
925
1577
  // The memory file closes last, once nothing else writes to it; WAL and shm go with it.
926
1578
  try { memory?.close(); } catch { /* already closed */ }
1579
+ void outbound.drain();
1580
+ releaseOpen();
927
1581
  result = nativeClose(callback);
928
1582
  server.closeIdleConnections?.();
929
1583
  });
@@ -932,16 +1586,18 @@ export async function createPulseServer({
932
1586
  };
933
1587
  server.on('close', () => quotaMonitor.stop());
934
1588
 
935
- return { server, store, agents, quotaMonitor };
1589
+ return { server, store, agents, quotaMonitor, markOpen, releaseOpen };
936
1590
  }
937
1591
 
938
1592
  export async function startPulse({ port, projectRoot, openBrowser }) {
939
- const { server, agents } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
1593
+ const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
940
1594
  await new Promise((resolve, reject) => {
941
1595
  server.once('error', reject);
942
1596
  server.listen(port, '127.0.0.1', resolve);
943
1597
  });
944
1598
  const address = server.address();
1599
+ // The room is taken from here on, and says so on disk for anyone who tries to open it again.
1600
+ await markOpen(address.port);
945
1601
  const url = `http://127.0.0.1:${address.port}`;
946
1602
  const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
947
1603
  const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';