@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.
- package/CHANGELOG.md +412 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +67 -185
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3825 -851
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +94 -13
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +602 -62
- package/public/troubleshooting.js +168 -46
- package/src/adapters/claude.mjs +2 -1
- package/src/adapters/codex.mjs +2 -1
- package/src/adapters/gemini.mjs +6 -5
- package/src/adapters/opencode.mjs +2 -1
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +6 -0
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +5 -3
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +36 -19
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +93 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -46
- package/src/room.mjs +443 -44
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +699 -69
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +4 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 ?
|
|
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
|
};
|
package/src/modules/index.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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';
|
package/src/modules/ollama.mjs
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 ?
|
|
20
|
-
const detail = !probe.running ? 'not running ·
|
|
21
|
-
: !settings.enabled ?
|
|
22
|
-
: roles.length ?
|
|
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:
|
|
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
|
|
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?
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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']
|
|
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
|
});
|
package/src/modules/ripley.mjs
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
};
|
package/src/modules/sdk.mjs
CHANGED
|
@@ -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 ??
|
|
27
|
-
|
|
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 = {}) => {
|