@jossuealcala/madre 0.3.3 → 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 (72) hide show
  1. package/CHANGELOG.md +412 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +67 -185
  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 +3825 -851
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +94 -13
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +602 -62
  19. package/public/troubleshooting.js +168 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  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/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +12 -1
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +9 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +36 -19
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +292 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/server.mjs +699 -69
  67. package/src/setup.mjs +1 -1
  68. package/src/updates.mjs +4 -2
  69. package/src/usage-sentinel.mjs +13 -8
  70. package/src/verdict.mjs +74 -0
  71. package/src/ashcode.mjs +0 -64
  72. 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, toolsForTurn as modulesToolsForTurn } 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,16 +37,18 @@ 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
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';
47
52
  import { spawn } from 'node:child_process';
48
53
  import { totalmem } from 'node:os';
49
54
  import { discoverModels } from './models.mjs';
@@ -51,6 +56,10 @@ import { setImageModule } from './capabilities.mjs';
51
56
  import { resolveGeminiKey } from './image-studio.mjs';
52
57
  import { commandByName, listCommands, parseCommand } from './commands.mjs';
53
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';
54
63
 
55
64
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
56
65
  const publicDirectory = join(sourceDirectory, '..', 'public');
@@ -69,7 +78,7 @@ async function body(request) {
69
78
  return JSON.parse(text || '{}');
70
79
  }
71
80
 
