@jossuealcala/madre 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +412 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +67 -185
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3825 -851
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +94 -13
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +602 -62
  19. package/public/troubleshooting.js +168 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +12 -1
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +9 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +36 -19
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +292 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/server.mjs +699 -69
  67. package/src/setup.mjs +1 -1
  68. package/src/updates.mjs +4 -2
  69. package/src/usage-sentinel.mjs +13 -8
  70. package/src/verdict.mjs +74 -0
  71. package/src/ashcode.mjs +0 -64
  72. package/src/modules/ashcode.mjs +0 -28
@@ -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,22 @@ 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', 'attached only inside a creation lease with the image scope on'],
17
+ requires: ['a Gemini API key with credits (the one the Gemini CLI stores, or GEMINI_API_KEY)'],
16
18
  models: MODELS,
17
19
  settings: { enabled: false, model: MODELS[0] },
18
20
  card: 'image-studio',
21
+ controls: [{ key: 'model', label: 'MODEL', type: 'select', options: MODELS, note: 'The Gemini image model Image Studio draws with. It bills against your own key.' }],
22
+ // Changing the model while it is on has to reach the running room, not just the file.
23
+ async onSettings(ctx, settings) { if (settings.enabled) ctx.services.setImageModule({ enabled: true, model: settings.model }); },
19
24
  async status(ctx) {
20
25
  const key = await ctx.services.imageKey();
21
26
  const model = ctx.settings.model ?? MODELS[0];
22
27
  return {
23
28
  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' },
29
+ 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
30
  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
31
  install: { display: ctx.settings.enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
27
32
  };
@@ -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
  }
@@ -41,22 +40,40 @@ export default defineModule({
41
40
  id: 'playwright',
42
41
  name: 'PLAYWRIGHT',
43
42
  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'],
43
+ tracks: { name: PLAYWRIGHT_PACKAGE, npm: PLAYWRIGHT_PACKAGE },
44
+ 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.',
45
+ 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'],
46
+ requires: ['@playwright/mcp and a chromium browser on this machine', 'RIPLEY on, to have pages to open'],
47
47
  settings: { enabled: false, browser: 'chromium', headless: true },
48
48
  card: 'switch',
49
+ controls: [
50
+ { key: 'browser', label: 'BROWSER', type: 'select', options: PLAYWRIGHT_BROWSERS, note: 'Which browser engine the agents drive.' },
51
+ { 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.' },
52
+ ],
49
53
  async status(ctx) {
50
- const version = await playwrightVersion({ env: ctx.env });
54
+ const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
51
55
  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' },
56
+ runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
57
+ settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
58
+ 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') },
54
59
  preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp && npx playwright install chromium'] },
55
60
  install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
56
61
  };
57
62
  },
63
+ // Getting the browser server onto this computer, or a newer one. Installed globally, which is
64
+ // where `npx --no` looks for it at turn time without downloading anything.
65
+ async updatePlan(ctx, { latest = null } = {}) {
66
+ const target = latest ? `${PLAYWRIGHT_PACKAGE}@${latest}` : PLAYWRIGHT_PACKAGE;
67
+ return {
68
+ command: 'npm',
69
+ args: ['install', '-g', target],
70
+ display: `npm install -g ${target}`,
71
+ 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.',
72
+ after: async () => { await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot, fresh: true }); },
73
+ };
74
+ },
58
75
  async toolsForTurn(ctx, turn) {
59
- if (!(await playwrightVersion({ env: ctx.env }))) return [];
76
+ if (!(await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot }))) return [];
60
77
  if (turn.mode === 0) return []; // a ghost turn leaves no screenshots and opens no browser
61
78
  const outputDir = turn.scratchDir ?? join(turn.roomDir ?? ctx.stateRoot, 'playwright');
62
79
  return [playwrightServerFor({ port: turn.port, outputDir, browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false })];
