@jossuealcala/madre 0.3.3 → 0.4.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 (73) hide show
  1. package/CHANGELOG.md +497 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +68 -186
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3979 -867
  13. package/public/es.js +2258 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +96 -15
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +622 -65
  19. package/public/troubleshooting.js +255 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  24. package/src/adapters/process.mjs +79 -20
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +36 -3
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +10 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +44 -23
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +297 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/sentinel-errors.mjs +19 -1
  67. package/src/server.mjs +709 -71
  68. package/src/setup.mjs +1 -1
  69. package/src/updates.mjs +4 -2
  70. package/src/usage-sentinel.mjs +13 -8
  71. package/src/verdict.mjs +74 -0
  72. package/src/ashcode.mjs +0 -64
  73. package/src/modules/ashcode.mjs +0 -28
@@ -2,9 +2,10 @@
2
2
  // command. Verified project state, checkpoints and handoffs in .ahp/.
3
3
 
4
4
  import { join, resolve } from 'node:path';
5
+ import { t } from '../i18n.mjs';
5
6
  import { realpath } from 'node:fs/promises';
6
7
  import { defineModule } from './sdk.mjs';
7
- import { readJson, gitToplevel, findOnPath } from './helpers.mjs';
8
+ import { readJson, gitToplevel, findOnPath, packageVersion } from './helpers.mjs';
8
9
 
9
10
  // AHP+ platform names for the agents MADRE knows about. Gemini has no AHP+
10
11
  // adapter yet, so it is simply not requested.
@@ -14,14 +15,14 @@ const VERSION = '1.4.1';
14
15
 
15
16
  async function detect(projectRoot) {
16
17
  const manifest = await readJson(join(projectRoot, '.ahp', 'manifest.json'));
17
- if (!manifest) return { installed: false };
18
- const pinned = await readJson(join(projectRoot, 'node_modules', '@jossuealcala', 'ahp-plus', 'package.json'));
18
+ if (!manifest) return { installed: false, detail: t('not in this project') };
19
+ const pinned = await packageVersion(PACKAGE, { projectRoot });
19
20
  return {
20
21
  installed: true,
21
- version: pinned?.version ?? null,
22
+ version: pinned ?? null,
22
23
  protocolVersion: manifest.protocol_version ?? null,
23
24
  projectId: manifest.project_id ?? null,
24
- detail: [pinned?.version ? `cli ${pinned.version}` : null, manifest.protocol_version ? `protocol ${manifest.protocol_version}` : null].filter(Boolean).join(' · '),
25
+ detail: [pinned ? `cli ${pinned}` : null, manifest.protocol_version ? `protocol ${manifest.protocol_version}` : null].filter(Boolean).join(' · '),
25
26
  };
26
27
  }
27
28
 
@@ -52,13 +53,14 @@ export default defineModule({
52
53
  name: 'AHP+',
53
54
  vendor: 'Agent Handoff Protocol Plus',
54
55
  package: PACKAGE,
55
- version: VERSION,
56
+ tracks: { name: PACKAGE, npm: PACKAGE },
56
57
  summary: 'Verified project state, checkpoints and handoffs between AI sessions, stored in .ahp/ next to your code.',
58
+ requires: ['the project is a git repository', 'npx on the PATH of the terminal MADRE was started from'],
57
59
  creates: ['.ahp/ with manifest, sessions, handoffs and evidence', 'a project-local pin of @jossuealcala/ahp-plus', 'IDE adapter files for the detected agents'],
58
60
  detect, preflight, installCommand,
59
61
  async status(ctx) {
60
62
  const status = await detect(ctx.projectRoot);
61
63
  const plan = installCommand({ agents: ctx.agents });
62
- return { status, preflight: await preflight(ctx.projectRoot), install: { display: plan.display, platforms: plan.platforms } };
64
+ return { status, runs: [{ name: PACKAGE, version: await packageVersion(PACKAGE, { projectRoot: ctx.projectRoot }), target: VERSION }], preflight: await preflight(ctx.projectRoot), install: { display: plan.display, platforms: plan.platforms } };
63
65
  },
64
66
  });
