@jossuealcala/madre 0.2.3 → 0.3.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.
@@ -0,0 +1,26 @@
1
+ // Git Pulse: /git in the composer brings the repository's facts into the room.
2
+ // Nothing to switch: it is on wherever the project is a git repository.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+ import { gitToplevel } from './helpers.mjs';
6
+
7
+ export default defineModule({
8
+ id: 'git-pulse',
9
+ name: 'Git Pulse',
10
+ vendor: 'MADRE',
11
+ summary: 'Type /git in the composer to bring the repository\'s branch, uncommitted changes, recent commits or diff stats into the room as a shared fact card, read-only, without spending an agent turn.',
12
+ creates: ['nothing: read-only git commands run inside the project'],
13
+ requires: ['the project is a git repository'],
14
+ commands: ['/git status', '/git log [n]', '/git diff', '/git branches'],
15
+ card: 'fixed',
16
+ async status(ctx) {
17
+ const isRepo = await gitToplevel(ctx.projectRoot);
18
+ return {
19
+ status: { installed: Boolean(isRepo), detail: isRepo ? 'on · project is a git repository' : 'not a git repository' },
20
+ preflight: isRepo ? { ok: true, problems: [] } : { ok: false, problems: ['Run `git init` in the project to use /git.'] },
21
+ install: { display: '/git in the composer', platforms: [] },
22
+ fixed: true,
23
+ };
24
+ },
25
+ async toggle() { return { status: 405, body: { error: 'Git Pulse has no switch: it is on wherever the project is a git repository.' } }; },
26
+ });
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, readFile, realpath } from 'node:fs/promises';
3
+ import { delimiter, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export async function readJson(path) {
9
+ try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
10
+ }
11
+
12
+ // Where does git think this project lives? AHP+ pins itself to the git root,
13
+ // so a project inside a bigger repository (or a home directory that was
14
+ // accidentally `git init`ed) would receive .ahp/ somewhere else entirely.
15
+ export async function gitToplevel(projectRoot) {
16
+ try {
17
+ const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
18
+ return await realpath(stdout.trim()).catch(() => stdout.trim());
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export async function findOnPath(name, envPath = process.env.PATH ?? '') {
25
+ for (const directory of envPath.split(delimiter).filter(Boolean)) {
26
+ const candidate = join(directory, name);
27
+ if (await access(candidate).then(() => true, () => false)) return candidate;
28
+ }
29
+ return null;
30
+ }
@@ -0,0 +1,39 @@
1
+ // Image Studio: MADRE's own MCP image server on the Gemini image models, for
2
+ // the CLIs that cannot draw natively. A switch and a model in config.json.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+
6
+ const MODELS = ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'];
7
+
8
+ export default defineModule({
9
+ id: 'image-studio',
10
+ configKey: 'imageStudio',
11
+ name: 'Image Studio',
12
+ 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)'],
16
+ models: MODELS,
17
+ settings: { enabled: false, model: MODELS[0] },
18
+ card: 'image-studio',
19
+ async status(ctx) {
20
+ const key = await ctx.services.imageKey();
21
+ const model = ctx.settings.model ?? MODELS[0];
22
+ return {
23
+ 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' },
25
+ 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
+ install: { display: ctx.settings.enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
27
+ };
28
+ },
29
+ async toggle(ctx, payload) {
30
+ const enabled = !ctx.settings.enabled;
31
+ const key = await ctx.services.imageKey();
32
+ if (enabled && !key) return { status: 412, body: { error: 'No Gemini API key found. Sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY, then enable Image Studio.' } };
33
+ const model = typeof payload?.model === 'string' && payload.model ? payload.model : (ctx.settings.model ?? MODELS[0]);
34
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), imageStudio: { enabled, model } } });
35
+ ctx.services.setImageModule({ enabled, model });
36
+ await ctx.record('extension.toggled', { id: 'image-studio', name: 'Image Studio', enabled, model });
37
+ return { status: 200, body: { enabled, model, capabilities: ctx.room.capabilities() } };
38
+ },
39
+ });
@@ -0,0 +1,21 @@
1
+ // The registry. Order is the order MODULES shows.
2
+ import ahp from './ahp.mjs';
3
+ import imageStudio from './image-studio.mjs';
4
+ import gitPulse from './git-pulse.mjs';
5
+ import ashcode from './ashcode.mjs';
6
+ import ripley from './ripley.mjs';
7
+ import ollama from './ollama.mjs';
8
+ import { matchRoute } from './sdk.mjs';
9
+
10
+ export const MODULES = [ahp, imageStudio, gitPulse, ashcode, ripley, ollama];
11
+ export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
12
+ export function describeModules(ctx) { return Promise.all(MODULES.map((module) => module.describe(ctx))); }
13
+ // One flat list of every route a module serves, with the module attached.
14
+ export function findModuleRoute(method, pathname) {
15
+ for (const module of MODULES) {
16
+ const hit = matchRoute(module.routes, method, pathname);
17
+ if (hit) return { module, ...hit };
18
+ }
19
+ return null;
20
+ }
21
+ export { defineModule } from './sdk.mjs';
@@ -0,0 +1,56 @@
1
+ // OLLAMA: local intelligence. Embeddings and distillation on this machine when
2
+ // Ollama runs. The server offers the wiring through ctx.services.ollama.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+ import { RECOMMENDED } from '../ollama.mjs';
6
+
7
+ export default defineModule({
8
+ id: 'ollama',
9
+ name: 'OLLAMA',
10
+ 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)'],
14
+ settings: { enabled: true, embeddings: true, archivist: true, agent: true },
15
+ card: 'ollama',
16
+ async status(ctx) {
17
+ const probe = ctx.services.ollama?.state() ?? { running: false, models: [], embedModel: null, chatModel: null };
18
+ 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';
23
+ return {
24
+ models: probe.models.map((model) => model.name),
25
+ status: { installed: settings.enabled && probe.running && roles.length > 0, detail },
26
+ ollama: { ...probe, settings },
27
+ recommended: RECOMMENDED,
28
+ preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
29
+ install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
30
+ };
31
+ },
32
+ async toggle(ctx) {
33
+ const enabled = !(ctx.settings.enabled ?? true);
34
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: { ...(ctx.config.modules?.ollama ?? {}), enabled } } });
35
+ const status = await ctx.services.ollama.wire();
36
+ await ctx.record('extension.toggled', { id: 'ollama', name: 'OLLAMA', enabled });
37
+ return { status: 200, body: { enabled, ollama: status } };
38
+ },
39
+ 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 } }) },
42
+ { method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
43
+ const next = { ...(ctx.config.modules?.ollama ?? {}) };
44
+ for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];
45
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), ollama: next } });
46
+ return { status: 200, body: { ollama: await ctx.services.ollama.wire({ probe: false }) } };
47
+ } },
48
+ { method: 'POST', path: '/api/ollama/pull', handler: async (ctx, { payload }) => {
49
+ const model = String(payload.model ?? '').trim();
50
+ 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.' } };
51
+ if (!ctx.services.ollama.state().running) return { status: 412, body: { error: 'Ollama is not running.' } };
52
+ const started = await ctx.services.ollama.pull(model);
53
+ return started.ok ? { status: 202, body: { pulling: model } } : { status: 409, body: { error: started.error } };
54
+ } },
55
+ ],
56
+ });
@@ -0,0 +1,20 @@
1
+ // RIPLEY: the file viewer renders HTML, SVG and Markdown inside a sealed frame.
2
+ // A switch in config.json, read live by the preview route.
3
+
4
+ import { defineModule } from './sdk.mjs';
5
+
6
+ export default defineModule({
7
+ id: 'ripley',
8
+ name: 'RIPLEY',
9
+ 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'],
12
+ card: 'ripley',
13
+ async status(ctx) {
14
+ return {
15
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? 'on · PREVIEW in the file viewer' : 'off · files show as source' },
16
+ install: { display: ctx.settings.enabled ? 'disable RIPLEY' : 'enable RIPLEY (config.json)', platforms: [] },
17
+ fixed: false,
18
+ };
19
+ },
20
+ });
@@ -0,0 +1,78 @@
1
+ // MADRE's module SDK. A module is one file: what it is, the settings it keeps
2
+ // in ~/.pulse/config.json, how it describes itself to MODULES, what its switch
3
+ // does, the routes it serves and the events it listens to. The server builds a
4
+ // context (ctx) per call and hands it in; the module never reaches for globals.
5
+ //
6
+ // ctx = { projectRoot, stateRoot, config, settings, env, agents, room, readConfig(), updateConfig(patch),
7
+ // record(type, payload), services: { ... what the server offers } }
8
+ //
9
+ // Kinds: 'builtin' switches MADRE's own behaviour (config.json only);
10
+ // 'installer' writes into the project through a confirmed command.
11
+
12
+ const camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
13
+
14
+ export function defineModule(spec) {
15
+ if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
16
+ if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
17
+ const kind = spec.kind ?? 'builtin';
18
+ const configKey = spec.configKey ?? camel(spec.id);
19
+ const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
20
+ const base = {
21
+ id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? '0.1.0',
22
+ summary: spec.summary ?? '', creates: spec.creates ?? [], requires: spec.requires ?? [], models: spec.models ?? [], commands: spec.commands,
23
+ card: spec.card ?? (kind === 'builtin' ? 'switch' : 'installer'),
24
+ };
25
+ const settingsFrom = (config) => ({ ...defaults, ...(config?.modules?.[configKey] ?? {}) });
26
+ const module = {
27
+ ...base,
28
+ configKey,
29
+ defaults,
30
+ settingsFrom,
31
+ routes: (spec.routes ?? []).map((route) => ({ ...route, method: route.method.toUpperCase() })),
32
+ onEvent: spec.onEvent ?? null,
33
+ conditions: spec.conditions ?? [],
34
+ // Legacy installer hooks, kept on the object so the confirm-and-run path can use them.
35
+ detect: spec.detect ?? null,
36
+ preflight: spec.preflight ?? null,
37
+ installCommand: spec.installCommand ?? null,
38
+
39
+ // What MODULES shows: base fields plus status, preflight, install line and whatever the module adds.
40
+ async describe(ctx) {
41
+ const settings = settingsFrom(ctx.config);
42
+ const own = spec.status ? await spec.status({ ...ctx, settings }) : {};
43
+ const installed = own.installed ?? (kind === 'builtin' ? Boolean(settings.enabled) : false);
44
+ return {
45
+ ...base,
46
+ ...own,
47
+ status: own.status ?? { installed, detail: own.detail ?? (installed ? 'on' : 'off') },
48
+ preflight: own.preflight ?? { ok: true, problems: [] },
49
+ install: own.install ?? (kind === 'builtin' ? { display: installed ? `disable ${base.name}` : `enable ${base.name} (config.json)`, platforms: [] } : { display: '', platforms: [] }),
50
+ };
51
+ },
52
+
53
+ // The switch. Default for builtins: flip `enabled`, persist, tell the room.
54
+ // A module may guard it (`confirm`) or replace it (`toggle`).
55
+ toggle: kind === 'builtin' || spec.toggle ? async (ctx, payload = {}) => {
56
+ const settings = settingsFrom(ctx.config);
57
+ if (spec.toggle) return spec.toggle({ ...ctx, settings }, payload);
58
+ const enabled = !settings.enabled;
59
+ if (enabled && spec.confirm && payload.confirm !== true) return { status: 400, body: { error: spec.confirm } };
60
+ await ctx.updateConfig({ modules: { ...(ctx.config.modules ?? {}), [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
61
+ const after = spec.onToggle ? await spec.onToggle({ ...ctx, settings: { ...settings, enabled } }, enabled) : null;
62
+ await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled, ...(spec.toggledEvent ?? {}) });
63
+ return { status: 200, body: { enabled, ...(after ?? {}), ...(spec.toggledBody?.(enabled) ?? {}) } };
64
+ } : null,
65
+ };
66
+ return Object.freeze(module);
67
+ }
68
+
69
+ // Matches a request against a module's routes; params come from a RegExp path.
70
+ export function matchRoute(routes, method, pathname) {
71
+ for (const route of routes) {
72
+ if (route.method !== method) continue;
73
+ if (typeof route.path === 'string') { if (route.path === pathname) return { route, params: [] }; continue; }
74
+ const match = pathname.match(route.path);
75
+ if (match) return { route, params: match.slice(1) };
76
+ }
77
+ return null;
78
+ }
package/src/ollama.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // Ollama: the local intelligence MADRE leans on when it is there. Embeddings
2
+ // for recall by meaning and a model to distil memories, both on this machine,
3
+ // so remembering costs no tokens elsewhere and nothing leaves. Nothing here is
4
+ // required: without Ollama every path falls back to what it did before.
5
+
6
+ export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
7
+ // Embedding models we know how to talk to, best first.
8
+ export const EMBED_MODELS = ['nomic-embed-text', 'mxbai-embed-large', 'snowflake-arctic-embed', 'all-minilm', 'bge-m3'];
9
+ // Chat models that follow instructions well enough to distil, best first for a 16 GB machine.
10
+ // General chat models first: @madre speaks for the room in the human's language, and coder
11
+ // models drift into other voices. A coder is taken only when nothing else is there.
12
+ export const CHAT_MODELS = ['qwen2.5:7b', 'llama3.1:8b', 'gemma3:4b', 'qwen2.5:3b', 'llama3.2:3b', 'qwen2.5:1.5b', 'llama3.2:1b', 'qwen2.5-coder:7b', 'qwen2.5-coder:1.5b'];
13
+ // Ollama's default window is 4k tokens and it drops the OLDEST text when a prompt overflows:
14
+ // the system prompt goes first. Every MADRE call asks for a wider window.
15
+ export const DEFAULT_NUM_CTX = 8192;
16
+ export const RECOMMENDED = { embed: 'nomic-embed-text', chat: 'qwen2.5:3b' };
17
+
18
+ export function ollamaHost(env = process.env) {
19
+ return (env.PULSE_OLLAMA_HOST ?? env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST).replace(/\/$/, '').replace(/^(?!https?:\/\/)/, 'http://');
20
+ }
21
+
22
+ const isEmbedModel = (model) => EMBED_MODELS.some((name) => model.name.startsWith(name)) || /embed|minilm|bge/i.test(model.name) || /bert|nomic/i.test(model.details?.family ?? '');
23
+ function pick(models, preferred, env) {
24
+ const names = models.map((model) => model.name);
25
+ const bare = (name) => name.replace(/:latest$/, '');
26
+ if (env && names.some((name) => bare(name) === bare(env))) return names.find((name) => bare(name) === bare(env));
27
+ for (const want of preferred) { const hit = names.find((name) => bare(name) === want || bare(name).startsWith(`${want}`)); if (hit) return hit; }
28
+ return names[0] ?? null;
29
+ }
30
+
31
+ // Is Ollama there, and what can it do? Quick, never throws.
32
+ export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.fetch, timeoutMs = 1500, env = process.env } = {}) {
33
+ try {
34
+ const response = await fetchImpl(`${host}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
35
+ if (!response.ok) return { running: false, host, models: [], embedModel: null, chatModel: null, error: `HTTP ${response.status}` };
36
+ const payload = await response.json();
37
+ const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, family: model.details?.family ?? '', details: model.details ?? {} }));
38
+ const embeds = models.filter(isEmbedModel);
39
+ const chats = models.filter((model) => !isEmbedModel(model));
40
+ return {
41
+ running: true, host, models,
42
+ embedModel: pick(embeds, EMBED_MODELS, env.PULSE_OLLAMA_EMBED_MODEL),
43
+ chatModel: pick(chats, CHAT_MODELS, env.PULSE_OLLAMA_MODEL),
44
+ };
45
+ } catch (error) {
46
+ return { running: false, host, models: [], embedModel: null, chatModel: null, error: error.message };
47
+ }
48
+ }
49
+
50
+ // An embedder the memory understands: { model, dims, embed(texts, { query }) }.
51
+ // nomic wants a task prefix; the others take the text as it is.
52
+ export function ollamaEmbedder({ host = ollamaHost(), model, fetchImpl = globalThis.fetch, timeoutMs = 60000 } = {}) {
53
+ if (!model) return null;
54
+ const nomic = model.startsWith('nomic');
55
+ return {
56
+ model: `ollama:${model}`,
57
+ dims: null,
58
+ local: true,
59
+ async embed(texts, { query = false } = {}) {
60
+ const input = texts.map((text) => `${nomic ? (query ? 'search_query: ' : 'search_document: ') : ''}${String(text ?? '').slice(0, 2000) || ' '}`);
61
+ const response = await fetchImpl(`${host}/api/embed`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, input }), signal: AbortSignal.timeout(timeoutMs) });
62
+ if (!response.ok) throw new Error(`Ollama embeddings HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
63
+ const payload = await response.json();
64
+ const vectors = payload.embeddings ?? [];
65
+ if (vectors.length !== texts.length) throw new Error(`Ollama returned ${vectors.length} vectors for ${texts.length} texts.`);
66
+ return vectors.map((values) => Float32Array.from(values));
67
+ },
68
+ };
69
+ }
70
+
71
+ // One answer from a local model. `json: true` asks Ollama for a JSON object.
72
+ export async function ollamaGenerate({ host = ollamaHost(), model, prompt, system = null, json = false, fetchImpl = globalThis.fetch, timeoutMs = 180000, temperature = 0.2, numCtx = DEFAULT_NUM_CTX } = {}) {
73
+ const started = Date.now();
74
+ const messages = [...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: prompt }];
75
+ const response = await fetchImpl(`${host}/api/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, messages, stream: false, options: { temperature, num_ctx: numCtx }, ...(json ? { format: 'json' } : {}) }), signal: AbortSignal.timeout(timeoutMs) });
76
+ if (!response.ok) throw new Error(`Ollama HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
77
+ const payload = await response.json();
78
+ const inputTokens = payload.prompt_eval_count ?? 0;
79
+ const outputTokens = payload.eval_count ?? 0;
80
+ return {
81
+ text: String(payload.message?.content ?? '').trim(),
82
+ usage: { inputTokens, outputTokens, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: inputTokens + outputTokens, costUsd: 0, source: 'ollama', local: true, model },
83
+ elapsedMs: Date.now() - started,
84
+ };
85
+ }
86
+
87
+ // The shape the room's invokers have: ({ prompt, timeoutMs }) → { text, usage }.
88
+ export function ollamaInvoker({ host = ollamaHost(), model, fetchImpl = globalThis.fetch } = {}) {
89
+ if (!model) return null;
90
+ return async ({ prompt, timeoutMs = 180000, json = false }) => ollamaGenerate({ host, model, prompt, json, fetchImpl, timeoutMs });
91
+ }
92
+
93
+ // Pulls a model, reporting progress lines as Ollama streams them.
94
+ export async function pullModel({ host = ollamaHost(), model, fetchImpl = globalThis.fetch, onLine = () => {}, timeoutMs = 30 * 60 * 1000 } = {}) {
95
+ const response = await fetchImpl(`${host}/api/pull`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, stream: true }), signal: AbortSignal.timeout(timeoutMs) });
96
+ if (!response.ok) throw new Error(`Ollama pull HTTP ${response.status}: ${(await response.text().catch(() => '')).slice(0, 200)}`);
97
+ const reader = response.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buffer = '';
100
+ let lastStatus = '';
101
+ for (;;) {
102
+ const { value, done } = await reader.read();
103
+ if (done) break;
104
+ buffer += decoder.decode(value, { stream: true });
105
+ let index;
106
+ while ((index = buffer.indexOf('\n')) >= 0) {
107
+ const line = buffer.slice(0, index).trim();
108
+ buffer = buffer.slice(index + 1);
109
+ if (!line) continue;
110
+ let event;
111
+ try { event = JSON.parse(line); } catch { continue; }
112
+ if (event.error) throw new Error(event.error);
113
+ const status = event.total ? `${event.status} · ${Math.round((event.completed ?? 0) / event.total * 100)}%` : event.status;
114
+ if (status && status !== lastStatus) { lastStatus = status; onLine(status); }
115
+ }
116
+ }
117
+ return { model };
118
+ }
@@ -0,0 +1,141 @@
1
+ // The archivist: every so often the cheapest allowed agent reads what nobody
2
+ // has distilled and keeps the few notes worth remembering. Scheduling by count
3
+ // or by quiet, one batch per run, a bench for whoever fails, a report to the
4
+ // room either way. The room hands in what it knows through `deps`.
5
+
6
+ import { pickDistiller, distillPrompt, parseDistillation, OLLAMA_ARCHIVIST } from '../distiller.mjs';
7
+
8
+ export const BENCH_MS = 30 * 60 * 1000;
9
+
10
+ export function distillDefaults(distill = {}, env = process.env) {
11
+ return {
12
+ enabled: distill.enabled ?? env.PULSE_DISTILL !== '0',
13
+ every: Math.max(1, Number(distill.every ?? env.PULSE_DISTILL_EVERY ?? 10)),
14
+ idleMs: Math.max(1000, Number(distill.idleMs ?? env.PULSE_DISTILL_IDLE_MS ?? 10 * 60 * 1000)),
15
+ maxChars: Math.max(500, Number(distill.maxChars ?? env.PULSE_DISTILL_MAX_CHARS ?? 6000)),
16
+ agent: distill.agent ?? env.PULSE_DISTILL_AGENT ?? null,
17
+ model: distill.model ?? env.PULSE_DISTILL_MODEL ?? null,
18
+ allowed: Array.isArray(distill.allowed) && distill.allowed.length ? [...distill.allowed] : null, // who may distil; null = anyone
19
+ };
20
+ }
21
+
22
+ export class Archivist {
23
+ #memory;
24
+ #settings;
25
+ #deps;
26
+ #timer = null;
27
+ #running = null;
28
+ #failures = new Map(); // fromSequence -> failed attempts on that batch
29
+ #bench = new Map(); // agent -> until (ms)
30
+
31
+ // deps: agents(), invokers(), busyAgents(), turnsInFlight(), timeoutFor(id), localTimeoutMs(), projectName(), emit(type, payload), recordUsage(agent, usage), stopped(), failureMessage(error)
32
+ constructor({ memory, settings, deps }) {
33
+ this.#memory = memory;
34
+ this.#settings = settings;
35
+ this.#deps = deps;
36
+ }
37
+ get inflight() { return this.#running; }
38
+ settings() { return { ...this.#settings, allowed: this.#settings.allowed ? [...this.#settings.allowed] : null }; }
39
+
40
+ configure(patch = {}) {
41
+ const s = this.#settings;
42
+ if (typeof patch.enabled === 'boolean') s.enabled = patch.enabled;
43
+ if (Number(patch.every) >= 1) s.every = Math.trunc(Number(patch.every));
44
+ if (Number(patch.idleMs) >= 1000) s.idleMs = Math.trunc(Number(patch.idleMs));
45
+ if (Number(patch.maxChars) >= 500) s.maxChars = Math.trunc(Number(patch.maxChars));
46
+ if ('agent' in patch) s.agent = patch.agent ? String(patch.agent) : null;
47
+ if ('allowed' in patch) s.allowed = Array.isArray(patch.allowed) && patch.allowed.length ? [...patch.allowed] : null;
48
+ this.#bench.clear();
49
+ return this.settings();
50
+ }
51
+
52
+ // After a turn: run now if enough piled up and the room is quiet, else wait for quiet.
53
+ schedule() {
54
+ if (this.#deps.stopped() || !this.#memory || !this.#settings.enabled) return;
55
+ clearTimeout(this.#timer);
56
+ this.#timer = null;
57
+ let pending = 0;
58
+ try { pending = this.#memory.undistilledCount(); } catch { return; }
59
+ if (pending < 2) return;
60
+ const wait = pending >= this.#settings.every ? (this.#deps.turnsInFlight() ? 3000 : 0) : this.#settings.idleMs;
61
+ this.#timer = setTimeout(() => {
62
+ this.#timer = null;
63
+ if (this.#deps.turnsInFlight()) { this.schedule(); return; }
64
+ void this.runNow();
65
+ }, wait);
66
+ this.#timer.unref?.();
67
+ }
68
+
69
+ #candidates() {
70
+ const invokers = this.#deps.invokers();
71
+ // @madre answers questions; the archive is written by the Ollama archivist itself.
72
+ const agents = this.#deps.agents().filter((agent) => agent.adapter !== 'madre-local');
73
+ const everyone = invokers.ollama ? [OLLAMA_ARCHIVIST, ...agents] : agents;
74
+ return this.#settings.allowed ? everyone.filter((agent) => this.#settings.allowed.includes(agent.id)) : everyone;
75
+ }
76
+
77
+ // One batch: the cheapest usable agent reads it, well-formed notes are kept,
78
+ // the batch is marked, tokens are counted. A batch that fails three times is
79
+ // skipped so a poisoned range cannot stall the archive; whoever failed sits out.
80
+ async runNow() {
81
+ if (!this.#memory) return null;
82
+ if (this.#running) return this.#running;
83
+ this.#running = (async () => {
84
+ const batch = this.#memory.undistilled({ maxChars: this.#settings.maxChars });
85
+ if (!batch.entries.length) return null;
86
+ const invokers = this.#deps.invokers();
87
+ const busy = this.#deps.busyAgents();
88
+ const now = Date.now();
89
+ for (const [id, until] of this.#bench) if (until <= now) this.#bench.delete(id);
90
+ const benched = new Set(this.#bench.keys());
91
+ const candidates = this.#candidates();
92
+ let agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers, benched });
93
+ if (!agent && benched.size) { this.#bench.clear(); agent = pickDistiller(candidates, { preferred: this.#settings.agent, busy, invokers }); }
94
+ if (!agent) return null;
95
+ const started = Date.now();
96
+ try {
97
+ const local = agent.adapter === 'ollama';
98
+ const prompt = distillPrompt({ entries: batch.entries, projectName: this.#deps.projectName(), existing: this.#memory.memories({ limit: 12 }), json: local });
99
+ const result = await invokers[agent.adapter]({ executable: agent.path, projectRoot: this.#deps.projectRoot(), prompt, timeoutMs: local ? this.#deps.localTimeoutMs() : this.#deps.timeoutFor(agent.id), model: local ? null : this.#settings.model, json: local, attachments: [], lease: null, scopes: { web: false, imageGen: false }, imageStudio: null });
100
+ const memories = parseDistillation(result?.text, { fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
101
+ const added = this.#memory.addMemories(memories, { agent: agent.id, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence });
102
+ this.#memory.markDistilled(batch.sequences);
103
+ this.#failures.delete(batch.fromSequence);
104
+ // Local tokens cost nothing and count against no provider budget; they are reported, not charged.
105
+ if (!local) await this.#deps.recordUsage(agent.id, result?.usage ?? null);
106
+ const kinds = {};
107
+ for (const memory of memories) kinds[memory.kind] = (kinds[memory.kind] ?? 0) + 1;
108
+ const report = { agent: agent.id, local, model: local ? result?.usage?.model ?? null : this.#settings.model, tokens: result?.usage?.totalTokens ?? null, added, parsed: memories.length, considered: batch.entries.length, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, remaining: batch.remaining, kinds, elapsedMs: Date.now() - started, total: this.#memory.memoryCount() };
109
+ await this.#deps.emit('memory.distilled', report);
110
+ return report;
111
+ } catch (error) {
112
+ const attempts = (this.#failures.get(batch.fromSequence) ?? 0) + 1;
113
+ this.#failures.set(batch.fromSequence, attempts);
114
+ const skipped = attempts >= 3;
115
+ if (skipped) { this.#memory.markDistilled(batch.sequences); this.#failures.delete(batch.fromSequence); }
116
+ this.#bench.set(agent.id, Date.now() + BENCH_MS);
117
+ const next = pickDistiller(candidates, { preferred: this.#settings.agent, busy: new Set(), invokers, benched: new Set(this.#bench.keys()) });
118
+ const report = { agent: agent.id, error: this.#deps.failureMessage(error), attempts, skipped, next: next?.id ?? null, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, considered: batch.entries.length, remaining: batch.remaining };
119
+ await this.#deps.emit('memory.distilled', report);
120
+ return report;
121
+ }
122
+ })().finally(() => { this.#running = null; });
123
+ return this.#running;
124
+ }
125
+
126
+ // Tests: run a distillation that is due by count without waiting for its
127
+ // timer, leave an idle wait alone, and let a run in flight finish.
128
+ async settle() {
129
+ if (this.#timer && !this.#deps.turnsInFlight() && this.#memory && this.#memory.undistilledCount() >= this.#settings.every) {
130
+ clearTimeout(this.#timer);
131
+ this.#timer = null;
132
+ await this.runNow();
133
+ }
134
+ await this.#running;
135
+ }
136
+
137
+ stop() {
138
+ clearTimeout(this.#timer);
139
+ this.#timer = null;
140
+ }
141
+ }
@@ -0,0 +1,15 @@
1
+ // Human uploads, stored under the room (never in the project). Registered
2
+ // here so a message can reference them by id; the ledger lets a restarted
3
+ // room rebuild the registry.
4
+
5
+ import { dirname } from 'node:path';
6
+
7
+ export class Attachments {
8
+ #records = new Map();
9
+ register(record) {
10
+ this.#records.set(record.id, { ...record, dir: dirname(record.path) });
11
+ return this.#records.get(record.id);
12
+ }
13
+ restore(records) { for (const record of records) this.#records.set(record.id, record); }
14
+ get(id) { return this.#records.get(id) ?? null; }
15
+ }
@@ -0,0 +1,83 @@
1
+ // The local token budget: a rolling window per agent (five hours, like the
2
+ // providers' short windows), raw totals for the record, and the seeding of the
3
+ // usage sentinel from the ledger on start.
4
+
5
+ // What a turn costs against MADRE's local budget. Cache reads are close to
6
+ // free at every provider, so they weigh a tenth; Codex counts cached tokens
7
+ // inside its input, so they are taken out before weighing.
8
+ export function budgetTokens(usage = {}) {
9
+ if (usage.local) return 0;
10
+ const n = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
11
+ const cached = n(usage.cachedInputTokens);
12
+ const input = usage.source === 'codex-json' ? Math.max(0, n(usage.inputTokens) - cached) : n(usage.inputTokens);
13
+ const fresh = input + n(usage.cacheCreationInputTokens) + n(usage.outputTokens) + n(usage.reasoningTokens);
14
+ if (!fresh && !cached) return n(usage.totalTokens);
15
+ return Math.round(fresh + cached * 0.1);
16
+ }
17
+
18
+ export class Budget {
19
+ #window = new Map(); // agent -> [{ at, spent }]
20
+ #raw = new Map(); // agent -> raw total tokens ever
21
+ #windowMs;
22
+ softBudget;
23
+
24
+ constructor({ softBudget = 500000, windowMs = Number(process.env.PULSE_BUDGET_WINDOW_MS ?? 5 * 3600 * 1000) } = {}) {
25
+ this.softBudget = softBudget;
26
+ this.#windowMs = windowMs;
27
+ }
28
+ get windowMs() { return this.#windowMs; }
29
+
30
+ // Replays usage from the ledger and tells the sentinel where each ring stood.
31
+ seed(events, sentinel) {
32
+ for (const event of events) {
33
+ if (event.type === 'usage.recorded') {
34
+ this.#raw.set(event.payload.agent, event.payload.roomTotalTokens);
35
+ const at = new Date(event.timestamp ?? 0).getTime() || 0;
36
+ const spent = event.payload.budgetTokens ?? budgetTokens(event.payload.usage ?? {});
37
+ const list = this.#window.get(event.payload.agent) ?? [];
38
+ list.push({ at, spent });
39
+ this.#window.set(event.payload.agent, list);
40
+ }
41
+ if (sentinel && (event.type === 'quota.updated' || event.type === 'limit.warning')) {
42
+ // An old report whose window has since reset must not seed a full ring.
43
+ const passed = event.payload.resetAt && new Date(event.payload.resetAt).getTime() <= Date.now();
44
+ sentinel.seed(passed ? { ...event.payload, usedPercent: 0 } : event.payload);
45
+ }
46
+ }
47
+ if (sentinel && Number.isFinite(this.softBudget) && this.softBudget > 0) {
48
+ for (const agent of this.#window.keys()) {
49
+ sentinel.seed({ agent, usedPercent: (this.windowTotal(agent) / this.softBudget) * 100, source: 'room-soft-budget' });
50
+ }
51
+ }
52
+ }
53
+
54
+ // Budget tokens spent by an agent inside the rolling window, dropping what fell out.
55
+ windowTotal(agent, now = Date.now()) {
56
+ const list = (this.#window.get(agent) ?? []).filter((entry) => now - entry.at < this.#windowMs);
57
+ this.#window.set(agent, list);
58
+ return list.reduce((sum, entry) => sum + entry.spent, 0);
59
+ }
60
+
61
+ // The local window as the UI should see it now: totals recomputed so a ring
62
+ // empties when the window rolls over, even without a new turn.
63
+ view(agents) {
64
+ const now = Date.now();
65
+ return Object.fromEntries(agents.map((agent) => {
66
+ const total = this.windowTotal(agent.id, now);
67
+ const oldest = (this.#window.get(agent.id) ?? [])[0]?.at ?? null;
68
+ return [agent.id, { tokens: total, rawTokens: this.#raw.get(agent.id) ?? 0, windowMs: this.#windowMs, rollsOverAt: oldest ? new Date(oldest + this.#windowMs).toISOString() : null }];
69
+ }));
70
+ }
71
+
72
+ // Charges a turn and returns what the ledger should record and what the ring shows.
73
+ record(agent, usage) {
74
+ const spent = budgetTokens(usage);
75
+ const list = this.#window.get(agent) ?? [];
76
+ list.push({ at: Date.now(), spent });
77
+ this.#window.set(agent, list);
78
+ const total = this.windowTotal(agent);
79
+ const rawTotal = (this.#raw.get(agent) ?? 0) + (usage.totalTokens ?? 0);
80
+ this.#raw.set(agent, rawTotal);
81
+ return { spent, total, rawTotal, windowMs: this.#windowMs, usedPercent: this.softBudget > 0 ? (total / this.softBudget) * 100 : null, projectedPercent: this.softBudget > 0 ? ((total + spent) / this.softBudget) * 100 : null };
82
+ }
83
+ }