@jossuealcala/madre 0.7.2 → 1.0.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.
- package/CHANGELOG.md +773 -1
- package/README.md +92 -28
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +157 -81
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11207 -4196
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +2784 -1026
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1497 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6207 -425
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +2 -2
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +89 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +4 -4
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +19 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +351 -65
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1526 -163
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
package/src/modules/ollama.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export async function ollamaView(probe, settings) {
|
|
|
46
46
|
|
|
47
47
|
export default defineModule({
|
|
48
48
|
id: 'ollama',
|
|
49
|
-
name: '
|
|
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,7 +59,7 @@ 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('
|
|
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
63
|
const detail = !probe.running ? (binary ? t('installed, not running · START it here') : t('not installed · INSTALL it here'))
|
|
64
64
|
: !settings.enabled ? t('off · {n} models available', { n: probe.models.length })
|
|
65
65
|
: roles.length ? `${t('on')} · ${roles.join(' · ')}` : t('on · no usable model yet · PULL one');
|
|
@@ -83,7 +83,7 @@ export default defineModule({
|
|
|
83
83
|
},
|
|
84
84
|
async toggle(ctx) {
|
|
85
85
|
const enabled = !(ctx.settings.enabled ?? true);
|
|
86
|
-
await ctx.updateConfig({ modules: {
|
|
86
|
+
await ctx.updateConfig({ modules: { ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
|
|
87
87
|
const status = await ctx.services.ollama.wire();
|
|
88
88
|
await ctx.record('extension.toggled', { id: 'ollama', name: 'OLLAMA', enabled });
|
|
89
89
|
return { status: 200, body: { enabled, ollama: status } };
|
|
@@ -94,7 +94,7 @@ export default defineModule({
|
|
|
94
94
|
{ method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
|
|
95
95
|
const next = { ...(ctx.config.modules?.ollama ?? {}) };
|
|
96
96
|
for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];
|
|
97
|
-
await ctx.updateConfig({ modules: {
|
|
97
|
+
await ctx.updateConfig({ modules: { ollama: next } });
|
|
98
98
|
return { status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }) } };
|
|
99
99
|
} },
|
|
100
100
|
{ 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
|
-
|
|
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',
|
|
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: '
|
|
139
|
+
name: 'Test browser',
|
|
49
140
|
vendor: 'MADRE · Playwright MCP',
|
|
50
141
|
tracks: { name: PLAYWRIGHT_PACKAGE, npm: PLAYWRIGHT_PACKAGE },
|
|
51
|
-
summary: 'Hands
|
|
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
|
|
53
|
-
requires: ['@playwright/mcp and a chromium browser on this machine', '
|
|
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 ?
|
|
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: '
|
|
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
|
|
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
|
}],
|
package/src/modules/ripley.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { t } from '../i18n.mjs';
|
|
|
6
6
|
|
|
7
7
|
export default defineModule({
|
|
8
8
|
id: 'ripley',
|
|
9
|
-
name: '
|
|
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.',
|
package/src/modules/runs.mjs
CHANGED
|
@@ -19,10 +19,10 @@ import { t } from '../i18n.mjs';
|
|
|
19
19
|
export default defineModule({
|
|
20
20
|
id: 'runs',
|
|
21
21
|
configKey: 'runs',
|
|
22
|
-
name: '
|
|
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
|
|
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 {
|
package/src/modules/sdk.mjs
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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,36 @@ 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: '
|
|
69
|
+
where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
|
|
69
70
|
inside: true,
|
|
70
71
|
},
|
|
71
72
|
{
|
|
72
|
-
|
|
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: '
|
|
77
|
+
where: 'Settings › Version and reports › Look for new versions · or PULSE_UPDATE_CHECK=0',
|
|
76
78
|
inside: true,
|
|
77
79
|
},
|
|
78
80
|
{
|
|
79
81
|
id: 'reports', host: new URL(DEFAULT_REPORT_URL).hostname, to: "the author's error collector",
|
|
80
82
|
what: 'An unknown condition: what broke, where in MADRE, and the version. Paths are cut back and your words are not in it.',
|
|
81
83
|
when: 'you press SEND — or on its own, only while auto-report is on',
|
|
82
|
-
where: '
|
|
84
|
+
where: 'Settings › Version and reports › Report automatically · or PULSE_REPORT_URL',
|
|
83
85
|
inside: true,
|
|
84
86
|
},
|
|
85
87
|
{
|
|
86
88
|
id: 'anthropic', host: 'api.anthropic.com', to: 'Anthropic',
|
|
87
89
|
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
90
|
when: 'while a Claude session is being watched for its quota',
|
|
89
|
-
where: '
|
|
91
|
+
where: 'Settings › Agents › Claude',
|
|
90
92
|
inside: true,
|
|
91
93
|
},
|
|
92
94
|
{
|
|
93
95
|
id: 'ollama', host: null, local: true, to: 'Ollama, on this computer',
|
|
94
96
|
what: 'Memories to embed, exchanges to distil, and whatever you ask @madre. It goes to a port on this machine and stops there.',
|
|
95
97
|
when: 'while Ollama is running',
|
|
96
|
-
where: '
|
|
98
|
+
where: 'Modules › Local AI',
|
|
97
99
|
inside: true,
|
|
98
100
|
},
|
|
99
101
|
];
|
|
@@ -131,7 +133,6 @@ export function declareDestinations(moduleId, reaches = []) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
export function clearDeclaredDestinations() { declaredById.clear(); }
|
|
134
|
-
export const declaredDestinations = () => [...declaredById.values()];
|
|
135
136
|
|
|
136
137
|
// Which declaration a URL belongs to. An address nobody declared is said to be exactly that.
|
|
137
138
|
export function classify(rawUrl, { reportHost = null } = {}) {
|
|
@@ -302,14 +303,15 @@ export class OutboundLog {
|
|
|
302
303
|
// The declaration with today's answer filled in: is this one on, and what has it done.
|
|
303
304
|
export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
304
305
|
const counts = log?.counts() ?? {};
|
|
305
|
-
|
|
306
|
-
const
|
|
306
|
+
// Said in the room's language (2026-10-12): the list read in English inside a Spanish room.
|
|
307
|
+
const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: t('the provider OpenCode is signed in to') };
|
|
308
|
+
const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? t('its own provider')}`);
|
|
307
309
|
const destinations = DESTINATIONS.map((one) => ({
|
|
308
310
|
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,
|
|
311
|
+
to: one.id === 'crew' && crew.length ? crew.join(' · ') : t(one.to),
|
|
312
|
+
what: t(one.what),
|
|
313
|
+
when: t(one.when),
|
|
314
|
+
where: t(one.where),
|
|
313
315
|
local: Boolean(one.local),
|
|
314
316
|
// Whether the call lands in the log at all. Everything MADRE starts does, the image studio
|
|
315
317
|
// from its own process included; the crew's own conversation with its provider does not.
|
|
@@ -325,7 +327,7 @@ export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
|
325
327
|
recent: log?.recent({ limit: 40 }) ?? [],
|
|
326
328
|
undeclared,
|
|
327
329
|
says: undeclared
|
|
328
|
-
?
|
|
329
|
-
: 'Every request this process made went to an address declared above.',
|
|
330
|
+
? (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 }))
|
|
331
|
+
: t('Every request this process made went to an address declared above.'),
|
|
330
332
|
};
|
|
331
333
|
}
|
package/src/override.mjs
ADDED
|
@@ -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
|
-
|
|
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;
|