@jossuealcala/madre 0.7.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +817 -1
  2. package/CONTRIBUTING.md +5 -1
  3. package/NOTICE +20 -1
  4. package/README.md +99 -34
  5. package/TRADEMARKS.md +65 -0
  6. package/bin/madre.mjs +30 -10
  7. package/docs/INTERNALS.md +2 -2
  8. package/docs/REFERENCE.md +159 -82
  9. package/docs/SDK.md +23 -19
  10. package/docs/madre-banner.png +0 -0
  11. package/package.json +4 -3
  12. package/public/app.js +11668 -4219
  13. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/Geist-latin.woff2 +0 -0
  15. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  16. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  17. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  18. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  19. package/public/assets/brand/OFL-Geist.txt +93 -0
  20. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  21. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  22. package/public/assets/brand/OFL-Sora.txt +93 -0
  23. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  24. package/public/assets/brand/Sora-latin.woff2 +0 -0
  25. package/public/assets/brand/madre-app-180.png +0 -0
  26. package/public/assets/brand/madre-app-192.png +0 -0
  27. package/public/assets/brand/madre-app-512.png +0 -0
  28. package/public/assets/brand/madre-app.svg +1 -0
  29. package/public/assets/brand/madre-favicon.svg +1 -0
  30. package/public/assets/news/1.0/alerts.webp +0 -0
  31. package/public/assets/news/1.0/bar.webp +0 -0
  32. package/public/assets/news/1.0/finder.webp +0 -0
  33. package/public/assets/news/1.0/github.webp +0 -0
  34. package/public/assets/news/1.0/intents.webp +0 -0
  35. package/public/assets/news/1.0/looks.webp +0 -0
  36. package/public/assets/news/1.0/modules.webp +0 -0
  37. package/public/assets/news/1.0/mother.webp +0 -0
  38. package/public/assets/news/1.0/nostromo.webp +0 -0
  39. package/public/assets/news/1.0/permissions.webp +0 -0
  40. package/public/assets/news/1.0/room.webp +0 -0
  41. package/public/assets/news/1.0/settings.webp +0 -0
  42. package/public/assets/wallpapers/biznaga.webp +0 -0
  43. package/public/assets/wallpapers/canela.webp +0 -0
  44. package/public/assets/wallpapers/jarrito.webp +0 -0
  45. package/public/assets/wallpapers/magna.webp +0 -0
  46. package/public/assets/wallpapers/oceano.webp +0 -0
  47. package/public/assets/wallpapers/paraiso.webp +0 -0
  48. package/public/assets/wallpapers/quila.webp +0 -0
  49. package/public/assets/wallpapers/tradicion.webp +0 -0
  50. package/public/assets/wallpapers/tropical.webp +0 -0
  51. package/public/assets/wallpapers/urbania.webp +0 -0
  52. package/public/assets/wallpapers/ventura.webp +0 -0
  53. package/public/assets/wallpapers/xico.webp +0 -0
  54. package/public/brands.js +28 -2
  55. package/public/editor.js +744 -0
  56. package/public/es.js +3443 -1425
  57. package/public/i18n.js +1 -1
  58. package/public/index.html +201 -175
  59. package/public/inquiry.js +11 -11
  60. package/public/loader.js +79 -0
  61. package/public/macroverso.js +1517 -0
  62. package/public/manifest.webmanifest +14 -0
  63. package/public/nostromo-engine.js +2222 -0
  64. package/public/resay.js +24 -0
  65. package/public/review-ask.js +85 -0
  66. package/public/salas-mac.css +1281 -0
  67. package/public/salas-mac.html +158 -0
  68. package/public/salas-mac.js +4593 -0
  69. package/public/salas.css +85 -0
  70. package/public/salas.html +47 -0
  71. package/public/salas.js +200 -0
  72. package/public/styles.css +6372 -426
  73. package/public/touch.js +83 -0
  74. package/public/tour-chapters.js +452 -0
  75. package/public/tour.js +546 -0
  76. package/public/troubleshooting.js +160 -141
  77. package/src/adapters/codex.mjs +46 -6
  78. package/src/adapters/gemini.mjs +30 -10
  79. package/src/adapters/madre.mjs +110 -18
  80. package/src/adapters/process.mjs +42 -2
  81. package/src/auth-probe.mjs +31 -8
  82. package/src/bridge/devices.mjs +202 -0
  83. package/src/bridge/handoff.mjs +45 -0
  84. package/src/bridge/host.mjs +82 -0
  85. package/src/bridge/network.mjs +133 -0
  86. package/src/bridge/policy.mjs +89 -0
  87. package/src/bridge/qr.mjs +287 -0
  88. package/src/bridge/server.mjs +269 -0
  89. package/src/capabilities.mjs +5 -9
  90. package/src/chats.mjs +59 -22
  91. package/src/checkpoint.mjs +137 -35
  92. package/src/choices.mjs +18 -3
  93. package/src/cli-args.mjs +12 -1
  94. package/src/commands.mjs +15 -15
  95. package/src/config.mjs +73 -9
  96. package/src/conversation-context.mjs +5 -2
  97. package/src/credentials.mjs +80 -0
  98. package/src/dataset.mjs +3 -1
  99. package/src/directives.mjs +6 -11
  100. package/src/enlaces.mjs +451 -0
  101. package/src/event-store.mjs +55 -10
  102. package/src/exam.mjs +5 -5
  103. package/src/extensions.mjs +31 -12
  104. package/src/file-ops.mjs +23 -0
  105. package/src/files.mjs +52 -2
  106. package/src/github.mjs +407 -0
  107. package/src/heartbeat.mjs +55 -0
  108. package/src/json-file.mjs +63 -0
  109. package/src/lease.mjs +2 -2
  110. package/src/lobby-work.mjs +103 -0
  111. package/src/local-ai.mjs +48 -0
  112. package/src/looks.mjs +88 -0
  113. package/src/maturity.mjs +3 -3
  114. package/src/mcp/github-server.mjs +164 -0
  115. package/src/mcp/image-server.mjs +1 -1
  116. package/src/mcp/memory-server.mjs +11 -3
  117. package/src/memory.mjs +38 -12
  118. package/src/models.mjs +127 -5
  119. package/src/modules/ahp.mjs +4 -4
  120. package/src/modules/ash.mjs +1 -1
  121. package/src/modules/bishop-plan.mjs +207 -0
  122. package/src/modules/bishop.mjs +645 -0
  123. package/src/modules/choices.mjs +2 -2
  124. package/src/modules/correo.mjs +1 -1
  125. package/src/modules/enlaces.mjs +255 -0
  126. package/src/modules/git-pulse.mjs +2 -2
  127. package/src/modules/github.mjs +322 -0
  128. package/src/modules/image-studio.mjs +3 -3
  129. package/src/modules/index.mjs +18 -4
  130. package/src/modules/intents-calibration.mjs +338 -0
  131. package/src/modules/intents-desk.mjs +481 -0
  132. package/src/modules/intents-observe.mjs +266 -0
  133. package/src/modules/intents-policy.mjs +414 -0
  134. package/src/modules/intents-try.mjs +91 -0
  135. package/src/modules/intents.mjs +145 -0
  136. package/src/modules/ollama.mjs +18 -6
  137. package/src/modules/playwright.mjs +121 -11
  138. package/src/modules/ripley.mjs +1 -1
  139. package/src/modules/runs.mjs +2 -2
  140. package/src/modules/sdk.mjs +10 -4
  141. package/src/ollama.mjs +6 -1
  142. package/src/outbound.mjs +36 -17
  143. package/src/override.mjs +100 -0
  144. package/src/ports.mjs +23 -1
  145. package/src/prices.mjs +72 -0
  146. package/src/privacy.mjs +30 -11
  147. package/src/quota-sources.mjs +0 -8
  148. package/src/request-guard.mjs +1 -1
  149. package/src/review-ask.mjs +111 -0
  150. package/src/room/archivist.mjs +55 -37
  151. package/src/room/budget.mjs +17 -5
  152. package/src/room/control.mjs +70 -7
  153. package/src/room/economy.mjs +35 -7
  154. package/src/room/escalation.mjs +3 -0
  155. package/src/room/guard.mjs +26 -2
  156. package/src/room/prompt.mjs +22 -15
  157. package/src/room/runs.mjs +84 -21
  158. package/src/room/vectors.mjs +19 -4
  159. package/src/room.mjs +411 -84
  160. package/src/router.mjs +3 -1
  161. package/src/runs.mjs +23 -5
  162. package/src/salas.mjs +313 -0
  163. package/src/sentinel-errors.mjs +1 -1
  164. package/src/server-sentinel.mjs +124 -0
  165. package/src/server.mjs +1864 -178
  166. package/src/setup.mjs +1 -0
  167. package/src/tour.mjs +174 -0
  168. package/src/updates.mjs +48 -8
  169. package/src/usage-sentinel.mjs +1 -1
  170. package/src/verdict.mjs +7 -7
  171. package/src/whats-new.mjs +160 -0
  172. package/docs/madre-banner.svg +0 -25
