@jossuealcala/madre 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +497 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +68 -186
- 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 +3979 -867
- package/public/es.js +2258 -0
- package/public/i18n.js +66 -0
- package/public/index.html +96 -15
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +622 -65
- package/public/troubleshooting.js +255 -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 +79 -20
- 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 +36 -3
- 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 +10 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +44 -23
- 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 +297 -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/sentinel-errors.mjs +19 -1
- package/src/server.mjs +709 -71
- 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
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 = {}) => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Is there a newer version of what a module is? Two kinds of answer, because there are two kinds
|
|
2
|
+
// of module.
|
|
3
|
+
//
|
|
4
|
+
// A module that ships with MADRE has its own version, written in its file and starting at 1.0.0.
|
|
5
|
+
// It cannot update on its own: it arrives in a release, so its update is MADRE's update, and the
|
|
6
|
+
// card says which release it travels in.
|
|
7
|
+
//
|
|
8
|
+
// A module that is a wrapper around something else — @playwright/mcp, Ollama, the AHP+ CLI — has
|
|
9
|
+
// no version of its own worth showing. Its version is that thing's version, found on this
|
|
10
|
+
// computer, and its update is that thing's next release. A module declares this once, in the SDK:
|
|
11
|
+
//
|
|
12
|
+
// tracks: { name: '@playwright/mcp', npm: '@playwright/mcp' }
|
|
13
|
+
// tracks: { name: 'ollama', github: 'ollama/ollama' }
|
|
14
|
+
//
|
|
15
|
+
// Every check is cached a day in ~/.pulse/updates/, goes out only while the release channel is on,
|
|
16
|
+
// and sends nothing but the name of the thing being asked about. A screen never waits on it:
|
|
17
|
+
// MODULES reads the cache, and the button on a card is what forces a fresh look.
|
|
18
|
+
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
21
|
+
import { checkForUpdate, compareVersions, UPDATE_TTL_MS } from '../updates.mjs';
|
|
22
|
+
|
|
23
|
+
export const GITHUB_RELEASES = 'https://api.github.com/repos';
|
|
24
|
+
|
|
25
|
+
async function readCache(file) {
|
|
26
|
+
try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The newest release of a repository, by its tag. Same shape and same cache as the registry read,
|
|
30
|
+
// because a card does not care where a version comes from.
|
|
31
|
+
export async function checkGithubRelease({ repo, current, cacheFile, fetchImpl = globalThis.fetch, now = Date.now(), ttlMs = UPDATE_TTL_MS, enabled = true, force = false, cacheOnly = false, timeoutMs = 4000 } = {}) {
|
|
32
|
+
const cached = cacheFile ? await readCache(cacheFile) : null;
|
|
33
|
+
const known = cached?.name === repo ? cached : null;
|
|
34
|
+
const fresh = known && Number.isFinite(known.checkedAt) && now - known.checkedAt < ttlMs;
|
|
35
|
+
let latest = known?.latest ?? null;
|
|
36
|
+
let checkedAt = known?.checkedAt ?? null;
|
|
37
|
+
let source = latest ? 'cache' : 'none';
|
|
38
|
+
let error = null;
|
|
39
|
+
if (enabled && !cacheOnly && (force || !fresh)) {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetchImpl(`${GITHUB_RELEASES}/${repo}/releases/latest`, { headers: { accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
42
|
+
if (!response.ok) throw new Error(`github HTTP ${response.status}`);
|
|
43
|
+
const doc = await response.json();
|
|
44
|
+
const tag = String(doc?.tag_name ?? doc?.name ?? '').trim().replace(/^v/, '');
|
|
45
|
+
if (!tag) throw new Error('github answered without a release');
|
|
46
|
+
latest = tag;
|
|
47
|
+
checkedAt = now;
|
|
48
|
+
source = 'github';
|
|
49
|
+
if (cacheFile) {
|
|
50
|
+
await mkdir(dirname(cacheFile), { recursive: true }).catch(() => {});
|
|
51
|
+
await writeFile(cacheFile, JSON.stringify({ name: repo, latest, checkedAt })).catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
error = cause.message;
|
|
55
|
+
if (!latest) source = 'error';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!enabled) source = latest ? 'cache' : 'off';
|
|
59
|
+
return { enabled, current: current ?? null, latest, available: Boolean(latest && current && compareVersions(current, latest) < 0), checkedAt, source, error };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What one card should say about being up to date. `item` is a described module.
|
|
63
|
+
export async function moduleUpdate(item, { stateRoot, madre, fetchImpl = globalThis.fetch, enabled = true, force = false, cacheOnly = !force, now = Date.now() } = {}) {
|
|
64
|
+
const tracks = item?.tracks ?? null;
|
|
65
|
+
const common = { fetchImpl, enabled, force, cacheOnly, now };
|
|
66
|
+
if (!tracks) {
|
|
67
|
+
// It ships with MADRE, so what can be newer is MADRE.
|
|
68
|
+
const check = await checkForUpdate({ name: madre.name, current: madre.version, cacheFile: join(stateRoot, 'updates.json'), ...common });
|
|
69
|
+
return { via: 'madre', name: madre.name, current: item?.version ?? null, ships: madre.version, latest: check.latest, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
|
|
70
|
+
}
|
|
71
|
+
const cacheFile = join(stateRoot, 'updates', `${item.id}.json`);
|
|
72
|
+
if (tracks.npm) {
|
|
73
|
+
const check = await checkForUpdate({ name: tracks.npm, current: item.version, cacheFile, ...common });
|
|
74
|
+
return { via: 'npm', name: tracks.npm, current: item.version ?? null, latest: check.latest, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
|
|
75
|
+
}
|
|
76
|
+
if (tracks.github) {
|
|
77
|
+
const check = await checkGithubRelease({ repo: tracks.github, current: item.version, cacheFile, ...common });
|
|
78
|
+
return { via: 'github', name: tracks.name ?? tracks.github, latest: check.latest, current: item.version ?? null, available: check.available, checkedAt: check.checkedAt, source: check.source, error: check.error };
|
|
79
|
+
}
|
|
80
|
+
return { via: 'none', name: tracks.name ?? item.name, current: item.version ?? null, latest: null, available: false, checkedAt: null, source: 'none', error: null };
|
|
81
|
+
}
|
package/src/ollama.mjs
CHANGED
|
@@ -34,16 +34,19 @@ export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.
|
|
|
34
34
|
const response = await fetchImpl(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
35
35
|
if (!response.ok) return { running: false, host, models: [], embedModel: null, chatModel: null, error: `HTTP ${response.status}` };
|
|
36
36
|
const payload = await response.json();
|
|
37
|
+
// What Ollama itself is running. It answers or it does not; either way the probe goes on.
|
|
38
|
+
const version = await fetchImpl(`${host}/api/version`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
39
|
+
.then((answer) => (answer.ok ? answer.json() : null)).then((body) => body?.version ?? null).catch(() => null);
|
|
37
40
|
const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, family: model.details?.family ?? '', details: model.details ?? {} }));
|
|
38
41
|
const embeds = models.filter(isEmbedModel);
|
|
39
42
|
const chats = models.filter((model) => !isEmbedModel(model));
|
|
40
43
|
return {
|
|
41
|
-
running: true, host, models,
|
|
44
|
+
running: true, host, version, models,
|
|
42
45
|
embedModel: pick(embeds, EMBED_MODELS, env.PULSE_OLLAMA_EMBED_MODEL),
|
|
43
46
|
chatModel: pick(chats, CHAT_MODELS, env.PULSE_OLLAMA_MODEL),
|
|
44
47
|
};
|
|
45
48
|
} catch (error) {
|
|
46
|
-
return { running: false, host, models: [], embedModel: null, chatModel: null, error: error.message };
|
|
49
|
+
return { running: false, host, version: null, models: [], embedModel: null, chatModel: null, error: error.message };
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
package/src/outbound.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// What left this machine.
|
|
2
|
+
//
|
|
3
|
+
// MADRE runs on your computer and keeps everything it knows in a file you own, and that sentence
|
|
4
|
+
// is worth exactly as much as the list that qualifies it. Something does leave: a version check,
|
|
5
|
+
// an embedding, a crash report, the briefing itself. A person deciding whether to point this at a
|
|
6
|
+
// private codebase needs that list to be complete, and needs it to come from the code rather than
|
|
7
|
+
// from a promise in a README.
|
|
8
|
+
//
|
|
9
|
+
// So there are two halves here and they are different things.
|
|
10
|
+
//
|
|
11
|
+
// DESTINATIONS is the declaration: every address MADRE's own process may reach, what it says
|
|
12
|
+
// there, what puts it on and where you turn it off. It is written by hand because it says what a
|
|
13
|
+
// request MEANS, and no wrapper can know that.
|
|
14
|
+
//
|
|
15
|
+
// The log is the check on the declaration. Every fetch the room's process makes goes through one
|
|
16
|
+
// wrapper — MADRE's own calls and any a module makes, because a module installed tomorrow runs
|
|
17
|
+
// inside this process and cannot opt out of it — and lands in a line saying where it went and
|
|
18
|
+
// when. An address nobody declared shows up as one nobody declared, which is the whole point.
|
|
19
|
+
//
|
|
20
|
+
// What is never recorded: bodies, headers, and the values of query parameters. The names stay.
|
|
21
|
+
// The Gemini embedding endpoint takes the key in the URL, and a log of what left this machine
|
|
22
|
+
// would be a poor place to leave it.
|
|
23
|
+
|
|
24
|
+
import { appendFile, mkdir, open, stat, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
26
|
+
|
|
27
|
+
export const KEEP = 200; // lines held for the screen
|
|
28
|
+
export const MAX_BYTES = 512 * 1024; // the file is trimmed to the last KEEP lines past this
|
|
29
|
+
const LOCAL = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0', '[::1]']);
|
|
30
|
+
|
|
31
|
+
// Where an unknown condition goes when somebody presses SEND. It lives here because this is the
|
|
32
|
+
// file that has to name every address MADRE can reach; the room reads it from here.
|
|
33
|
+
// The collector, under the product's own name. The address a human reads in the outbound log is
|
|
34
|
+
// part of what they are being asked to trust, and a workers.dev subdomain with an account name in
|
|
35
|
+
// it reads like somebody's scratch endpoint. The old address must keep answering: every room on
|
|
36
|
+
// 0.4.0 and earlier carries it compiled in, and turning it off would stop their reports silently.
|
|
37
|
+
export const DEFAULT_REPORT_URL = 'https://madre.run/v1/reports';
|
|
38
|
+
export const LEGACY_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
|
|
39
|
+
|
|
40
|
+
// The declaration. `what` is the honest sentence: what a request carries, not what it is called.
|
|
41
|
+
export const DESTINATIONS = [
|
|
42
|
+
{
|
|
43
|
+
id: 'crew', host: null, to: 'whoever runs the agent you send a turn to',
|
|
44
|
+
what: 'The briefing — the document above, your question, the memories it summoned and the transcript it carries.',
|
|
45
|
+
when: 'every turn you send to an agent that is not @madre',
|
|
46
|
+
where: 'you choose it every time you send a turn; @madre answers on this computer and sends nothing',
|
|
47
|
+
inside: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'embeddings', host: 'generativelanguage.googleapis.com', match: (url) => /batchEmbedContents/.test(url.pathname), to: 'Google · Gemini embeddings',
|
|
51
|
+
what: 'The text of what is being embedded: the first 2000 characters of each memory, and the question a recall is made of.',
|
|
52
|
+
when: 'a memory is written or recalled, while embeddings are set to Gemini',
|
|
53
|
+
where: 'MU/TH/UR → MEMORY → EMBEDDINGS · OLLAMA keeps it on this computer, OFF drops back to words',
|
|
54
|
+
inside: true,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'image', host: 'generativelanguage.googleapis.com', match: (url) => /generateContent/.test(url.pathname), to: 'Google · Gemini image model',
|
|
58
|
+
what: 'The prompt an agent wrote for an image.',
|
|
59
|
+
when: 'an agent calls the image tool during a lease',
|
|
60
|
+
where: 'MODULES → IMAGE STUDIO',
|
|
61
|
+
// It runs in a process of its own and writes its own line into this same log, so it is here.
|
|
62
|
+
inside: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'npm', host: 'registry.npmjs.org', to: 'the npm registry',
|
|
66
|
+
what: 'The name of a package, to read the version of its latest release. Nothing about you or your project.',
|
|
67
|
+
when: 'at most once a day per package, while the release channel is on',
|
|
68
|
+
where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
|
|
69
|
+
inside: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'github', host: 'api.github.com', to: 'GitHub',
|
|
73
|
+
what: 'The name of a repository, to read the tag of its latest release. Nothing about you or your project.',
|
|
74
|
+
when: 'at most once a day per repository, while the release channel is on',
|
|
75
|
+
where: 'MU/TH/UR → RELEASE CHANNEL · or PULSE_UPDATE_CHECK=0',
|
|
76
|
+
inside: true,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'reports', host: new URL(DEFAULT_REPORT_URL).hostname, to: "the author's error collector",
|
|
80
|
+
what: 'An unknown condition: what broke, where in MADRE, and the version. Paths are cut back and your words are not in it.',
|
|
81
|
+
when: 'you press SEND — or on its own, only while auto-report is on',
|
|
82
|
+
where: 'MU/TH/UR → AUTO-REPORT UNKNOWN CONDITIONS · or PULSE_REPORT_URL',
|
|
83
|
+
inside: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'anthropic', host: 'api.anthropic.com', to: 'Anthropic',
|
|
87
|
+
what: 'Your Claude Code token, to read how much of your plan is left. It is read from where Claude keeps it and never stored by MADRE.',
|
|
88
|
+
when: 'while a Claude session is being watched for its quota',
|
|
89
|
+
where: '⚙ CONNECTIONS → CLAUDE',
|
|
90
|
+
inside: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'ollama', host: null, local: true, to: 'Ollama, on this computer',
|
|
94
|
+
what: 'Memories to embed, exchanges to distil, and whatever you ask @madre. It goes to a port on this machine and stops there.',
|
|
95
|
+
when: 'while Ollama is running',
|
|
96
|
+
where: 'MODULES → OLLAMA',
|
|
97
|
+
inside: true,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const byId = new Map(DESTINATIONS.map((one) => [one.id, one]));
|
|
102
|
+
export const destination = (id) => byId.get(id) ?? null;
|
|
103
|
+
|
|
104
|
+
// Which declaration a URL belongs to. An address nobody declared is said to be exactly that.
|
|
105
|
+
export function classify(rawUrl, { reportHost = null } = {}) {
|
|
106
|
+
let url;
|
|
107
|
+
try { url = new URL(rawUrl); } catch { return { id: 'unknown', to: String(rawUrl).slice(0, 80), local: false }; }
|
|
108
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
109
|
+
if (LOCAL.has(host)) return { id: url.port === '11434' ? 'ollama' : 'room', to: `${host}:${url.port || '80'}`, local: true };
|
|
110
|
+
if (reportHost && host === reportHost) return { id: 'reports', to: byId.get('reports').to, local: false };
|
|
111
|
+
for (const one of DESTINATIONS) {
|
|
112
|
+
if (!one.host || one.host !== host) continue;
|
|
113
|
+
if (one.match && !one.match(url)) continue;
|
|
114
|
+
return { id: one.id, to: one.to, local: false };
|
|
115
|
+
}
|
|
116
|
+
return { id: 'unknown', to: host, local: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One line of the log. No body, no headers, and no query VALUES — only which parameters were set.
|
|
120
|
+
export function lineFor({ url, method = 'GET', status = null, ok = false, ms = 0, bytes = null, error = null, at, reportHost = null }) {
|
|
121
|
+
const kind = classify(url, { reportHost });
|
|
122
|
+
let path = '';
|
|
123
|
+
let params = [];
|
|
124
|
+
try {
|
|
125
|
+
const parsed = new URL(url);
|
|
126
|
+
path = parsed.pathname;
|
|
127
|
+
params = [...parsed.searchParams.keys()];
|
|
128
|
+
} catch { path = ''; }
|
|
129
|
+
return {
|
|
130
|
+
at: at ?? new Date().toISOString(),
|
|
131
|
+
id: kind.id,
|
|
132
|
+
to: kind.to,
|
|
133
|
+
local: Boolean(kind.local),
|
|
134
|
+
method: String(method || 'GET').toUpperCase(),
|
|
135
|
+
path,
|
|
136
|
+
...(params.length ? { params } : {}),
|
|
137
|
+
...(Number.isFinite(status) ? { status } : {}),
|
|
138
|
+
ok: Boolean(ok),
|
|
139
|
+
ms: Math.round(ms),
|
|
140
|
+
...(Number.isFinite(bytes) ? { bytes } : {}),
|
|
141
|
+
...(error ? { error } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function tail(file, window = 96 * 1024) {
|
|
146
|
+
let handle;
|
|
147
|
+
try {
|
|
148
|
+
const size = (await stat(file)).size;
|
|
149
|
+
if (!size) return [];
|
|
150
|
+
handle = await open(file, 'r');
|
|
151
|
+
const length = Math.min(window, size);
|
|
152
|
+
const buffer = Buffer.alloc(length);
|
|
153
|
+
await handle.read(buffer, 0, length, size - length);
|
|
154
|
+
const lines = buffer.toString('utf8').split('\n').filter(Boolean);
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
try { out.push(JSON.parse(line)); } catch { /* a half line at the window's edge */ }
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
} finally {
|
|
163
|
+
await handle?.close().catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class OutboundLog {
|
|
168
|
+
#file;
|
|
169
|
+
#keep;
|
|
170
|
+
#recent = [];
|
|
171
|
+
#counts = new Map();
|
|
172
|
+
#writes = Promise.resolve();
|
|
173
|
+
#reportHost = null;
|
|
174
|
+
#restore = null;
|
|
175
|
+
|
|
176
|
+
constructor({ file = null, keep = KEEP } = {}) {
|
|
177
|
+
this.#file = file;
|
|
178
|
+
this.#keep = keep;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The collector's address is a setting, so which host counts as the collector is read live.
|
|
182
|
+
watchReportUrl(url) {
|
|
183
|
+
try { this.#reportHost = url ? new URL(url).hostname : null; } catch { this.#reportHost = null; }
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async load() {
|
|
188
|
+
if (!this.#file) return this;
|
|
189
|
+
const lines = await tail(this.#file);
|
|
190
|
+
this.#recent = lines.slice(-this.#keep);
|
|
191
|
+
for (const line of this.#recent) this.#count(line);
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#count(line) {
|
|
196
|
+
const at = this.#counts.get(line.id) ?? { calls: 0, failed: 0, last: null };
|
|
197
|
+
at.calls += 1;
|
|
198
|
+
if (!line.ok) at.failed += 1;
|
|
199
|
+
at.last = line.at;
|
|
200
|
+
this.#counts.set(line.id, at);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
record(input) {
|
|
204
|
+
const line = lineFor({ ...input, reportHost: this.#reportHost });
|
|
205
|
+
this.#recent.push(line);
|
|
206
|
+
if (this.#recent.length > this.#keep) this.#recent.splice(0, this.#recent.length - this.#keep);
|
|
207
|
+
this.#count(line);
|
|
208
|
+
if (this.#file) {
|
|
209
|
+
this.#writes = this.#writes
|
|
210
|
+
.then(async () => {
|
|
211
|
+
await mkdir(dirname(this.#file), { recursive: true }).catch(() => {});
|
|
212
|
+
await appendFile(this.#file, `${JSON.stringify(line)}\n`);
|
|
213
|
+
const size = await stat(this.#file).then((one) => one.size, () => 0);
|
|
214
|
+
// Only a writer that is holding a full screen of lines may rewrite the file: the
|
|
215
|
+
// image studio appends from its own process and knows nothing of what came before it.
|
|
216
|
+
if (size > MAX_BYTES && this.#recent.length >= this.#keep) await writeFile(this.#file, `${this.#recent.map((one) => JSON.stringify(one)).join('\n')}\n`);
|
|
217
|
+
})
|
|
218
|
+
.catch(() => null);
|
|
219
|
+
}
|
|
220
|
+
return line;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
recent({ limit = 40 } = {}) { return this.#recent.slice(-limit).reverse(); }
|
|
224
|
+
|
|
225
|
+
counts() { return Object.fromEntries(this.#counts); }
|
|
226
|
+
|
|
227
|
+
// The wrapper. It never changes what the caller gets back and never reads the response body:
|
|
228
|
+
// a log that consumed what it watched would break the thing it is watching.
|
|
229
|
+
watch(fetchImpl = globalThis.fetch) {
|
|
230
|
+
if (fetchImpl?.watched) return fetchImpl;
|
|
231
|
+
const log = this;
|
|
232
|
+
const watched = async function outbound(resource, init = {}) {
|
|
233
|
+
const url = typeof resource === 'string' ? resource : (resource?.url ?? String(resource));
|
|
234
|
+
const method = init?.method ?? resource?.method ?? 'GET';
|
|
235
|
+
const bytes = typeof init?.body === 'string' ? Buffer.byteLength(init.body) : null;
|
|
236
|
+
const started = Date.now();
|
|
237
|
+
try {
|
|
238
|
+
const response = await fetchImpl(resource, init);
|
|
239
|
+
log.record({ url, method, bytes, status: response?.status ?? null, ok: Boolean(response?.ok), ms: Date.now() - started });
|
|
240
|
+
return response;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// The name and the code, never the message: a message carries the address it failed on
|
|
243
|
+
// and sometimes what was in it.
|
|
244
|
+
log.record({ url, method, bytes, ok: false, ms: Date.now() - started, error: error?.code ?? error?.name ?? 'failed' });
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
watched.watched = true;
|
|
249
|
+
return watched;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Everything in this process, including whatever a module calls. Returns the undo.
|
|
253
|
+
install(target = globalThis) {
|
|
254
|
+
if (this.#restore) return this.#restore;
|
|
255
|
+
const original = target.fetch;
|
|
256
|
+
// Binding makes a new function, which would hide a watch somebody else already installed;
|
|
257
|
+
// the mark travels with it so one process logs a request once.
|
|
258
|
+
const bound = original.bind(target);
|
|
259
|
+
bound.watched = original.watched;
|
|
260
|
+
target.fetch = this.watch(bound);
|
|
261
|
+
this.#restore = () => { target.fetch = original; this.#restore = null; };
|
|
262
|
+
return this.#restore;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async drain() { await this.#writes; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The declaration with today's answer filled in: is this one on, and what has it done.
|
|
269
|
+
export function outboundView({ log = null, state = {}, agents = [] } = {}) {
|
|
270
|
+
const counts = log?.counts() ?? {};
|
|
271
|
+
const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
|
|
272
|
+
const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${vendors[agent.id] ?? 'its own provider'}`);
|
|
273
|
+
const destinations = DESTINATIONS.map((one) => ({
|
|
274
|
+
id: one.id,
|
|
275
|
+
to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
|
|
276
|
+
what: one.what,
|
|
277
|
+
when: one.when,
|
|
278
|
+
where: one.where,
|
|
279
|
+
local: Boolean(one.local),
|
|
280
|
+
// Whether the call lands in the log at all. Everything MADRE starts does, the image studio
|
|
281
|
+
// from its own process included; the crew's own conversation with its provider does not.
|
|
282
|
+
inside: Boolean(one.inside),
|
|
283
|
+
on: state[one.id] ?? null,
|
|
284
|
+
calls: counts[one.id]?.calls ?? 0,
|
|
285
|
+
failed: counts[one.id]?.failed ?? 0,
|
|
286
|
+
last: counts[one.id]?.last ?? null,
|
|
287
|
+
}));
|
|
288
|
+
const undeclared = counts.unknown?.calls ?? 0;
|
|
289
|
+
return {
|
|
290
|
+
destinations,
|
|
291
|
+
recent: log?.recent({ limit: 40 }) ?? [],
|
|
292
|
+
undeclared,
|
|
293
|
+
says: undeclared
|
|
294
|
+
? `${undeclared} request${undeclared === 1 ? '' : 's'} went to an address nothing here declares. A module can do that; nothing can do it unlogged.`
|
|
295
|
+
: 'Every request this process made went to an address declared above.',
|
|
296
|
+
};
|
|
297
|
+
}
|