@@ -0,0 +1,36 @@
1
+ // Ash: MADRE's token economy.
2
+ //
3
+ // It used to be a text compressor that rewrote the human's message before sending it. That was
4
+ // retired: it altered the one thing nobody asked it to touch, it was lossy, and measured against
5
+ // a real turn it saved a fifth of one per cent. Everything it claimed to do is now done without
6
+ // touching a word anyone wrote.
7
+ //
8
+ // Most of the economy needs no switch and is always on, because none of it loses anything: the
9
+ // briefing carries only the blocks a turn can use, what never changes is read first so a CLI can
10
+ // take it from its own cache, the transcript window holds still instead of sliding, and every
11
+ // turn is weighed against what it was actually charged.
12
+ //
13
+ // What is left to decide is the one thing that changes how an agent answers rather than what it
14
+ // is asked: whether to ask for compact prose. Output is the dearer half of a bill, so this is
15
+ // the switch worth having, and it is the human's to make.
16
+
17
+ import { defineModule } from './sdk.mjs';
18
+ import { t } from '../i18n.mjs';
19
+
20
+ export default defineModule({
21
+ id: 'ash',
22
+ configKey: 'ash',
23
+ name: 'Ash',
24
+ vendor: 'MADRE',
25
+ version: '1.0.0',
26
+ summary: 'Asks every agent for compact prose. The rest of the economy is always on: the briefing carries only what a turn can use, what never changes is read first so a cache can match it, and the transcript holds still instead of sliding.',
27
+ creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
28
+ card: 'ash',
29
+ async status(ctx) {
30
+ return {
31
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · agents answer in compact prose') : t('off · agents answer at their own length') },
32
+ install: { display: ctx.settings.enabled ? 'stop asking for compact replies' : 'ask every agent for compact replies', platforms: [] },
33
+ };
34
+ },
35
+ async onToggle(ctx, enabled) { ctx.room.setAsh(enabled); return null; },
36
+ });
@@ -2,21 +2,23 @@
2
2
  // Nothing to switch: it is on wherever the project is a git repository.
3
3
 
4
4
  import { defineModule } from './sdk.mjs';
5
+ import { t } from '../i18n.mjs';
5
6
  import { gitToplevel } from './helpers.mjs';
6
7
 