72
- function openUrl(url) {
81
+ export function openUrl(url) {
73
82
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
74
83
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
75
84
  execFile(command, args, () => {});
@@ -115,6 +124,11 @@ export async function createPulseServer({
115
124
  // Tests substitute the real installers with local scripts.
116
125
  installers = {},
117
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(),
118
132
  probe = probeAll,
119
133
  imageKey = resolveGeminiKey,
120
134
  // The sentinel's outbound channel; tests hand in a fake.
@@ -125,14 +139,51 @@ export async function createPulseServer({
125
139
  const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
126
140
  // ~/.pulse/config.json fills in whatever the environment did not set.
127
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);
128
145
  // RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
129
146
  const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
130
147
  agentTimeouts ??= agentTimeoutsFromEnv();
131
- const agents = providedAgents ?? await detectAgents();
148
+ const detector = detect ?? (providedAgents ? null : detectAgents);
149
+ const agents = providedAgents ?? await detector();
132
150
  const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
133
151
  const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
134
- const store = await new EventStore(join(roomDir, 'events.jsonl')).initialize();
135
- 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();
136
187
  // The room's memory: derived from the ledger, rebuilt if missing or stale,
137
188
  // and never a reason for the room not to open.
138
189
  // Release channel: one registry read a day, cached for every room on this machine. On by
@@ -142,13 +193,25 @@ export async function createPulseServer({
142
193
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
143
194
  async function versionView({ force = false } = {}) {
144
195
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
145
- 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 };
146
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
+
147
210
  // Privacy: the terms that never travel through this room, from config.json and the environment.
148
211
  // config.json is shared by every room on this machine, so the list is re-read before each
149
212
  // human message and whenever MU/TH/UR looks: a term named in one room guards them all.
150
- const privacy = new Privacy(privacySettings(await readConfig(root)));
151
- 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; };
152
215
  const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
153
216
  .catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
154
217
  // Meaning-aware recall through the user's own Gemini key, when there is one;
@@ -188,11 +251,24 @@ export async function createPulseServer({
188
251
  if (room) {
189
252
  room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
190
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
+ }
191
267
  }
192
268
  return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
193
269
  }
194
270
  // Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
195
- 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 ?? {}) });
196
272
  const embedEnvFor = (cfg) => {
197
273
  const view = { ...process.env };
198
274
  if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
@@ -210,7 +286,7 @@ export async function createPulseServer({
210
286
  const startupMemory = await memoryConfig();
211
287
  let listening = null;
212
288
  const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
213
- room = new Room({
289
+ const roomOptions = () => ({
214
290
  store,
215
291
  agents,
216
292
  projectRoot,
@@ -220,6 +296,7 @@ export async function createPulseServer({
220
296
  softTokenBudget,
221
297
  contextMaxChars,
222
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,
223
300
  distill: {
224
301
  every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
225
302
  idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
@@ -236,6 +313,7 @@ export async function createPulseServer({
236
313
  delegation,
237
314
  maxPlanSteps,
238
315
  });
316
+ room = new Room(roomOptions());
239
317
  await wireOllama({ probe: false });
240
318
  const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
241
319
  embedKick?.unref?.();
@@ -266,7 +344,7 @@ export async function createPulseServer({
266
344
  capabilities: room.capabilities(),
267
345
  memory: memorySettingsView(),
268
346
  updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
269
- 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 },
270
348
  };
271
349
  }
272
350
  // What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
@@ -274,7 +352,7 @@ export async function createPulseServer({
274
352
  const distill = room.distillSettings();
275
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 }))];
276
354
  return {
277
- 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,
278
356
  candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
279
357
  ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
280
358
  stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
@@ -316,9 +394,10 @@ export async function createPulseServer({
316
394
  if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
317
395
  if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
318
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;
319
398
  if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
320
399
  config.memory = next;
321
- 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 } : {}) });
322
401
  live.memory = true;
323
402
  }
324
403
  if (patch.scopes && typeof patch.scopes === 'object') {
@@ -377,12 +456,78 @@ export async function createPulseServer({
377
456
  return { status: 202, body: { accepted: true, command: plan.display } };
378
457
  }
379
458
 
380
- // Attachments live beside the room log, never inside the project.
381
- const roomDirectory = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
382
- const attachmentsRoot = join(roomDirectory, 'attachments');
383
- room.restoreAttachments(historicalEvents
384
- .filter((event) => event.type === 'attachment.stored')
385
- .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
+
386
531
 
387
532
  async function rawBody(request, limit) {
388
533
  const chunks = [];
@@ -398,7 +543,8 @@ export async function createPulseServer({
398
543
  const startupConfig = await readConfig(root);
399
544
  room.setScopes(startupConfig.scopes ?? {});
400
545
  setImageModule(startupConfig.modules?.imageStudio ?? {});
401
- 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));
402
548
  const recoveredTurns = await room.reconcile();
403
549
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
404
550
  const quotaMonitor = new QuotaMonitor({
@@ -456,7 +602,7 @@ export async function createPulseServer({
456
602
  })();
457
603
  return inFlight;
458
604
  }
459
- const unsubscribe = room.subscribe(() => { void broadcastPending(); });
605
+ let unsubscribe = () => {};
460
606
 
461
607
  // The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
462
608
  // redacted reports in the ledger. Sending them anywhere is the human's call.
@@ -469,8 +615,8 @@ export async function createPulseServer({
469
615
  emit: (type, payload) => room.record(type, payload),
470
616
  save: async (settings) => { const current = await readConfig(root); await updateConfig(root, { telemetry: { ...(current.telemetry ?? {}), ...settings } }); },
471
617
  });
472
- sentinel.seed(historicalEvents);
473
- const unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
618
+ outbound.watchReportUrl(sentinel.settings().reportUrl);
619
+ let unsubscribeSentinel = () => {};
474
620
  if (!testMode && !crashHandlersInstalled) {
475
621
  crashHandlersInstalled = true;
476
622
  for (const origin of ['uncaughtException', 'unhandledRejection']) {
@@ -480,7 +626,79 @@ export async function createPulseServer({
480
626
  });
481
627
  }
482
628
  }
483
- 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();
484
702
  const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
485
703
  poller.unref();
486
704
 
@@ -496,9 +714,48 @@ export async function createPulseServer({
496
714
  services: {
497
715
  imageKey,
498
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
+ }),
499
723
  ollama: {
500
724
  state: () => ollama,
501
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
+ },
502
759
  // Pulls stream into the room like a module install; one at a time.
503
760
  pull: async (model) => {
504
761
  if (installing) return { ok: false, error: `Another install is running (${installing}).` };
@@ -531,7 +788,7 @@ export async function createPulseServer({
531
788
  if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
532
789
  // Built-ins are switches the module itself defines.
533
790
  if (extension.toggle) return extension.toggle(await moduleContext(), payload);
534
- 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.') } };
535
792
  if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
536
793
  const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
537
794
  if (!preflight.ok && !installers[id]) {
@@ -589,7 +846,7 @@ export async function createPulseServer({
589
846
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
590
847
  return response.end(html);
591
848
  }
592
- 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)) {
593
850
  const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
594
851
  response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
595
852
  return response.end(js);
@@ -602,8 +859,13 @@ export async function createPulseServer({
602
859
  if (request.method === 'GET' && url.pathname === '/api/state') {
603
860
  return sendJson(response, 200, {
604
861
  projectRoot,
605
- agents,
606
- 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() },
607
869
  ripley: { enabled: await ripleyOn() },
608
870
  softTokenBudget,
609
871
  timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
@@ -674,7 +936,7 @@ export async function createPulseServer({
674
936
  // resolve to the same route.
675
937
  const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
676
938
  if (previewMatch) {
677
- 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.') });
678
940
  const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
679
941
  const relative = decodeURIComponent(previewMatch[2]);
680
942
  const file = await readServable(which, relative);
@@ -709,7 +971,7 @@ export async function createPulseServer({
709
971
  }
710
972
  if (request.method === 'GET' && url.pathname === '/api/settings') {
711
973
  await refreshPrivacy();
712
- 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) ?? {}) })) });
713
975
  }
714
976
  if (request.method === 'POST' && url.pathname === '/api/settings') {
715
977
  return sendJson(response, 200, { settings: await applySettings(await body(request)) });
@@ -722,13 +984,15 @@ export async function createPulseServer({
722
984
  }
723
985
  if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
724
986
  const patch = await body(request).catch(() => ({}));
725
- 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 });
726
990
  }
727
991
  const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
728
992
  if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
729
993
  if (sentinelMatch[2] === 'issue') {
730
994
  const issue = sentinel.issueUrl(sentinelMatch[1]);
731
- 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.') });
732
996
  }
733
997
  const outcome = await sentinel.send(sentinelMatch[1]);
734
998
  return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
@@ -740,23 +1004,99 @@ export async function createPulseServer({
740
1004
  const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
741
1005
  return sendJson(response, result.status ?? 200, result.body ?? {});
742
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
+
743
1070
  // The dataset behind MADRE AI: exported on demand next to the ledger.
744
1071
  if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
745
1072
  const dir = join(roomDir, 'dataset');
746
1073
  const notes = memory ? memory.memories({ limit: 5000 }) : [];
747
1074
  if (request.method === 'GET') {
748
1075
  const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
749
- 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() });
750
1079
  }
751
1080
  const events = await store.readAll();
752
1081
  const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
753
1082
  await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
754
- 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() });
755
1086
  }
756
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
+ }
757
1097
  if (request.method === 'GET' && url.pathname === '/api/privacy') {
758
1098
  await refreshPrivacy();
759
- 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()) });
760
1100
  }
761
1101
  if (request.method === 'POST' && url.pathname === '/api/privacy') {
762
1102
  const patch = await body(request).catch(() => ({}));
@@ -764,16 +1104,19 @@ export async function createPulseServer({
764
1104
  const next = { ...current };
765
1105
  if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
766
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);
767
1109
  await updateConfig(root, { privacy: next });
768
- privacy.set(next.terms ?? [], next.marker);
769
- await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker });
770
- 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()) });
771
1114
  }
772
1115
  if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
773
1116
  const payload = await body(request).catch(() => ({}));
774
- 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.') });
775
1118
  await refreshPrivacy();
776
- 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.') });
777
1120
  const result = await room.purgePrivate();
778
1121
  // The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
779
1122
  tailOffset = (await store.tail(0)).offset;
@@ -797,13 +1140,15 @@ export async function createPulseServer({
797
1140
  const info = await versionView();
798
1141
  if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
799
1142
  const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
800
- if (!command) return sendJson(response, 412, { error: 'This copy runs from source: pull the repository and start it again.' });
801
- if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: 'Agents are still working. STOPALL or wait, then update.' });
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.') });
802
1145
  await room.record('room.updating', { from: info.current, to: info.latest, install, command });
803
1146
  sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
804
1147
  setTimeout(() => {
805
1148
  server.close(() => {
806
- const child = spawn('/bin/sh', ['-c', command], { cwd: canonicalProjectRoot, detached: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
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' } });
807
1152
  child.unref();
808
1153
  setTimeout(() => process.exit(0), 200);
809
1154
  });
@@ -821,27 +1166,236 @@ export async function createPulseServer({
821
1166
  }
822
1167
  if (request.method === 'POST' && url.pathname === '/api/mother/code000') {
823
1168
  const payload = await body(request).catch(() => ({}));
824
- 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.') });
825
1170
  const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
826
- 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.') });
827
1172
  }
828
1173
  if (request.method === 'GET' && url.pathname === '/api/memory') {
829
- 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.') });
830
1175
  const sealed = room.motherStatus()?.lockedForMs ?? 0;
831
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 });
832
1177
  const research = room.memoryResearch();
833
- 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.') });
834
1353
  }
835
1354
  const forgetMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/memory\/(\d+)$/);
836
1355
  if (forgetMatch) {
837
1356
  const payload = await body(request).catch(() => ({}));
838
- if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
839
- 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.') });
840
1359
  const row = await room.forgetMemory(forgetMatch[1]);
841
- 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.') });
842
1361
  }
843
1362
  if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
844
- 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);
845
1399
  }
846
1400
  const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
847
1401
  if (loginMatch) {
@@ -858,7 +1412,7 @@ export async function createPulseServer({
858
1412
  }
859
1413
  if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
860
1414
  const opencode = agents.find((agent) => agent.id === 'opencode');
861
- 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.') });
862
1416
  const models = [];
863
1417
  await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
864
1418
  return sendJson(response, 200, { models });
@@ -870,24 +1424,93 @@ export async function createPulseServer({
870
1424
  const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
871
1425
  if (stopMatch) {
872
1426
  const stopped = await room.stopPlan(stopMatch[1]);
873
- 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.') });
874
1428
  }
875
1429
  if (request.method === 'GET' && url.pathname === '/api/commands') {
876
- 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] });
877
1439
  }
878
1440
  if (request.method === 'POST' && url.pathname === '/api/commands') {
879
1441
  const { text } = await body(request);
880
1442
  const parsed = parseCommand(text);
881
- if (!parsed) return sendJson(response, 400, { error: 'Not a command. Commands start with "/" followed by a name.' });
882
- const command = commandByName(parsed.name);
883
- if (!command) return sendJson(response, 404, { error: `Unknown command /${parsed.name}.` });
884
- if (!(await command.available({ projectRoot: canonicalProjectRoot }))) return sendJson(response, 412, { error: `/${parsed.name} is not available in this project (${command.title}).` });
885
- 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
+ }
886
1463
  const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
887
1464
  return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
888
1465
  }
889
1466
  if (request.method === 'GET' && url.pathname === '/api/extensions') {
890
- 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);
891
1514
  }
892
1515
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
893
1516
  if (installMatch) {
@@ -925,7 +1548,7 @@ export async function createPulseServer({
925
1548
  });
926
1549
  return sendJson(response, 200, { emitted: Boolean(warning), warning });
927
1550
  }
928
- sendJson(response, 404, { error: 'Not found.' });
1551
+ sendJson(response, 404, { error: t('Not found.') });
929
1552
  } catch (error) {
930
1553
  sendJson(response, 400, { error: error.message });
931
1554
  }
@@ -937,19 +1560,24 @@ export async function createPulseServer({
937
1560
  server.close = (callback) => {
938
1561
  clearInterval(poller);
939
1562
  clearTimeout(embedKick);
1563
+ unwatchOutbound();
940
1564
  // In-flight agent processes are killed and their turns recorded as failed
941
1565
  // before the SSE clients go away, so open pages see the outcome.
942
1566
  const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
943
1567
  let result;
944
1568
  shutdown.then(() => {
945
- void broadcastPending().then(() => {
1569
+ void broadcastPending().then(() => chatWrites).then(() => {
946
1570
  unsubscribe();
947
1571
  unsubscribeSentinel();
1572
+ unsubscribeEyecat();
1573
+ unsubscribeLive();
948
1574
  unsubscribeGhost();
949
1575
  for (const client of clients.keys()) client.end();
950
1576
  clients.clear();
951
1577
  // The memory file closes last, once nothing else writes to it; WAL and shm go with it.
952
1578
  try { memory?.close(); } catch { /* already closed */ }
1579
+ void outbound.drain();
1580
+ releaseOpen();
953
1581
  result = nativeClose(callback);
954
1582
  server.closeIdleConnections?.();
955
1583
  });
@@ -958,16 +1586,18 @@ export async function createPulseServer({
958
1586
  };
959
1587
  server.on('close', () => quotaMonitor.stop());
960
1588
 
961
- return { server, store, agents, quotaMonitor };
1589
+ return { server, store, agents, quotaMonitor, markOpen, releaseOpen };
962
1590
  }
963
1591
 
964
1592
  export async function startPulse({ port, projectRoot, openBrowser }) {
965
- const { server, agents } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
1593
+ const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
966
1594
  await new Promise((resolve, reject) => {
967
1595
  server.once('error', reject);
968
1596
  server.listen(port, '127.0.0.1', resolve);
969
1597
  });
970
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);
971
1601
  const url = `http://127.0.0.1:${address.port}`;
972
1602
  const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
973
1603
  const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';