@@ -68,6 +85,6 @@ export default defineModule({
68
85
  match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
69
86
  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
87
  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'] },
88
+ fixes: { darwin: ['npm install -g @playwright/mcp', 'npx playwright install chromium'], linux: ['npm install -g @playwright/mcp', 'npx playwright install --with-deps chromium'] },
72
89
  }],
73
90
  });
@@ -2,17 +2,19 @@
2
2
  // A switch in config.json, read live by the preview route.
3
3
 
4
4
  import { defineModule } from './sdk.mjs';
5
+ import { t } from '../i18n.mjs';
5
6
 
6
7
  export default defineModule({
7
8
  id: 'ripley',
8
9
  name: 'RIPLEY',
9
10
  vendor: 'MADRE · PREVIEW',
10
- summary: 'Renders HTML, SVG and Markdown from the project and from .pulse/out in the file viewer, inside a sealed frame: scripts run but nothing leaves, nothing is stored, nothing reaches MADRE. Nothing leaves the room.',
11
- creates: ['nothing in the project', 'a ripley switch in ~/.pulse/config.json'],
11
+ version: '1.0.0',
12
+ summary: 'Renders HTML, SVG and Markdown from the project and from .pulse/out in the file viewer, inside a sealed frame.',
13
+ creates: ['nothing in the project', 'a switch in ~/.pulse/config.json', 'scripts run in the frame \u00b7 nothing leaves, nothing is stored, nothing reaches MADRE'],
12
14
  card: 'ripley',
13
15
  async status(ctx) {
14
16
  return {
15
- status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? 'on · PREVIEW in the file viewer' : 'off · files show as source' },
17
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · PREVIEW in the file viewer') : t('off · files show as source') },
16
18
  install: { display: ctx.settings.enabled ? 'disable RIPLEY' : 'enable RIPLEY (config.json)', platforms: [] },
17
19
  fixed: false,
18
20
  };
@@ -14,8 +14,43 @@
14
14
  // Kinds: 'builtin' switches MADRE's own behaviour (config.json only);
15
15
  // 'installer' writes into the project through a confirmed command.
16
16
 
17
+ import { readFile, stat } from 'node:fs/promises';
18
+
17
19
  const camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
18
20
 
21
+ let release = null;
22
+ // MADRE's own release, read once from the package that is running.
23
+ export async function madreRelease() {
24
+ if (release) return release;
25
+ const raw = await readFile(new URL('../../package.json', import.meta.url), 'utf8').catch(() => '');
26
+ try { release = JSON.parse(raw).version ?? '0.0.0'; } catch { release = '0.0.0'; }
27
+ return release;
28
+ }
29
+
30
+ // What version a card shows. A module that is a wrapper around something else — a browser server,
31
+ // a local model runner, a CLI — has no version worth showing of its own: what matters is the
32
+ // version of the thing it drives, found on this computer, and `null` means it is not there. Every
33
+ // other module declares its own, starting at 1.0.0. A module someone else wrote and left
34
+ // unversioned falls back to the day its file was written, which is the only truth on disk.
35
+ export async function versionOf(module, { declared = null, tracked } = {}) {
36
+ if (tracked !== undefined) return { version: tracked, source: 'tracked' };
37
+ if (declared) return { version: declared, source: 'declared' };
38
+ if (module?.external) {
39
+ const when = await stat(module.file).then((info) => info.mtime).catch(() => null);
40
+ return when ? { version: when.toISOString().slice(0, 10), source: 'file' } : { version: null, source: 'none' };
41
+ }
42
+ return { version: '1.0.0', source: 'declared' };
43
+ }
44
+
45
+ // What a module drives that is not MADRE and not the module itself: an npm package, a server, a
46
+ // binary on this computer. `version` is what was found here and now, `null` when it is not
47
+ // installed at all; `target` is what the module would install if asked. The card shows each one
48
+ // as its own tag, so a version on screen always belongs to something nameable.
49
+ export function dependencies(runs) {
50
+ const list = Array.isArray(runs) ? runs : runs ? [runs] : [];
51
+ return list.filter((dep) => dep && dep.name).map((dep) => ({ name: String(dep.name), version: dep.version ?? null, target: dep.target ?? null }));
52
+ }
53
+
19
54
  export function defineModule(spec) {
20
55
  if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
21
56
  if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
@@ -23,9 +58,25 @@ export function defineModule(spec) {
23
58
  const configKey = spec.configKey ?? camel(spec.id);
24
59
  const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
25
60
  const base = {
26
- id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? '0.1.0',
27
- summary: spec.summary ?? '', creates: spec.creates ?? [], requires: spec.requires ?? [], models: spec.models ?? [], commands: spec.commands,
61
+ id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? null,
62
+ // What this module's version follows, when it is not its own: { name, npm } or { name, github }.
63
+ // MADRE reads the version from there and looks for a newer one on its own, once a day.
64
+ tracks: spec.tracks ? { name: spec.tracks.name ?? spec.tracks.npm ?? spec.tracks.github ?? null, npm: spec.tracks.npm ?? null, github: spec.tracks.github ?? null } : null,
65
+ // Where a newer version of this module itself is published. A module you wrote says where it
66
+ // lives and MADRE can go and get it: the file is fetched, checked the same way an upload is,
67
+ // and only replaces the one installed if it passes and says it is newer.
68
+ updates: spec.updates?.url && /^https:\/\//.test(String(spec.updates.url)) ? { url: String(spec.updates.url) } : null,
69
+ summary: spec.summary ?? '', creates: spec.creates ?? [], requires: spec.requires ?? [], models: spec.models ?? [], commands: spec.commands ?? (spec.slash?.length ? spec.slash.map((command) => command.usage ?? `/${command.name}`) : undefined),
28
70
  card: spec.card ?? (kind === 'builtin' ? 'switch' : 'installer'),
71
+ // The settings floor of the card, declared instead of drawn: MADRE renders these and saves
72
+ // them into the module's own block of ~/.pulse/config.json.
73
+ controls: (spec.controls ?? []).map((control) => {
74
+ if (!control?.key) throw new Error(`Module ${spec.id}: a control needs a key.`);
75
+ const type = control.type ?? 'switch';
76
+ if (!['select', 'switch', 'text'].includes(type)) throw new Error(`Module ${spec.id}: control ${control.key} has no such type "${type}".`);
77
+ if (type === 'select' && !control.options?.length) throw new Error(`Module ${spec.id}: control ${control.key} is a select with no options.`);
78
+ return { key: control.key, label: control.label ?? control.key.toUpperCase(), type, options: control.options ?? [], note: control.note ?? '', invert: Boolean(control.invert) };
79
+ }),
29
80
  };
30
81
  const settingsFrom = (config) => ({ ...defaults, ...(config?.modules?.[configKey] ?? {}) });
31
82
  const module = {
@@ -36,12 +87,23 @@ export function defineModule(spec) {
36
87
  routes: (spec.routes ?? []).map((route) => ({ ...route, method: route.method.toUpperCase() })),
37
88
  onEvent: spec.onEvent ?? null,
38
89
  conditions: spec.conditions ?? [],
90
+ // Slash commands the human types in the composer; they run on the server with the module's
91
+ // ctx and settings and land in the room as a fact card everyone reads. Only while the module is on.
92
+ slash: (spec.slash ?? []).map((command) => {
93
+ if (!command?.name || !/^[a-z][a-z0-9-]*$/.test(command.name)) throw new Error(`Module ${spec.id}: a slash command needs a kebab-case name.`);
94
+ if (typeof command.execute !== 'function') throw new Error(`Module ${spec.id}: /${command.name} needs an execute(ctx, args) function.`);
95
+ return { name: command.name, usage: command.usage ?? `/${command.name}`, summary: command.summary ?? '', title: command.title ?? spec.name, available: command.available ?? null, execute: command.execute };
96
+ }),
39
97
  // Tools for a turn, only while the module is on. Failures never break a turn.
40
98
  toolsForTurn: spec.toolsForTurn ? async (ctx, turn) => {
41
99
  const settings = settingsFrom(ctx.config);
42
100
  if (kind === 'builtin' && !settings.enabled) return [];
43
101
  try { return (await spec.toolsForTurn({ ...ctx, settings }, turn)) ?? []; } catch (error) { console.error(`MADRE module ${spec.id}: toolsForTurn failed: ${error.message}`); return []; }
44
102
  } : null,
103
+ // How the outside thing this module drives gets a newer version onto this computer. MADRE
104
+ // shows the command before it runs and never runs one the human has not read. A module that
105
+ // cannot update what it drives returns the note that says where to get it instead.
106
+ updatePlan: spec.updatePlan ? async (ctx, what) => spec.updatePlan({ ...ctx, settings: settingsFrom(ctx.config) }, what) : null,
45
107
  // Legacy installer hooks, kept on the object so the confirm-and-run path can use them.
46
108
  detect: spec.detect ?? null,
47
109
  preflight: spec.preflight ?? null,
@@ -52,15 +114,44 @@ export function defineModule(spec) {
52
114
  const settings = settingsFrom(ctx.config);
53
115
  const own = spec.status ? await spec.status({ ...ctx, settings }) : {};
54
116
  const installed = own.installed ?? (kind === 'builtin' ? Boolean(settings.enabled) : false);
117
+ const runs = dependencies(own.runs);
118
+ const tracked = base.tracks ? (runs.find((dep) => dep.name === base.tracks.name)?.version ?? null) : undefined;
119
+ const stamp = await versionOf(this, { declared: spec.version ?? null, tracked });
55
120
  return {
56
121
  ...base,
122
+ ...(this.external ? { external: true, origin: this.origin, file: this.file } : {}),
57
123
  ...own,
124
+ version: stamp.version,
125
+ versionSource: stamp.source,
126
+ canUpdate: Boolean(spec.updatePlan),
127
+ controls: base.controls.map((control) => ({ ...control, value: settings[control.key] ?? null })),
128
+ ships: this.external ? null : await madreRelease(),
129
+ runs,
58
130
  status: own.status ?? { installed, detail: own.detail ?? (installed ? 'on' : 'off') },
59
131
  preflight: own.preflight ?? { ok: true, problems: [] },
60
132
  install: own.install ?? (kind === 'builtin' ? { display: installed ? `disable ${base.name}` : `enable ${base.name} (config.json)`, platforms: [] } : { display: '', platforms: [] }),
61
133
  };
62
134
  },
63
135
 
136
+ // One setting from the card's own floor. Only a key the module declared, only a value its
137
+ // type allows, and the module hears about it if it asked to.
138
+ setControl: base.controls.length ? async (ctx, { key, value } = {}) => {
139
+ const control = base.controls.find((known) => known.key === key);
140
+ if (!control) return { status: 400, body: { error: `${base.name} has no setting "${key}".` } };
141
+ let next = value;
142
+ if (control.type === 'switch') {
143
+ if (typeof value !== 'boolean') return { status: 400, body: { error: `${control.label} is on or off.` } };
144
+ } else if (control.type === 'select') {
145
+ if (!control.options.includes(value)) return { status: 400, body: { error: `${control.label} must be one of ${control.options.join(', ')}.` } };
146
+ } else {
147
+ next = String(value ?? '').slice(0, 500);
148
+ }
149
+ const settings = { ...settingsFrom(ctx.config), [key]: next };
150
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), [key]: next } } });
151
+ if (spec.onSettings) await spec.onSettings({ ...ctx, settings }, settings);
152
+ return { status: 200, body: { settings: Object.fromEntries(base.controls.map((known) => [known.key, settings[known.key] ?? null])) } };
153
+ } : null,
154
+
64
155
  // The switch. Default for builtins: flip `enabled`, persist, tell the room.
65
156
  // A module may guard it (`confirm`) or replace it (`toggle`).
66
157
  toggle: kind === 'builtin' || spec.toggle ? async (ctx, payload = {}) => {