7
8
  export default defineModule({
8
9
  id: 'git-pulse',
9
10
  name: 'Git Pulse',
10
11
  vendor: 'MADRE',
11
- summary: 'Type /git in the composer to bring the repository\'s branch, uncommitted changes, recent commits or diff stats into the room as a shared fact card, without spending an agent turn. /git commit and /git push are your own hand on the repository: a commit is local, a push shows what would leave and only goes with /git push confirm.',
12
- creates: ['nothing by itself: the read commands are read-only', 'a commit or a push only when you type /git commit or /git push confirm'],
12
+ version: '1.0.0',
13
+ summary: 'Brings the repository into the room: /git posts the branch, the uncommitted changes, the recent commits or the diff stats as a shared fact card, without spending an agent turn.',
14
+ creates: ['nothing by itself \u00b7 the read commands only read', 'a local commit only when you type /git commit', 'a push only when you type /git push confirm, after it shows what would leave'],
13
15
  requires: ['the project is a git repository'],
14
16
  commands: ['/git status', '/git log [n]', '/git diff', '/git branches', '/git commit "message"', '/git push [confirm]'],
15
17
  card: 'fixed',
16
18
  async status(ctx) {
17
19
  const isRepo = await gitToplevel(ctx.projectRoot);
18
20
  return {
19
- status: { installed: Boolean(isRepo), detail: isRepo ? 'on · project is a git repository' : 'not a git repository' },
21
+ status: { installed: Boolean(isRepo), detail: isRepo ? t('on · project is a git repository') : t('not a git repository') },
20
22
  preflight: isRepo ? { ok: true, problems: [] } : { ok: false, problems: ['Run `git init` in the project to use /git.'] },
21
23
  install: { display: '/git in the composer', platforms: [] },
22
24
  fixed: true,
@@ -28,3 +28,34 @@ export async function findOnPath(name, envPath = process.env.PATH ?? '') {
28
28
  }
29
29
  return null;
30
30
  }
31
+
32
+ // The version of an npm package that is actually on this machine, found by reading its
33
+ // package.json: up the node_modules chain from the project first, then npm's global root.
34
+ // Nothing is executed. Asking a package for its own --version through npx is not a test of
35
+ // anything: when the package is not installed at all, npx answers with npm's version and exits
36
+ // cleanly, which is how PLAYWRIGHT came to report a browser server that was never there.
37
+ let globalRoot;
38
+ async function npmGlobalRoot(env = process.env) {
39
+ if (globalRoot !== undefined) return globalRoot;
40
+ try { const { stdout } = await execFileAsync('npm', ['root', '-g'], { env, timeout: 10000 }); globalRoot = stdout.trim() || null; }
41
+ catch { globalRoot = null; }
42
+ return globalRoot;
43
+ }
44
+
45
+ export async function packageVersion(name, { projectRoot = process.cwd(), env = process.env } = {}) {
46
+ const parts = name.split('/');
47
+ let directory = projectRoot;
48
+ for (let depth = 0; depth < 24; depth += 1) {
49
+ const found = await readJson(join(directory, 'node_modules', ...parts, 'package.json'));
50
+ if (found?.version) return found.version;
51
+ const parent = join(directory, '..');
52
+ if (parent === directory) break;
53
+ directory = parent;
54
+ }
55
+ const root = await npmGlobalRoot(env);
56
+ if (root) {
57
+ const found = await readJson(join(root, ...parts, 'package.json'));
58
+ if (found?.version) return found.version;
59
+ }
60
+ return null;
61
+ }
@@ -2,6 +2,7 @@
2
2
  // the CLIs that cannot draw natively. A switch and a model in config.json.
3
3
 
4
4
  import { defineModule } from './sdk.mjs';
5
+ import { t } from '../i18n.mjs';
5
6
 
6
7
  const MODELS = ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'];
7
8
 
@@ -10,18 +11,23 @@ export default defineModule({
10
11
  configKey: 'imageStudio',
11
12
  name: 'Image Studio',
12
13
  vendor: 'MADRE · Gemini API',
13
- summary: 'Gives Gemini CLI, Claude Code and OpenCode an image-generation tool through a MADRE-owned MCP server on the Gemini API image models, using your own Gemini key and credits. Attached only inside a creation lease with the image scope on.',
14
- creates: ['nothing in the project: images land in the lease directory like any artifact', 'an "image-studio" entry in ~/.pulse/config.json', 'an MCP server process per turn, started and stopped by the room'],
15
- requires: ['a Gemini API key with credits (the key the Gemini CLI stores, or GEMINI_API_KEY)'],
14
+ version: '1.0.0',
15
+ summary: 'Gives Gemini CLI, Claude Code and OpenCode an image tool, through a MADRE-owned MCP server on the Gemini image models and your own key and credits.',
16
+ creates: ['nothing in the project \u00b7 images land in the lease folder', 'an entry in ~/.pulse/config.json', 'an MCP server per turn, started and stopped by the room'],
17
+ // Not something it writes: a condition for it to attach at all, which is what REQUIRES is for.
18
+ requires: ['a Gemini API key with credits (the one the Gemini CLI stores, or GEMINI_API_KEY)', 'attached only inside a creation lease with the image scope on'],
16
19
  models: MODELS,
17
20
  settings: { enabled: false, model: MODELS[0] },
18
21
  card: 'image-studio',
22
+ controls: [{ key: 'model', label: 'MODEL', type: 'select', options: MODELS, note: 'The Gemini image model Image Studio draws with. It bills against your own key.' }],
23
+ // Changing the model while it is on has to reach the running room, not just the file.
24
+ async onSettings(ctx, settings) { if (settings.enabled) ctx.services.setImageModule({ enabled: true, model: settings.model }); },
19
25
  async status(ctx) {
20
26
  const key = await ctx.services.imageKey();
21
27
  const model = ctx.settings.model ?? MODELS[0];
22
28
  return {
23
29
  model,
24
- status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? `on · ${model}${key ? '' : ' · no Gemini key found'}` : key ? 'key found' : 'no Gemini key found' },
30
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? `${t('on')} · ${model}${key ? '' : t(' · no Gemini key found')}` : key ? t('key found') : t('no Gemini key found') },
25
31
  preflight: key ? { ok: true, problems: [] } : { ok: false, problems: ['No Gemini API key: sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY. Image models bill against that key.'] },
26
32
  install: { display: ctx.settings.enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
27
33
  };
@@ -1,21 +1,144 @@
1
- // The registry. Order is the order MODULES shows.
1
+ // The registry. Order is the order MODULES shows. MADRE's own modules first; then the
2
+ // human's, loaded from ~/.pulse/modules/*.mjs (every project) and <project>/.madre/modules/*.mjs
3
+ // (this project). An external module is a file whose default export is a plain spec object
4
+ // (defineModule is applied here), a module already built with defineModule, or a function
5
+ // receiving { defineModule } and returning either. Agents cannot write those folders: .madre/
6
+ // is a forbidden zone, and ~/.pulse lives outside every project.
7
+ import { readdir, readFile, writeFile, mkdir, unlink, mkdtemp, rm, access } from 'node:fs/promises';
8
+ import { translate } from '../i18n.mjs';
9
+ import { join, basename, resolve, sep } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { pathToFileURL, fileURLToPath } from 'node:url';
2
12
  import ahp from './ahp.mjs';
3
13
  import imageStudio from './image-studio.mjs';
4
14
  import gitPulse from './git-pulse.mjs';
5
- import ashcode from './ashcode.mjs';
15
+ import ash from './ash.mjs';
6
16
  import ripley from './ripley.mjs';
7
17
  import ollama from './ollama.mjs';
8
18
  import playwright from './playwright.mjs';
9
- import { matchRoute } from './sdk.mjs';
19
+ import { defineModule, matchRoute } from './sdk.mjs';
10
20
 
11
- export const MODULES = [ahp, imageStudio, gitPulse, ashcode, ripley, ollama, playwright];
12
- // Every tool server the modules hand to one turn, flattened; a module that fails hands nothing.
13
- export async function toolsForTurn(ctx, turn) {
14
- const lists = await Promise.all(MODULES.filter((module) => module.toolsForTurn).map((module) => module.toolsForTurn(ctx, turn)));
15
- return lists.flat().filter((server) => server && server.name && server.command);
21
+ export const MODULES = [ahp, imageStudio, gitPulse, ash, ripley, ollama, playwright];
22
+ export const BUILTIN_IDS = new Set(MODULES.map((module) => module.id));
23
+ export const loadFailures = []; // { file, error } for MODULES to show
24
+ export const moduleFolders = ({ stateRoot, projectRoot }) => ({ user: join(stateRoot, 'modules'), project: join(projectRoot, '.madre', 'modules') });
25
+ // Where the SDK guide and the example live in this installation, for agents who build modules.
26
+ export const sdkPaths = () => ({ guide: fileURLToPath(new URL('../../docs/SDK.md', import.meta.url)), example: fileURLToPath(new URL('../../docs/sdk/hello-module.mjs', import.meta.url)) });
27
+ // A file an agent wrote that means "install me as a module": <id>.module.mjs.
28
+ export const isModuleFile = (path) => /\.module\.mjs$/.test(String(path ?? ''));
29
+
30
+ // The rules an outside module must keep so it cannot reach MADRE's core: its own id, and its
31
+ // routes under /api/x/<id>/ only, never MADRE's own paths.
32
+ function checkExternal(module, { replace = false } = {}) {
33
+ const taken = MODULES.find((known) => known.id === module.id);
34
+ // A module that ships with MADRE can never be shadowed. One of the human's own can be
35
+ // replaced, because that is what installing a newer copy of it is.
36
+ if (taken && (!replace || !taken.external)) throw new Error(`the id "${module.id}" is already taken`);
37
+ for (const route of module.routes ?? []) {
38
+ const path = typeof route.path === 'string' ? route.path : route.path?.source ?? '';
39
+ if (!path.startsWith(`/api/x/${module.id}/`) && !path.startsWith(`\\/api\\/x\\/${module.id}\\/`)) throw new Error(`route ${path} must live under /api/x/${module.id}/`);
40
+ }
41
+ return module;
42
+ }
43
+
44
+ // Loads one file as a module, throwing a readable error when it is not one.
45
+ async function importModuleFile(file) {
46
+ const loaded = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
47
+ let spec = loaded.default ?? loaded.module ?? null;
48
+ if (typeof spec === 'function') spec = await spec({ defineModule });
49
+ if (!spec || typeof spec !== 'object') throw new Error('the default export must be a module spec object');
50
+ return typeof spec.describe === 'function' ? spec : defineModule(spec);
51
+ }
52
+
53
+ export async function loadExternalModules({ stateRoot, projectRoot }) {
54
+ for (let index = MODULES.length - 1; index >= 0; index -= 1) if (MODULES[index].external) MODULES.splice(index, 1);
55
+ loadFailures.length = 0;
56
+ const folders = moduleFolders({ stateRoot, projectRoot });
57
+ for (const [origin, dir] of Object.entries(folders)) {
58
+ let files = [];
59
+ try { files = (await readdir(dir)).filter((name) => /\.(mjs|js)$/.test(name) && !name.startsWith('.')).sort(); } catch { continue; }
60
+ for (const name of files) {
61
+ const file = join(dir, name);
62
+ try {
63
+ const module = checkExternal(await importModuleFile(file));
64
+ MODULES.push(Object.freeze({ ...module, external: true, origin, file }));
65
+ } catch (error) {
66
+ loadFailures.push({ file, origin, error: error.message });
67
+ }
68
+ }
69
+ }
70
+ return { loaded: MODULES.filter((module) => module.external), failures: [...loadFailures], folders };
71
+ }
72
+
73
+ // The one door every module comes through, whoever wrote it and wherever the text came from: it
74
+ // is written to a scratch copy, imported there and checked against the house rules. A module runs
75
+ // inside MADRE with the human's own permissions, so the check is the point. Checking installs
76
+ // nothing and touches no registry — asking what a file is must never change what is loaded.
77
+ export async function verifyModuleText({ text, name = null, replace = true }) {
78
+ const scratch = await mkdtemp(join(tmpdir(), 'madre-module-check-'));
79
+ try {
80
+ const probe = join(scratch, name && /\.m?js$/.test(name) ? basename(name) : 'candidate.mjs');
81
+ await writeFile(probe, text);
82
+ const module = checkExternal(await importModuleFile(probe), { replace });
83
+ return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null };
84
+ } finally { await rm(scratch, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }); }
85
+ }
86
+
87
+ export async function installModuleText({ text, name = null, scope = 'user', stateRoot, projectRoot, source = null, replace = true }) {
88
+ const module = await verifyModuleText({ text, name, replace });
89
+ const replaced = Boolean(MODULES.find((known) => known.id === module.id)?.external);
90
+ const folders = moduleFolders({ stateRoot, projectRoot });
91
+ const dir = folders[scope === 'project' ? 'project' : 'user'];
92
+ await mkdir(dir, { recursive: true });
93
+ const target = join(dir, `${module.id}.mjs`);
94
+ await writeFile(target, text);
95
+ // Where it came from, so the same place can be asked for a newer one later.
96
+ if (source) await writeFile(join(dir, `${module.id}.source.json`), JSON.stringify({ ...source, at: new Date().toISOString(), version: module.version ?? null }, null, 2)).catch(() => {});
97
+ await loadExternalModules({ stateRoot, projectRoot });
98
+ return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced };
16
99
  }
100
+
101
+ // The same thing from a file already on this computer. `source` must be inside the project or the
102
+ // room folder; agents never reach the module folders themselves.
103
+ export async function installModuleFile({ source, scope = 'user', stateRoot, projectRoot, roomDir = null }) {
104
+ const canonical = resolve(source);
105
+ const allowed = [resolve(projectRoot), ...(roomDir ? [resolve(roomDir)] : [])];
106
+ if (!allowed.some((root) => canonical === root || canonical.startsWith(root + sep))) throw new Error('A module can only be installed from a file inside the project or the room folder.');
107
+ const text = await readFile(canonical, 'utf8');
108
+ return installModuleText({ text, name: basename(canonical), scope, stateRoot, projectRoot, source: { kind: 'file', from: canonical } });
109
+ }
110
+
111
+ // Where a module was installed from, if MADRE was told. This is what "update this module" asks
112
+ // again: the file the author keeps it in, or the address they publish it at.
113
+ export async function moduleOrigin(module) {
114
+ if (!module?.external || !module.file) return null;
115
+ const declared = module.updates?.url ? { kind: 'url', from: module.updates.url } : null;
116
+ const remembered = await readFile(module.file.replace(/\.mjs$/, '.source.json'), 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
117
+ return declared ?? (remembered?.from ? { kind: remembered.kind ?? 'file', from: remembered.from, at: remembered.at ?? null } : null);
118
+ }
119
+
120
+ // Removing is for the human's modules only; MADRE's own stay.
121
+ export async function removeExternalModule({ id, stateRoot, projectRoot }) {
122
+ const module = MODULES.find((known) => known.id === id);
123
+ if (!module) throw new Error(`No module "${id}".`);
124
+ if (!module.external) throw new Error(`${module.name} ships with MADRE and cannot be removed; switch it off instead.`);
125
+ await unlink(module.file).catch(() => {});
126
+ await loadExternalModules({ stateRoot, projectRoot });
127
+ return { id, name: module.name, file: module.file };
128
+ }
129
+
17
130
  export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
18
- export function describeModules(ctx) { return Promise.all(MODULES.map((module) => module.describe(ctx))); }
131
+ // What MODULES shows, with each card's update state attached from the cache when the room offers
132
+ // the service. Never a network read: a screen that waits on a registry is a screen that hangs.
133
+ export async function describeModules(ctx) {
134
+ const items = await Promise.all(MODULES.map((module) => module.describe(ctx)));
135
+ const look = ctx.services?.moduleUpdate;
136
+ // One place, at the boundary: what a module says about itself is written in English inside the
137
+ // module, and it is the card that speaks the room's language. Nothing in the module files
138
+ // changes, and neither does what a module hands to an agent.
139
+ if (!look) return translate(items);
140
+ return translate(await Promise.all(items.map(async (item) => ({ ...item, update: await look(item).catch(() => null) }))));
141
+ }
19
142
  // One flat list of every route a module serves, with the module attached.
20
143
  export function findModuleRoute(method, pathname) {
21
144
  for (const module of MODULES) {
@@ -24,4 +147,13 @@ export function findModuleRoute(method, pathname) {
24
147
  }
25
148
  return null;
26
149
  }
150
+ // Every slash command the modules declare, with its module attached.
151
+ export function moduleCommands() {
152
+ return MODULES.flatMap((module) => (module.slash ?? []).map((command) => ({ ...command, module })));
153
+ }
154
+ // Every tool server the modules hand to one turn, flattened; a module that fails hands nothing.
155
+ export async function toolsForTurn(ctx, turn) {
156
+ const lists = await Promise.all(MODULES.filter((module) => module.toolsForTurn).map((module) => module.toolsForTurn(ctx, turn)));
157
+ return lists.flat().filter((server) => server && server.name && server.command);
158
+ }
27
159
  export { defineModule } from './sdk.mjs';
@@ -2,33 +2,81 @@
2
2
  // Ollama runs. The server offers the wiring through ctx.services.ollama.
3
3
 
4
4
  import { defineModule } from './sdk.mjs';
5
+ import { t } from '../i18n.mjs';
6
+ import { findOnPath } from './helpers.mjs';
5
7
  import { RECOMMENDED } from '../ollama.mjs';
6
8
 
9
+ // Ollama is not an npm package, so each system has its own way in. Where MADRE can run it, the
10
+ // command is shown on the button before it runs; where it cannot, it hands over the download.
11
+ export function ollamaInstallPlan({ platform = process.platform, brew = null } = {}) {
12
+ if (platform === 'darwin') {
13
+ return brew
14
+ ? { command: brew, args: ['install', 'ollama'], display: 'brew install ollama', note: 'Installs Ollama with Homebrew, the package manager already on this computer.' }
15
+ : { command: null, download: 'https://ollama.com/download', display: null, note: 'Homebrew is not on this computer. Download Ollama from ollama.com, open it once, and press RECHECK.' };
16
+ }
17
+ if (platform === 'linux') {
18
+ return { command: 'sh', args: ['-c', 'curl -fsSL https://ollama.com/install.sh | sh'], display: 'curl -fsSL https://ollama.com/install.sh | sh', note: "Ollama's own install script, downloaded from ollama.com and run on this computer." };
19
+ }
20
+ return { command: null, download: 'https://ollama.com/download', display: null, note: 'Download the Ollama installer from ollama.com, run it, and press RECHECK.' };
21
+ }
22
+
23
+ // A newer Ollama. Where MADRE can do it, the command is shown before it runs; where it cannot,
24
+ // it hands over the download rather than pretending.
25
+ export function ollamaUpdatePlan({ platform = process.platform, brew = null } = {}) {
26
+ if (platform === 'darwin') {
27
+ return brew
28
+ ? { command: brew, args: ['upgrade', 'ollama'], display: 'brew upgrade ollama', note: 'Upgrades Ollama with Homebrew. Your models stay where they are.' }
29
+ : { command: null, download: 'https://ollama.com/download', display: null, note: 'Ollama was not installed with Homebrew. Download the newer one from ollama.com, open it once, and press RECHECK. Your models stay where they are.' };
30
+ }
31
+ if (platform === 'linux') {
32
+ return { command: 'sh', args: ['-c', 'curl -fsSL https://ollama.com/install.sh | sh'], display: 'curl -fsSL https://ollama.com/install.sh | sh', note: "Ollama's own install script upgrades in place. Your models stay where they are." };
33
+ }
34
+ return { command: null, download: 'https://ollama.com/download', display: null, note: 'Download the newer Ollama from ollama.com and run it. Your models stay where they are.' };
35
+ }
36
+
37
+ // Waking it: the same command on every system, and the app on macOS does it too.
38
+ export const ollamaStartPlan = () => ({ command: 'ollama', args: ['serve'], display: 'ollama serve' });
39
+
40
+ // What the room needs to know about the local brain: its own state, whether it is even on this
41
+ // computer, and the one step that moves it forward. The card and the routes share this.
42
+ export async function ollamaView(probe, settings) {
43
+ const binary = await findOnPath('ollama');
44
+ return { ...probe, settings, binary, install: ollamaInstallPlan({ brew: await findOnPath('brew') }), start: ollamaStartPlan() };
45
+ }
46
+
7
47
  export default defineModule({
8
48
  id: 'ollama',
9
49
  name: 'OLLAMA',
10
50
  vendor: 'MADRE · LOCAL INTELLIGENCE',
11
- summary: 'Recall by meaning and memory distillation on this machine through Ollama: no provider tokens, nothing leaves. Needs Ollama running with an embedding model and a chat model; MADRE can pull the recommended ones.',
12
- creates: ['nothing in the project', 'an ollama block in ~/.pulse/config.json', 'models in Ollama\'s own store when you press PULL'],
13
- requires: ['Ollama installed and running (ollama serve, or the Ollama app)'],
51
+ tracks: { name: 'ollama', github: 'ollama/ollama' },
52
+ summary: 'Recall by meaning and memory distillation on this machine, through Ollama: no provider tokens, nothing leaves.',
53
+ creates: ['nothing in the project', 'a block in ~/.pulse/config.json', 'models in Ollama\'s own store when you press PULL'],
54
+ requires: ['Ollama running (the app, or ollama serve)', 'an embedding model and a chat model \u00b7 MADRE can pull the recommended ones'],
14
55
  settings: { enabled: true, embeddings: true, archivist: true, agent: true },
15
56
  card: 'ollama',
16
57
  async status(ctx) {
17
58
  const probe = ctx.services.ollama?.state() ?? { running: false, models: [], embedModel: null, chatModel: null };
59
+ const view = await ollamaView(probe, ctx.settings);
60
+ const binary = view.binary;
18
61
  const settings = ctx.settings;
19
- const roles = [settings.embeddings && probe.embedModel ? `embeddings · ${probe.embedModel}` : null, settings.archivist && probe.chatModel ? `archivist · ${probe.chatModel}` : null, settings.agent !== false && probe.chatModel ? '@madre in the room' : null].filter(Boolean);
20
- const detail = !probe.running ? 'not running · start Ollama and RECHECK'
21
- : !settings.enabled ? `off · ${probe.models.length} model${probe.models.length === 1 ? '' : 's'} available`
22
- : roles.length ? `on · ${roles.join(' · ')}` : 'on · no usable model yet · PULL one';
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);
63
+ const detail = !probe.running ? (binary ? t('installed, not running · START it here') : t('not installed · INSTALL it here'))
64
+ : !settings.enabled ? t('off · {n} models available', { n: probe.models.length })
65
+ : roles.length ? `${t('on')} · ${roles.join(' · ')}` : t('on · no usable model yet · PULL one');
23
66
  return {
67
+ runs: [{ name: 'ollama', version: probe.version ?? null }],
24
68
  models: probe.models.map((model) => model.name),
25
69
  status: { installed: settings.enabled && probe.running && roles.length > 0, detail },
26
- ollama: { ...probe, settings },
70
+ ollama: view,
27
71
  recommended: RECOMMENDED,
28
72
  preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
29
73
  install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
30
74
  };
31
75
  },
76
+ async updatePlan(ctx) {
77
+ const { findOnPath } = await import('./helpers.mjs');
78
+ return ollamaUpdatePlan({ brew: await findOnPath('brew') });
79
+ },
32
80
  async toggle(ctx) {
33
81
  const enabled = !(ctx.settings.enabled ?? true);
34
82
  await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
@@ -37,14 +85,22 @@ export default defineModule({
37
85
  return { status: 200, body: { enabled, ollama: status } };
38
86
  },
39
87
  routes: [
40
- { method: 'GET', path: '/api/ollama', handler: async (ctx) => ({ status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }), recommended: RECOMMENDED } }) },
41
- { method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ctx.services.ollama.wire(), recommended: RECOMMENDED } }) },
88
+ { method: 'GET', path: '/api/ollama', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire({ probe: false }), ctx.settings), recommended: RECOMMENDED } }) },
89
+ { method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire(), ctx.settings), recommended: RECOMMENDED } }) },
42
90
  { method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
43
91
  const next = { ...(ctx.config.modules?.ollama ?? {}) };
44
92
  for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];