@@ -46,7 +46,7 @@ export async function ollamaView(probe, settings) {
46
46
 
47
47
  export default defineModule({
48
48
  id: 'ollama',
49
- name: 'OLLAMA',
49
+ name: 'Local AI',
50
50
  vendor: 'MADRE · LOCAL INTELLIGENCE',
51
51
  tracks: { name: 'ollama', github: 'ollama/ollama' },
52
52
  summary: 'Recall by meaning and memory distillation on this machine, through Ollama: no provider tokens, nothing leaves.',
@@ -59,15 +59,18 @@ export default defineModule({
59
59
  const view = await ollamaView(probe, ctx.settings);
60
60
  const binary = view.binary;
61
61
  const settings = ctx.settings;
62
- const roles = [settings.embeddings && probe.embedModel ? `${t('embeddings')} · ${probe.embedModel}` : null, settings.archivist && probe.chatModel ? `${t('archivist')} · ${probe.chatModel}` : null, settings.agent !== false && probe.chatModel ? t('@madre in the room') : null].filter(Boolean);
62
+ const roles = [settings.embeddings && probe.embedModel ? `${t('smart search')} · ${probe.embedModel}` : null, settings.archivist && probe.chatModel ? `${t('archivist')} · ${probe.chatModel}` : null, settings.agent !== false && probe.chatModel ? t('@madre in the project') : null].filter(Boolean);
63
+ // Home can leave this one project without the local AI while the module stays on (src/local-ai.mjs).
64
+ const project = await ctx.services.ollama?.project?.() ?? { on: true };
63
65
  const detail = !probe.running ? (binary ? t('installed, not running · START it here') : t('not installed · INSTALL it here'))
64
66
  : !settings.enabled ? t('off · {n} models available', { n: probe.models.length })
67
+ : !project.on ? t('off in this project · Home › Settings › Local AI')
65
68
  : roles.length ? `${t('on')} · ${roles.join(' · ')}` : t('on · no usable model yet · PULL one');
66
69
  return {
67
70
  runs: [{ name: 'ollama', version: probe.version ?? null }],
68
71
  models: probe.models.map((model) => model.name),
69
- status: { installed: settings.enabled && probe.running && roles.length > 0, detail },
70
- ollama: view,
72
+ status: { installed: settings.enabled && project.on && probe.running && roles.length > 0, detail },
73
+ ollama: { ...view, project },
71
74
  recommended: RECOMMENDED,
72
75
  preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
73
76
  install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
@@ -82,8 +85,17 @@ export default defineModule({
82
85
  return { ...plan, note: `${plan.note} If an older Ollama is still the one answering, MADRE restarts it so the new one does.`, after: () => ctx.services.ollama.restart?.() ?? null };
83
86
  },
84
87
  async toggle(ctx) {
88
+ // Outside «auto» the switch on this card is this project's: it never turns the local AI off
89
+ // for every other project (src/local-ai.mjs). In «auto», or with it off for all, it is the
90
+ // switch for every project, as it always was.
91
+ const project = await ctx.services.ollama?.project?.();
92
+ if (project?.editable && project.master) {
93
+ const status = await ctx.services.ollama.setProject(!project.chosen);
94
+ await ctx.record('extension.toggled', { id: 'ollama', name: 'OLLAMA', enabled: !project.chosen, project: true });
95
+ return { status: 200, body: { enabled: !project.chosen, ollama: status } };
96
+ }
85
97
  const enabled = !(ctx.settings.enabled ?? true);
86
- await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
98
+ await ctx.updateConfig({ modules: { ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
87
99
  const status = await ctx.services.ollama.wire();
88
100
  await ctx.record('extension.toggled', { id: 'ollama', name: 'OLLAMA', enabled });
89
101
  return { status: 200, body: { enabled, ollama: status } };
@@ -94,7 +106,7 @@ export default defineModule({
94
106
  { method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
95
107
  const next = { ...(ctx.config.modules?.ollama ?? {}) };
96
108
  for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];
97
- await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: next } });
109
+ await ctx.updateConfig({ modules: { ollama: next } });
98
110
  return { status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }) } };
99
111
  } },
100
112
  { method: 'POST', path: '/api/ollama/install', handler: async (ctx) => {
@@ -3,10 +3,14 @@
3
3
  // scratch folder. Runs @playwright/mcp per turn, isolated, with allowed origins limited to the
4
4
  // room's own address: nothing else on the network is reachable through it.
5
5
 
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdtemp, rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
6
9
  import { join } from 'node:path';
7
10
  import { t } from '../i18n.mjs';
8
11
  import { defineModule } from './sdk.mjs';
9
12
  import { packageVersion } from './helpers.mjs';
13
+ import { terminateProcessTree, lastWords } from '../adapters/process.mjs';
10
14
 
11
15
  export const PLAYWRIGHT_PACKAGE = '@playwright/mcp';
12
16
  export const PLAYWRIGHT_BROWSERS = ['chromium', 'firefox', 'webkit'];
@@ -25,7 +29,94 @@ export async function playwrightVersion({ env = process.env, projectRoot = proce
25
29
  return version;
26
30
  }
27
31
 
28
- export function playwrightServerFor({ port, outputDir, browser = 'chromium', headless = true }) {
32
+ // Does the browser server start, with its browser? Asked when the switch is turned on, with the
33
+ // same server a turn gets: the card said «on · chromium» while every turn's server closed before it
34
+ // spoke, and the agents saw only CONNECTION_CLOSED. It is asked to open about:blank, because a
35
+ // server without its browser starts fine and fails only at the first page.
36
+ export async function playwrightStarts({ browser = 'chromium', env = process.env, cwd = process.cwd(), timeoutMs = 60000, command = 'npx', args = null } = {}) {
37
+ // npx is a .cmd shim on Windows and there is no shell to run it (WIN-027): not asked there.
38
+ if (process.platform === 'win32' && command === 'npx') return { ok: true, checked: false };
39
+ const outputDir = await mkdtemp(join(tmpdir(), 'madre-playwright-'));
40
+ const argv = args ?? ['--no', PLAYWRIGHT_PACKAGE, '--', '--headless', '--isolated', '--browser', browser, '--output-dir', outputDir, '--no-sandbox'];
41
+ return new Promise((resolve) => {
42
+ let child = null;
43
+ let out = '';
44
+ let err = '';
45
+ let settled = false;
46
+ let timer = null;
47
+ const finish = (result) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ clearTimeout(timer);
51
+ if (child) terminateProcessTree(child, { graceMs: 1000 });
52
+ rm(outputDir, { recursive: true, force: true }).catch(() => {});
53
+ resolve(result);
54
+ };
55
+ try {
56
+ child = spawn(command, argv, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'], shell: false, detached: true, windowsHide: true });
57
+ } catch (error) {
58
+ finish({ ok: false, problem: error.message });
59
+ return;
60
+ }
61
+ timer = setTimeout(() => finish({ ok: false, problem: t('it did not answer within {seconds}s', { seconds: Math.round(timeoutMs / 1000) }) }), timeoutMs);
62
+ timer.unref?.();
63
+ const send = (message) => { if (!settled) child.stdin.write(`${JSON.stringify(message)}\n`); };
64
+ child.stdin.on('error', () => {});
65
+ child.stdout.setEncoding('utf8');
66
+ child.stderr.setEncoding('utf8');
67
+ child.stdout.on('data', (chunk) => {
68
+ out += chunk;
69
+ for (let newline = out.indexOf('\n'); newline >= 0; newline = out.indexOf('\n')) {
70
+ const line = out.slice(0, newline).trim();
71
+ out = out.slice(newline + 1);
72
+ let message;
73
+ try { message = JSON.parse(line); } catch { continue; }
74
+ if (message.id === 1) {
75
+ if (message.error) { finish({ ok: false, problem: String(message.error.message ?? 'initialize failed') }); return; }
76
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
77
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'browser_navigate', arguments: { url: 'about:blank' } } });
78
+ } else if (message.id === 2) {
79
+ // The reason, without the path of the executable it looked for or the command it suggests:
80
+ // the card and the refusal say what to run, in the room's words.
81
+ const said = (message.result?.content ?? []).map((part) => part?.text ?? '').join(' ').replace(/^#+\s*Error\s*/i, '').replace(/^Error:\s*/i, '').replace(/;\s*expected executable at[\s\S]*$/i, '').replace(/\s+/g, ' ').trim();
82
+ if (message.error || message.result?.isError) { finish({ ok: false, problem: String(message.error?.message ?? said).slice(0, 300) }); return; }
83
+ finish({ ok: true, checked: true });
84
+ return;
85
+ }
86
+ }
87
+ });
88
+ child.stderr.on('data', (chunk) => { err += chunk; });
89
+ child.on('error', (error) => finish({ ok: false, problem: error.message }));
90
+ child.on('close', (code) => finish({ ok: false, problem: lastWords(err) ?? t('it exited with code {code} before it spoke', { code }) }));
91
+ send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'madre', version: '1' } } });
92
+ });
93
+ }
94
+
95
+ // A turn that asks to look at a page, click through it, test it, read its console or take a picture
96
+ // of it, in the two languages the crew is spoken to here. A page merely named is not one: «cambia el
97
+ // lema de la página» needs no browser.
98
+ export const BROWSER_INTENT = /navegador|browser|playwright|captura|screenshot|pantallazo|\bclic|\bclick|consola|console|responsive|m[oó]vil|mobile|viewport|ancho de|se ve\b|c[oó]mo (se )?ve|visual|vista previa|preview|ripley|interfaz|\bui\b|\bux\b|e2e|end.to.end|pru[eé]ba(lo|la|las|los)? en|test (it|the page|in the browser)|abre (la|el) (p[aá]gina|app|sitio)|open the (page|app|site)|smoke test/i;
99
+ export const wantsBrowser = (text) => BROWSER_INTENT.test(String(text ?? ''));
100
+
101
+ // The last answer to «does it start?», for a switch that was already on: asked again in the
102
+ // background at most every ten minutes, so the card can say the browser is missing and no turn
103
+ // carries a server that would only fail.
104
+ let started = { at: 0, result: null, asking: null };
105
+ export function playwrightHealth({ browser = 'chromium', env = process.env, cwd = process.cwd(), now = Date.now(), check = playwrightStarts } = {}) {
106
+ if (env.PULSE_PLAYWRIGHT_CHECK === '0') return started.result;
107
+ if (!started.asking && now - started.at > 10 * 60 * 1000) {
108
+ started.asking = check({ browser, env, cwd }).then((result) => { started = { at: Date.now(), result, asking: null }; return result; }, () => { started.asking = null; return null; });
109
+ }
110
+ return started.result;
111
+ }
112
+ export function forgetPlaywrightHealth(result = null) { started = { at: result ? Date.now() : 0, result, asking: null }; }
113
+
114
+ // What the browser may open: this room, and the servers the human started from RUNS in this project
115
+ // while they listen on 127.0.0.1. RIPLEY's sealed frame keeps no data, so an app that stores its
116
+ // data could only be tried through it half-blind. Nothing else on the network.
117
+ export function playwrightServerFor({ port, outputDir, browser = 'chromium', headless = true, servers = [] }) {
118
+ const origins = [port, ...servers.filter((one) => Number.isInteger(one) && one !== port)].flatMap((one) => [`http://127.0.0.1:${one}`, `http://localhost:${one}`]);
119
+ const served = servers.filter((one) => Number.isInteger(one) && one !== port);
29
120
  return {
30
121
  name: PLAYWRIGHT_SERVER_NAME,
31
122
  command: 'npx',
@@ -36,21 +127,21 @@ export function playwrightServerFor({ port, outputDir, browser = 'chromium', hea
36
127
  // abort and then routes each allowed origin to continue, and Playwright matches the most
37
128
  // recently added route first — so a blocked `*`, registered after both, wins over the
38
129
  // allowlist and aborts even this room with ERR_BLOCKED_BY_CLIENT.
39
- args: ['--no', '@playwright/mcp', '--', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--output-dir', outputDir, '--no-sandbox'],
130
+ args: ['--no', '@playwright/mcp', '--', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', origins.join(';'), '--output-dir', outputDir, '--no-sandbox'],
40
131
  env: {},
41
132
  tools: PLAYWRIGHT_TOOLS,
42
- brief: `a headless browser that reaches only this MADRE at http://127.0.0.1:${port}. Open a project file rendered by RIPLEY at http://127.0.0.1:${port}/preview/project/<path>, click, read the console and network, take screenshots (they land in ${outputDir}). Nothing else on the network is reachable through it.`,
133
+ brief: `a headless browser that reaches only this MADRE at http://127.0.0.1:${port}${served.length ? ` and the project's servers the human started (${served.map((one) => `http://127.0.0.1:${one}`).join(', ')})` : ''}. Open a project file rendered by RIPLEY at http://127.0.0.1:${port}/preview/project/<path>, click, read the console and network, take screenshots (they land in ${outputDir}). RIPLEY's preview keeps no data (no localStorage, no cookies)${served.length ? '; to try an app with its data, open it on its server' : ''}. Nothing else on the network is reachable through it.`,
43
134
  };
44
135
  }
45
136
 
46
137
  export default defineModule({
47
138
  id: 'playwright',
48
- name: 'PLAYWRIGHT',
139
+ name: 'Test browser',
49
140
  vendor: 'MADRE · Playwright MCP',
50
141
  tracks: { name: PLAYWRIGHT_PACKAGE, npm: PLAYWRIGHT_PACKAGE },
51
- summary: 'Hands every agent a headless browser that reaches only this room: it opens the RIPLEY preview of a page, clicks through it, reads the console and takes screenshots.',
52
- creates: ['nothing in the project \u00b7 screenshots land in .pulse/out/<turn>/', 'a switch in ~/.pulse/config.json', 'a browser per turn, started and stopped by the CLI', 'no origin but this room is reachable through it'],
53
- requires: ['@playwright/mcp and a chromium browser on this machine', 'RIPLEY on, to have pages to open'],
142
+ summary: 'Hands an agent a headless browser that reaches only this project and the servers you start from COMMANDS, on the turns that ask to look at a page, click through it, read its console or take screenshots.',
143
+ creates: ['nothing in the project \u00b7 screenshots land in .pulse/out/<turn>/', 'a switch in ~/.pulse/config.json', 'a browser per turn, started and stopped by the CLI', 'no origin but this project is reachable through it'],
144
+ requires: ['@playwright/mcp and a chromium browser on this machine', 'PREVIEW on, to have pages to open'],
54
145
  settings: { enabled: false, browser: 'chromium', headless: true },
55
146
  card: 'switch',
56
147
  controls: [
@@ -59,10 +150,11 @@ export default defineModule({
59
150
  ],
60
151
  async status(ctx) {
61
152
  const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
153
+ const health = ctx.settings.enabled && version ? playwrightHealth({ browser: ctx.settings.browser ?? 'chromium', env: ctx.env, cwd: ctx.projectRoot }) : null;
62
154
  return {
63
155
  runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
64
156
  settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
65
- status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (version ? `${t('on')} · ${ctx.settings.browser}` : t('on · the browser server is not installed')) : version ? t('off') : t('off · the browser server is not installed') },
157
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (!version ? t('on · the browser server is not installed') : health?.ok === false ? t('on · the browser does not start: {problem}', { problem: String(health.problem).slice(0, 160) }) : `${t('on')} · ${ctx.settings.browser}`) : version ? t('off') : t('off · the browser server is not installed') },
66
158
  preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp, then its browser: npx @playwright/mcp install-browser chrome-for-testing'] },
67
159
  install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
68
160
  };
@@ -82,16 +174,34 @@ export default defineModule({
82
174
  async toolsForTurn(ctx, turn) {
83
175
  if (!(await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot }))) return [];
84
176
  if (turn.mode === 0) return []; // a ghost turn leaves no screenshots and opens no browser
177
+ // Only for a turn that asks for what a browser does. Its tool list goes into every call the CLI
178
+ // makes inside the turn: a one-line change cost Codex 576 thousand tokens of reading, and the
179
+ // browser it carried was never opened.
180
+ if (turn.text !== undefined && !wantsBrowser(turn.text)) return [];
181
+ // A browser known not to start is not handed to anyone: every call to it would fail.
182
+ if (playwrightHealth({ browser: ctx.settings.browser ?? 'chromium', env: ctx.env, cwd: ctx.projectRoot })?.ok === false) return [];
85
183
  const outputDir = turn.scratchDir ?? join(turn.roomDir ?? ctx.stateRoot, 'playwright');
86
- return [playwrightServerFor({ port: turn.port, outputDir, browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false })];
184
+ return [playwrightServerFor({ port: turn.port, outputDir, browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false, servers: turn.servers ?? [] })];
185
+ },
186
+ // The switch, guarded: on only if the server starts and has its browser. Off is always allowed.
187
+ async toggle(ctx) {
188
+ const enabled = !ctx.settings.enabled;
189
+ if (enabled) {
190
+ const check = await playwrightStarts({ browser: ctx.settings.browser ?? 'chromium', env: ctx.env, cwd: ctx.projectRoot });
191
+ forgetPlaywrightHealth(check);
192
+ if (!check.ok) return { status: 412, body: { error: t('TEST BROWSER did not start, so it stays off: {problem}. Install the server and its browser (npm install -g @playwright/mcp, then npx @playwright/mcp install-browser chrome-for-testing) and turn it on again.', { problem: String(check.problem).replace(/[.\s]+$/, '') }), code: 'MU-054' } };
193
+ }
194
+ await ctx.updateConfig({ modules: { playwright: { ...(ctx.config.modules?.playwright ?? {}), enabled } } });
195
+ await ctx.record('extension.toggled', { id: 'playwright', name: 'PLAYWRIGHT', enabled });
196
+ return { status: 200, body: { enabled } };
87
197
  },
88
198
  conditions: [{
89
199
  id: 'playwright-missing',
90
200
  code: 'MU-054',
91
201
  severity: 'informational',
92
- title: 'PLAYWRIGHT: the browser server is not installed',
202
+ title: 'TEST BROWSER: the browser server is not installed',
93
203
  match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
94
- diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn. Two different things have to be on this computer and only one of them is the package: the server, and the browser build that this version of it expects. A server that starts without its browser answers every tool call with "Browser … is not installed", and one that cannot start at all reaches the agent as CONNECTION_CLOSED.',
204
+ diagnosis: 'The TEST BROWSER module runs @playwright/mcp per turn. Two different things have to be on this computer and only one of them is the package: the server, and the browser build that this version of it expects. A server that starts without its browser answers every tool call with "Browser … is not installed", and one that cannot start at all reaches the agent as CONNECTION_CLOSED.',
95
205
  remedy: 'Install the server once, globally, then its browser — the browser is downloaded by the server itself, not by the playwright CLI — and RECHECK in MODULES.',
96
206
  fixes: { darwin: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'], linux: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'] },
97
207
  }],
@@ -6,7 +6,7 @@ import { t } from '../i18n.mjs';
6
6
 
7
7
  export default defineModule({
8
8
  id: 'ripley',
9
- name: 'RIPLEY',
9
+ name: 'Preview',
10
10
  vendor: 'MADRE · PREVIEW',
11
11
  version: '1.0.0',
12
12
  summary: 'Renders HTML, SVG and Markdown from the project in the file viewer, inside a sealed frame, and opens a browser on the servers answering on this computer.',
@@ -19,10 +19,10 @@ import { t } from '../i18n.mjs';
19
19
  export default defineModule({
20
20
  id: 'runs',
21
21
  configKey: 'runs',
22
- name: 'Runs',
22
+ name: 'Commands',
23
23
  vendor: 'MADRE',
24
24
  version: '1.0.0',
25
- summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a checkpoint is taken first, and the output comes back to the room. Costs about 190 tokens of instruction per turn below AIRLOCK while it is on.',
25
+ summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a restore point is taken first, and the output comes back to the project. Costs about 300 tokens of instruction per turn below FULL ACCESS while it is on.',
26
26
  creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
27
27
  async status(ctx) {
28
28
  return {
@@ -103,8 +103,14 @@ export function defineModule(spec) {
103
103
  if (!control?.key) throw new Error(`Module ${spec.id}: a control needs a key.`);
104
104
  const type = control.type ?? 'switch';
105
105
  if (!['select', 'switch', 'text'].includes(type)) throw new Error(`Module ${spec.id}: control ${control.key} has no such type "${type}".`);
106
- if (type === 'select' && !control.options?.length) throw new Error(`Module ${spec.id}: control ${control.key} is a select with no options.`);
107
- return { key: control.key, label: control.label ?? control.key.toUpperCase(), type, options: control.options ?? [], note: control.note ?? '', invert: Boolean(control.invert) };
106
+ // A select may say, for each value it saves, the words a person reads: `choices` is
107
+ // [{ value, label }], and its values are the options. The label is prose, translated with
108
+ // the rest of the card; the value is what is saved, never shown.
109
+ const choices = type === 'select' && Array.isArray(control.choices) ? control.choices.filter((choice) => typeof choice?.value === 'string').map((choice) => ({ value: choice.value, label: String(choice.label ?? choice.value) })) : null;
110
+ const options = control.options ?? choices?.map((choice) => choice.value) ?? [];
111
+ if (type === 'select' && !options.length) throw new Error(`Module ${spec.id}: control ${control.key} is a select with no options.`);
112
+ if (choices && choices.some((choice) => !options.includes(choice.value))) throw new Error(`Module ${spec.id}: control ${control.key} has a choice that is not one of its options.`);
113
+ return { key: control.key, label: control.label ?? control.key.toUpperCase(), type, options, ...(choices ? { choices } : {}), note: control.note ?? '', invert: Boolean(control.invert) };
108
114
  }),
109
115
  // Where this module's traffic goes, in the same four answers `src/outbound.mjs` demands of
110
116
  // MADRE's own destinations. Without it a connector's traffic reads in the log as «an address
@@ -197,7 +203,7 @@ export function defineModule(spec) {
197
203
  next = String(value ?? '').slice(0, 500);
198
204
  }
199
205
  const settings = { ...settingsFrom(ctx.config), [key]: next };
200
- await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), [key]: next } } });
206
+ await ctx.updateConfig({ modules: { [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), [key]: next } } });
201
207
  if (spec.onSettings) await spec.onSettings(mine(ctx, settings), settings);
202
208
  return { status: 200, body: { settings: Object.fromEntries(base.controls.map((known) => [known.key, settings[known.key] ?? null])) } };
203
209
  } : null,
@@ -209,7 +215,7 @@ export function defineModule(spec) {
209
215
  if (spec.toggle) return spec.toggle(mine(ctx, settings), payload);
210
216
  const enabled = !settings.enabled;
211
217
  if (enabled && spec.confirm && payload.confirm !== true) return { status: 400, body: { error: spec.confirm } };
212
- await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
218
+ await ctx.updateConfig({ modules: { [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
213
219
  const after = spec.onToggle ? await spec.onToggle(mine(ctx, { ...settings, enabled }), enabled) : null;
214
220
  await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled, ...(spec.toggledEvent ?? {}) });
215
221
  return { status: 200, body: { enabled, ...(after ?? {}), ...(spec.toggledBody?.(enabled) ?? {}) } };
package/src/ollama.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  // so remembering costs no tokens elsewhere and nothing leaves. Nothing here is
4
4
  // required: without Ollama every path falls back to what it did before.
5
5
 
6
+ import { t } from './i18n.mjs';
7
+
6
8
  export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
7
9
  // Embedding models we know how to talk to, best first.
8
10
  export const EMBED_MODELS = ['nomic-embed-text', 'mxbai-embed-large', 'snowflake-arctic-embed', 'all-minilm', 'bge-m3'];
@@ -94,7 +96,10 @@ export function ollamaEmbedder({ host = ollamaHost(), model, fetchImpl = globalT
94
96
  export async function ollamaGenerate({ host = ollamaHost(), model, prompt, system = null, json = false, fetchImpl = globalThis.fetch, timeoutMs = 180000, temperature = 0.2, numCtx = DEFAULT_NUM_CTX } = {}) {
95
97
  const started = Date.now();
96
98
  const messages = [...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: prompt }];
97
- const response = await fetchImpl(`${host}/api/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, messages, stream: false, options: { temperature, num_ctx: numCtx }, ...(json ? { format: 'json' } : {}) }), signal: AbortSignal.timeout(timeoutMs) });
99
+ // A model that takes longer than the turn allows says so in the room's words, as any agent does:
100
+ // the bare «The operation was aborted due to timeout» reached the person, in English.
101
+ const response = await fetchImpl(`${host}/api/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, messages, stream: false, options: { temperature, num_ctx: numCtx }, ...(json ? { format: 'json' } : {}) }), signal: AbortSignal.timeout(timeoutMs) })
102
+ .catch((error) => { throw error?.name === 'TimeoutError' ? new Error(t('{label} did not respond before the timeout ({seconds}s).', { label: `Ollama (${model})`, seconds: Math.round(timeoutMs / 1000) })) : error; });
98
103
  if (!response.ok) throw new Error(`Ollama HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
99
104
  const payload = await response.json();
100
105
  const inputTokens = payload.prompt_eval_count ?? 0;
package/src/outbound.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // would be a poor place to leave it.
23
23
 
24
24
  import { appendFile, mkdir, open, stat, writeFile } from 'node:fs/promises';
25
+ import { t } from './i18n.mjs';
25
26
  import { dirname } from 'node:path';
26
27
 
27
28
  export const KEEP = 200; // lines held for the screen
@@ -50,14 +51,14 @@ export const DESTINATIONS = [
50
51
  id: 'embeddings', host: 'generativelanguage.googleapis.com', match: (url) => /batchEmbedContents/.test(url.pathname), to: 'Google · Gemini embeddings',
51
52
  what: 'The text of what is being embedded: the first 2000 characters of each memory, and the question a recall is made of.',
52
53
  when: 'a memory is written or recalled, while embeddings are set to Gemini',
53
- where: 'MU/TH/UR → MEMORY → EMBEDDINGS · OLLAMA keeps it on this computer, OFF drops back to words',
54
+ where: 'Settings › Memory › Smart search · Ollama keeps it on this computer, off drops back to words',
54
55
  inside: true,
55
56
  },
56
57
  {
57
58
  id: 'image', host: 'generativelanguage.googleapis.com', match: (url) => /generateContent/.test(url.pathname), to: 'Google · Gemini image model',
58
59
  what: 'The prompt an agent wrote for an image.',
59
60
  when: 'an agent calls the image tool during a lease',
60
- where: 'MODULES → IMAGE STUDIO',
61
+ where: 'Modules › Images',
61
62
  // It runs in a process of its own and writes its own line into this same log, so it is here.
62
63
  inside: true,
63
64
  },
@@ -65,35 +66,51 @@ export const DESTINATIONS = [
65
66
  id: 'npm', host: 'registry.npmjs.org', to: 'the npm registry',
66
67
  what: 'The name of a package, to read the version of its latest release. Nothing about you or your project.',
67
68
  when: 'at most once a day per package, while the release channel is on',
68
- where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
69
+ where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
69
70
  inside: true,
70
71
  },
71
72
  {
72
- id: 'github', host: 'api.github.com', to: 'GitHub',
73
+ // Only the release read: the rest of api.github.com is the GitHub module's, and says so itself.
74
+ id: 'github', host: 'api.github.com', match: (url) => /\/releases\/latest$/.test(url.pathname), to: 'GitHub',
73
75
  what: 'The name of a repository, to read the tag of its latest release. Nothing about you or your project.',
74
76
  when: 'at most once a day per repository, while the release channel is on',
75
- where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
77
+ where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
78
+ inside: true,
79
+ },
80
+ {
81
+ // «Still in use» (src/heartbeat.mjs): the only request MADRE makes on its own that has no switch.
82
+ id: 'alive', host: 'madre.run', match: (url) => url.pathname === '/api/ping', to: 'madre.run · still in use',
83
+ what: 'A random identifier this copy made for itself, the MADRE version, the system and the Node version. No name, no email, no IP kept, nothing from your projects.',
84
+ when: 'once a day',
85
+ where: 'Always on: it is how the author knows how many people use MADRE',
86
+ inside: true,
87
+ },
88
+ {
89
+ id: 'usage', host: 'madre.run', match: (url) => url.pathname === '/api/usage', to: "madre.run · the author's usage figures",
90
+ what: "The author's token, to read how many copies are on. Only in the author's own copy, run from source.",
91
+ when: 'while the «MADRE usage» widget is on Home',
92
+ where: "Only in the author's copy",
76
93
  inside: true,
77
94
  },
78
95
  {
79
96
  id: 'reports', host: new URL(DEFAULT_REPORT_URL).hostname, to: "the author's error collector",
80
97
  what: 'An unknown condition: what broke, where in MADRE, and the version. Paths are cut back and your words are not in it.',
81
98
  when: 'you press SEND — or on its own, only while auto-report is on',
82
- where: 'MU/TH/UR → AUTO-REPORT UNKNOWN CONDITIONS · or PULSE_REPORT_URL',
99
+ where: 'Settings › Version and reports › Report automatically · or PULSE_REPORT_URL',
83
100
  inside: true,
84
101
  },
85
102
  {
86
103
  id: 'anthropic', host: 'api.anthropic.com', to: 'Anthropic',
87
104
  what: 'Your Claude Code token, to read how much of your plan is left. It is read from where Claude keeps it and never stored by MADRE.',
88
105
  when: 'while a Claude session is being watched for its quota',
89
- where: '⚙ CONNECTIONS → CLAUDE',
106
+ where: 'Settings › Agents › Claude',
90
107
  inside: true,
91
108
  },
92
109
  {
93
110
  id: 'ollama', host: null, local: true, to: 'Ollama, on this computer',
94
111
  what: 'Memories to embed, exchanges to distil, and whatever you ask @madre. It goes to a port on this machine and stops there.',
95
112
  when: 'while Ollama is running',
96
- where: 'MODULES → OLLAMA',
113
+ where: 'Modules › Local AI',
97
114
  inside: true,
98
115
  },
99
116
  ];
@@ -131,7 +148,6 @@ export function declareDestinations(moduleId, reaches = []) {
131
148
  }
132
149
  }
133
150
  export function clearDeclaredDestinations() { declaredById.clear(); }
134
- export const declaredDestinations = () => [...declaredById.values()];
135
151
 
136
152
  // Which declaration a URL belongs to. An address nobody declared is said to be exactly that.
137
153
  export function classify(rawUrl, { reportHost = null } = {}) {
@@ -139,6 +155,8 @@ export function classify(rawUrl, { reportHost = null } = {}) {
139
155
  try { url = new URL(rawUrl); } catch { return { id: 'unknown', to: String(rawUrl).slice(0, 80), local: false }; }
140
156
  const host = url.hostname.replace(/^\[|\]$/g, '');
141
157
  if (LOCAL.has(host)) return { id: url.port === '11434' ? 'ollama' : 'room', to: `${host}:${url.port || '80'}`, local: true };
158
+ // madre.run carries more than the collector: a declaration that names its path is asked first.
159
+ for (const one of DESTINATIONS) if (one.match && one.host === host && one.match(url)) return { id: one.id, to: one.to, local: false };
142
160
  if (reportHost && host === reportHost) return { id: 'reports', to: byId.get('reports').to, local: false };
143
161
  for (const one of [...DESTINATIONS, ...declaredById.values()]) {
144
162
  if (!one.host || one.host !== host) continue;
@@ -302,14 +320,15 @@ export class OutboundLog {
302
320
  // The declaration with today's answer filled in: is this one on, and what has it done.
303
321
  export function outboundView({ log = null, state = {}, agents = [] } = {}) {
304
322
  const counts = log?.counts() ?? {};
305
- const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
306
- const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? 'its own provider'}`);
323
+ // Said in the room's language (2026-10-12): the list read in English inside a Spanish room.
324
+ const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: t('the provider OpenCode is signed in to') };
325
+ const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? t('its own provider')}`);
307
326
  const destinations = DESTINATIONS.map((one) => ({
308
327
  id: one.id,
309
- to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
310
- what: one.what,
311
- when: one.when,
312
- where: one.where,
328
+ to: one.id === 'crew' && crew.length ? crew.join(' · ') : t(one.to),
329
+ what: t(one.what),
330
+ when: t(one.when),
331
+ where: t(one.where),
313
332
  local: Boolean(one.local),
314
333
  // Whether the call lands in the log at all. Everything MADRE starts does, the image studio
315
334
  // from its own process included; the crew's own conversation with its provider does not.
@@ -325,7 +344,7 @@ export function outboundView({ log = null, state = {}, agents = [] } = {}) {
325
344
  recent: log?.recent({ limit: 40 }) ?? [],
326
345
  undeclared,
327
346
  says: undeclared
328
- ? `${undeclared} request${undeclared === 1 ? '' : 's'} went to an address nothing here declares. A module can do that; nothing can do it unlogged.`
329
- : 'Every request this process made went to an address declared above.',
347
+ ? (undeclared === 1 ? t('1 request went to an address nothing here declares. A module can do that; nothing can do it unlogged.') : t('{n} requests went to an address nothing here declares. A module can do that; nothing can do it unlogged.', { n: undeclared }))
348
+ : t('Every request this process made went to an address declared above.'),
330
349
  };
331
350
  }
@@ -0,0 +1,100 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+
3
+ // How #3 CONTROL and #4 AIRLOCK are unlocked, as the person chose it in AJUSTES › Permissions.
4
+ //
5
+ // The key is checked here, by the project, since 2026-10-12 (SRV-01 of the 1.0 review): before,
6
+ // only the browser checked it, and two POSTs from any process on the Mac opened #4 with no key.
7
+ // The page asks for the key and sends what was typed; `keyHolds` decides. Four rules never move,
8
+ // whatever the file says:
9
+ // · #4 always asks for something — there is no «just confirm» for a mode that runs commands;
10
+ // · a personal phrase is never stored, only its salted SHA-256; the phrase typed to unlock is
11
+ // compared here and forgotten, and the fingerprint never leaves the project (`publicOverride`);
12
+ // · the grace is short: #3 up to 30 minutes, #4 up to 5, and off unless chosen;
13
+ // · the project name stops a slip, not a program: any process can read a folder's name. The
14
+ // phrase is the key a program on the Mac cannot know.
15
+ export const OVERRIDE_KEYS = Object.freeze({
16
+ 3: Object.freeze(['designation', 'phrase', 'confirm']),
17
+ 4: Object.freeze(['designation-airlock', 'phrase-airlock', 'designation']),
18
+ });
19
+ export const OVERRIDE_GRACE = Object.freeze({
20
+ 3: Object.freeze([0, 5, 15, 30]),
21
+ 4: Object.freeze([0, 5]),
22
+ });
23
+ const DEFAULT_KEYS = { 3: 'designation', 4: 'designation-airlock' };
24
+
25
+ const HEX = (length) => new RegExp(`^[0-9a-f]{${length}}$`);
26
+ function cleanPhrase(phrase) {
27
+ if (!phrase || typeof phrase !== 'object') return null;
28
+ const salt = String(phrase.salt ?? '').toLowerCase();
29
+ const hash = String(phrase.hash ?? '').toLowerCase();
30
+ return HEX(32).test(salt) && HEX(64).test(hash) ? { salt, hash } : null;
31
+ }
32
+
33
+ // Whatever is on disk, read as something the room can act on: unknown keys fall back to the
34
+ // default, a key that needs a phrase falls back when there is no phrase.
35
+ export function overrideSettings(stored = {}) {
36
+ const source = stored && typeof stored === 'object' ? stored : {};
37
+ const phrase = cleanPhrase(source.phrase);
38
+ const keys = {};
39
+ const grace = {};
40
+ for (const mode of [3, 4]) {
41
+ let key = source.keys?.[mode] ?? source.keys?.[String(mode)];
42
+ if (!OVERRIDE_KEYS[mode].includes(key)) key = DEFAULT_KEYS[mode];
43
+ if (key.startsWith('phrase') && !phrase) key = DEFAULT_KEYS[mode];
44
+ keys[mode] = key;
45
+ const minutes = Number(source.grace?.[mode] ?? source.grace?.[String(mode)] ?? 0);
46
+ grace[mode] = OVERRIDE_GRACE[mode].includes(minutes) ? minutes : 0;
47
+ }
48
+ return { keys, grace, phrase };
49
+ }
50
+
51
+ // A change from AJUSTES over what is stored. `phrase: null` forgets the phrase, and with it any
52
+ // key that needed it.
53
+ export function mergeOverride(current = {}, patch = {}) {
54
+ const base = overrideSettings(current);
55
+ const next = {
56
+ keys: { ...base.keys, ...(patch.keys && typeof patch.keys === 'object' ? patch.keys : {}) },
57
+ grace: { ...base.grace, ...(patch.grace && typeof patch.grace === 'object' ? patch.grace : {}) },
58
+ phrase: 'phrase' in patch ? patch.phrase : base.phrase,
59
+ };
60
+ return overrideSettings(next);
61
+ }
62
+
63
+ // What the pages are told: which key, the grace, and whether a phrase exists — never its
64
+ // fingerprint, which a program could otherwise read and try words against at leisure.
65
+ export function publicOverride(settings) {
66
+ const { keys, grace, phrase } = overrideSettings(settings);
67
+ return { keys, grace, phrase: phrase ? { set: true } : null };
68
+ }
69
+
70
+ // The fingerprint of a phrase: the same words however they were spaced or capitalised.
71
+ export function phraseHash(salt, phrase) {
72
+ const words = String(phrase ?? '').normalize('NFC').trim().replace(/\s+/g, ' ').toLowerCase();
73
+ return createHash('sha256').update(`${salt}:${words}`).digest('hex');
74
+ }
75
+ const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
76
+
77
+ // Whether what was typed opens this mode with the key chosen for it. `words` holds what was typed,
78
+ // once, or twice for a key that #4 asks for twice; `designation` is the project's folder name.
79
+ export function keyHolds(stored, { mode, words = [], designation = '' } = {}) {
80
+ if (mode !== 3 && mode !== 4) return false;
81
+ const settings = overrideSettings(stored);
82
+ const typed = (Array.isArray(words) ? words : []).map((word) => (typeof word === 'string' ? word.trim() : ''));
83
+ const isProject = (word) => Boolean(word) && Boolean(designation) && word.toLowerCase() === String(designation).toLowerCase();
84
+ const isPhrase = (word) => Boolean(word) && Boolean(settings.phrase) && same(phraseHash(settings.phrase.salt, word), settings.phrase.hash);
85
+ switch (settings.keys[mode]) {
86
+ case 'confirm': return mode === 3;
87
+ case 'designation': return isProject(typed[0]);
88
+ case 'phrase': return isPhrase(typed[0]);
89
+ case 'designation-airlock': return isProject(typed[0]) && isProject(typed[1]);
90
+ case 'phrase-airlock': return isPhrase(typed[0]) && isPhrase(typed[1]);
91
+ default: return false;
92
+ }
93
+ }
94
+
95
+ // Whether a change to these settings must show the phrase first: while a phrase guards a mode, a
96
+ // program that could change the key could also take the phrase away.
97
+ export function changeNeedsPhrase(stored) {
98
+ const settings = overrideSettings(stored);
99
+ return Boolean(settings.phrase) && [3, 4].some((mode) => settings.keys[mode].startsWith('phrase'));
100
+ }
package/src/ports.mjs CHANGED
@@ -13,7 +13,9 @@
13
13
  // would be noise at best and an invitation to frame somebody else's service at worst.
14
14
  const SYSTEM_PORTS = new Set([22, 25, 53, 88, 139, 445, 548, 631, 5353, 7000]);
15
15
  // Processes that always listen and never serve a page worth previewing.
16
- const NOISE = /^(rapportd|sharingd|identityservicesd|controlce|remoted|launchd|mDNSResponder|cupsd|Spotify|Dropbox|Docker|com\.docker)/i;
16
+ // Ollama is here because MADRE itself uses it (the local model, @madre): started from a project
17
+ // folder it would pass for that project's server.
18
+ const NOISE = /^(rapportd|sharingd|identityservicesd|controlce|remoted|launchd|mDNSResponder|cupsd|Spotify|Dropbox|Docker|com\.docker|ollama)/i;
17
19
 
18
20
  // One `lsof -nP -iTCP -sTCP:LISTEN` table into the ports a human might want to look at.
19
21
  // Loopback only: a line bound to * or 0.0.0.0 is reachable from the network, and this room does
@@ -53,6 +55,26 @@ export function classifyListening(output = '', { self = null } = {}) {
53
55
  };
54
56
  }
55
57
 
58
+ // Where each listening process was started, from `lsof -a -p <pids> -d cwd -Fpn`: a `p<pid>` line
59
+ // then an `n<path>` line. Same tool, same read-only look; it is how a list of ports learns which
60
+ // one is this project's own dev server rather than somebody else's on the same machine.
61
+ export function parseCwds(output = '') {
62
+ const cwds = new Map();
63
+ let pid = null;
64
+ for (const line of String(output ?? '').split('\n')) {
65
+ if (line.startsWith('p')) pid = Number(line.slice(1));
66
+ else if (line.startsWith('n') && Number.isInteger(pid)) cwds.set(pid, line.slice(1));
67
+ }
68
+ return cwds;
69
+ }
70
+
71
+ // A process started in the project or anywhere under it serves this project.
72
+ export function inProject(cwd, projectRoot) {
73
+ if (!cwd || !projectRoot) return false;
74
+ const root = String(projectRoot).replace(/\/+$/, '');
75
+ return cwd === root || cwd.startsWith(`${root}/`);
76
+ }
77
+
56
78
  // What RIPLEY may open, which is what nearly every caller wants.
57
79
  export function parseListening(output = '', options = {}) {
58
80
  return classifyListening(output, options).ports;