45
93
  await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: next } });
46
94
  return { status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }) } };
47
95
  } },
96
+ { method: 'POST', path: '/api/ollama/install', handler: async (ctx) => {
97
+ const started = await ctx.services.ollama.install();
98
+ return started.ok ? { status: 202, body: { installing: true, command: started.command } } : { status: started.download ? 412 : 409, body: { error: started.error, download: started.download ?? null } };
99
+ } },
100
+ { method: 'POST', path: '/api/ollama/start', handler: async (ctx) => {
101
+ const started = await ctx.services.ollama.start();
102
+ return started.ok ? { status: 200, body: { ollama: ctx.services.ollama.state() } } : { status: 412, body: { error: started.error } };
103
+ } },
48
104
  { method: 'POST', path: '/api/ollama/pull', handler: async (ctx, { payload }) => {
49
105
  const model = String(payload.model ?? '').trim();
50
106
  if (!/^[a-z0-9][a-z0-9._:/-]{1,80}$/i.test(model)) return { status: 400, body: { error: 'Give a model name like nomic-embed-text or qwen2.5:3b.' } };
@@ -3,25 +3,24 @@
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 { execFile } from 'node:child_process';
7
- import { promisify } from 'node:util';
8
6
  import { join } from 'node:path';
7
+ import { t } from '../i18n.mjs';
9
8
  import { defineModule } from './sdk.mjs';
9
+ import { packageVersion } from './helpers.mjs';
10
10
 
11
- const execFileAsync = promisify(execFile);
11
+ export const PLAYWRIGHT_PACKAGE = '@playwright/mcp';
12
+ export const PLAYWRIGHT_BROWSERS = ['chromium', 'firefox', 'webkit'];
12
13
  export const PLAYWRIGHT_SERVER_NAME = 'pulse-playwright';
13
14
  // The tools @playwright/mcp exposes that a room turn may use. Screenshots and files land in scratch.
14
15
  export const PLAYWRIGHT_TOOLS = ['browser_navigate', 'browser_navigate_back', 'browser_snapshot', 'browser_click', 'browser_type', 'browser_fill_form', 'browser_hover', 'browser_press_key', 'browser_select_option', 'browser_wait_for', 'browser_console_messages', 'browser_network_requests', 'browser_take_screenshot', 'browser_resize', 'browser_tabs', 'browser_close'];
15
16
 
16
17
  let probe = { at: 0, version: null };
17
- // Is @playwright/mcp installed where npx can find it without downloading? Cached a minute.
18
- export async function playwrightVersion({ env = process.env, now = Date.now() } = {}) {
19
- if (now - probe.at < 60000) return probe.version;
20
- let version = null;
21
- try {
22
- const { stdout } = await execFileAsync('npx', ['--no', '@playwright/mcp', '--version'], { env, timeout: 15000 });
23
- version = stdout.trim().split('\n').pop().trim() || 'installed';
24
- } catch { version = null; }
18
+ // Is @playwright/mcp installed where npx can find it without downloading? Read from the package
19
+ // itself, never asked of it: `npx --no <package> --version` answers with npm's own version and
20
+ // exits cleanly when the package is not there, so it said 11.16.0 for a server never installed.
21
+ export async function playwrightVersion({ env = process.env, projectRoot = process.cwd(), now = Date.now(), fresh = false } = {}) {
22
+ if (!fresh && now - probe.at < 60000) return probe.version;
23
+ const version = await packageVersion(PLAYWRIGHT_PACKAGE, { env, projectRoot });
25
24
  probe = { at: now, version };
26
25
  return version;
27
26
  }
@@ -30,7 +29,10 @@ export function playwrightServerFor({ port, outputDir, browser = 'chromium', hea
30
29
  return {
31
30
  name: PLAYWRIGHT_SERVER_NAME,
32
31
  command: 'npx',
33
- args: ['--no', '@playwright/mcp', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--blocked-origins', '*', '--output-dir', outputDir, '--no-sandbox'],
32
+ // `--` or npm eats them: without the separator npm takes --headless, --isolated and the
33
+ // rest as its own config, hands the server only their bare values, and the server exits with
34
+ // "too many arguments" before it speaks a word of MCP. The agent sees CONNECTION_CLOSED.
35
+ args: ['--no', '@playwright/mcp', '--', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--blocked-origins', '*', '--output-dir', outputDir, '--no-sandbox'],
34
36
  env: {},
35
37
  tools: PLAYWRIGHT_TOOLS,
36
38
  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.`,
@@ -41,33 +43,52 @@ export default defineModule({
41
43
  id: 'playwright',
42
44
  name: 'PLAYWRIGHT',
43
45
  vendor: 'MADRE · Playwright MCP',
44
- summary: 'Hands every agent a headless browser that reaches only this MADRE: open the RIPLEY preview of a page, click through it, read the console, take screenshots into the turn\'s scratch folder. Runs @playwright/mcp isolated per turn; no other origin is reachable.',
45
- creates: ['nothing in the project: screenshots land in .pulse/out/<turn>/', 'a playwright switch in ~/.pulse/config.json', 'a browser process per turn, started and stopped by the CLI'],
46
- requires: ['@playwright/mcp installed (npm install -g @playwright/mcp) and a browser (npx playwright install chromium)', 'RIPLEY on, to have pages to open'],
46
+ tracks: { name: PLAYWRIGHT_PACKAGE, npm: PLAYWRIGHT_PACKAGE },
47
+ 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.',
48
+ 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'],
49
+ requires: ['@playwright/mcp and a chromium browser on this machine', 'RIPLEY on, to have pages to open'],
47
50
  settings: { enabled: false, browser: 'chromium', headless: true },
48
51
  card: 'switch',
52
+ controls: [
53
+ { key: 'browser', label: 'BROWSER', type: 'select', options: PLAYWRIGHT_BROWSERS, note: 'Which browser engine the agents drive.' },
54
+ { key: 'headless', label: 'SHOW THE WINDOW', type: 'switch', invert: true, note: 'Off, the browser runs headless. On, it opens on this screen so you can watch.' },
55
+ ],
49
56
  async status(ctx) {
50
- const version = await playwrightVersion({ env: ctx.env });
57
+ const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
51
58
  return {
52
- version: version ?? null,
53
- status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (version ? `on · @playwright/mcp ${version} · ${ctx.settings.browser}` : 'on · @playwright/mcp not found') : version ? `off · @playwright/mcp ${version} found` : 'off · @playwright/mcp not installed' },
54
- preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp && npx playwright install chromium'] },
59
+ runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
60
+ settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
61
+ 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') },
62
+ 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'] },
55
63
  install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
56
64
  };
57
65
  },
66
+ // Getting the browser server onto this computer, or a newer one. Installed globally, which is
67
+ // where `npx --no` looks for it at turn time without downloading anything.
68
+ async updatePlan(ctx, { latest = null } = {}) {
69
+ const target = latest ? `${PLAYWRIGHT_PACKAGE}@${latest}` : PLAYWRIGHT_PACKAGE;
70
+ return {
71
+ command: 'npm',
72
+ args: ['install', '-g', target],
73
+ display: `npm install -g ${target}`,
74
+ note: 'Installs the browser server for every project on this computer. A browser itself may still be missing; the card says so if it is.',
75
+ after: async () => { await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot, fresh: true }); },
76
+ };
77
+ },
58
78
  async toolsForTurn(ctx, turn) {
59
- if (!(await playwrightVersion({ env: ctx.env }))) return [];
79
+ if (!(await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot }))) return [];
60
80
  if (turn.mode === 0) return []; // a ghost turn leaves no screenshots and opens no browser
61
81
  const outputDir = turn.scratchDir ?? join(turn.roomDir ?? ctx.stateRoot, 'playwright');
62
82
  return [playwrightServerFor({ port: turn.port, outputDir, browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false })];
63
83
  },
64
84
  conditions: [{
65
85
  id: 'playwright-missing',
86
+ code: 'MU-054',
66
87
  severity: 'informational',
67
88
  title: 'PLAYWRIGHT: the browser server is not installed',
68
89
  match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
69
- diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn. It is on, or you tried to open it, but npx cannot find the package without downloading, or no browser is installed.',
70
- remedy: 'Install it once, globally, then RECHECK in MODULES.',
71
- fixes: { darwin: ['npm install -g @playwright/mcp', 'npx playwright install chromium'], linux: ['npm install -g @playwright/mcp', 'npx playwright install --with-deps chromium'], win32: ['npm install -g @playwright/mcp', 'npx playwright install chromium'] },
90
+ 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.',
91
+ 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.',
92
+ 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'] },
72
93
  }],
73
94
  });