@jossuealcala/madre 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/CONTRIBUTING.md +31 -2
  3. package/README.md +145 -187
  4. package/SECURITY.md +1 -1
  5. package/bin/madre.mjs +35 -2
  6. package/docs/INTERNALS.md +75 -0
  7. package/docs/training/Modelfile +5 -0
  8. package/docs/training/README.md +47 -0
  9. package/docs/training/train.sh +17 -0
  10. package/package.json +5 -3
  11. package/public/app.js +353 -5
  12. package/public/brands.js +18 -0
  13. package/public/index.html +2 -0
  14. package/public/styles.css +49 -11
  15. package/public/troubleshooting.js +31 -0
  16. package/src/adapters/madre.mjs +195 -0
  17. package/src/auth-probe.mjs +1 -0
  18. package/src/capabilities.mjs +1 -0
  19. package/src/conversation-context.mjs +4 -2
  20. package/src/dataset.mjs +127 -0
  21. package/src/distiller.mjs +16 -6
  22. package/src/embeddings.mjs +7 -1
  23. package/src/event-store.mjs +29 -1
  24. package/src/extensions.mjs +24 -224
  25. package/src/memory.mjs +64 -4
  26. package/src/modules/ahp.mjs +64 -0
  27. package/src/modules/ashcode.mjs +28 -0
  28. package/src/modules/git-pulse.mjs +26 -0
  29. package/src/modules/helpers.mjs +30 -0
  30. package/src/modules/image-studio.mjs +39 -0
  31. package/src/modules/index.mjs +21 -0
  32. package/src/modules/ollama.mjs +56 -0
  33. package/src/modules/ripley.mjs +20 -0
  34. package/src/modules/sdk.mjs +78 -0
  35. package/src/ollama.mjs +118 -0
  36. package/src/privacy.mjs +109 -0
  37. package/src/room/archivist.mjs +141 -0
  38. package/src/room/attachments.mjs +15 -0
  39. package/src/room/budget.mjs +83 -0
  40. package/src/room/context.mjs +31 -0
  41. package/src/room/control.mjs +66 -0
  42. package/src/room/escalation.mjs +41 -0
  43. package/src/room/ghost.mjs +32 -0
  44. package/src/room/guard.mjs +52 -0
  45. package/src/room/prompt.mjs +66 -0
  46. package/src/room/vectors.mjs +47 -0
  47. package/src/room.mjs +190 -392
  48. package/src/server.mjs +229 -41
  49. package/src/setup.mjs +25 -7
  50. package/src/updates.mjs +79 -0
@@ -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,109 @@
1
+ // Privacy: terms that must never travel through a room. ERROR-001 in a real room
2
+ // showed the channel: an agent's own configuration (organisation instructions,
3
+ // the account it runs under) leaks into its reply, the reply enters the shared
4
+ // ledger, the archivist distils it into a durable memory, the memory becomes a
5
+ // training example. Four hops, no control at any of them.
6
+ //
7
+ // This module is the control. The human names the terms in MU/TH/UR → PRIVACY
8
+ // (or PULSE_PRIVATE_TERMS); MADRE replaces them with a marker at every hop:
9
+ // agent replies before they are recorded, notes before they are kept, entries
10
+ // before they are indexed, dataset pairs before they are written. A purge does
11
+ // the same to what the room already holds. The terms themselves stay in
12
+ // ~/.pulse/config.json and are never written to the ledger or to any prompt.
13
+
14
+ export const PRIVACY_MARKER = '[ENTIDAD-ORG]';
15
+ export const MAX_TERMS = 64;
16
+
17
+ const ACCENTS = { a: '[aáàäâ]', e: '[eéèëê]', i: '[iíìïî]', o: '[oóòöô]', u: '[uúùüû]', n: '[nñ]', c: '[cç]' };
18
+
19
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+
21
+ // One term as a pattern: case- and accent-insensitive, tolerant of the spacing
22
+ // people use between the words of a name ("Come Verde", "ComeVerde", "come-verde").
23
+ function termPattern(term) {
24
+ const words = term.trim().split(/\s+/).map((word) => [...word.toLowerCase()].map((char) => ACCENTS[char] ?? escape(char)).join(''));
25
+ return words.join('[\\s._-]*');
26
+ }
27
+
28
+ export function normalizeTerms(list) {
29
+ const seen = new Set();
30
+ const terms = [];
31
+ for (const raw of Array.isArray(list) ? list : String(list ?? '').split(/[\n,;]+/)) {
32
+ const term = String(raw ?? '').replace(/\s+/g, ' ').trim();
33
+ if (term.length < 3 || term.length > 80) continue;
34
+ const key = term.toLowerCase();
35
+ if (seen.has(key)) continue;
36
+ seen.add(key);
37
+ terms.push(term);
38
+ if (terms.length >= MAX_TERMS) break;
39
+ }
40
+ // Longest first, so "Come Verde Holdings" is replaced whole before "Come Verde" gets to it.
41
+ return terms.sort((a, b) => b.length - a.length);
42
+ }
43
+
44
+ export function privacyPattern(terms) {
45
+ const list = normalizeTerms(terms);
46
+ if (!list.length) return null;
47
+ return new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(termPattern).join('|')})(?![\\p{L}\\p{N}])`, 'giu');
48
+ }
49
+
50
+ // From config.json (`privacy.terms`, `privacy.marker`) and the environment.
51
+ export function privacySettings(config = {}, env = process.env) {
52
+ const fromEnv = env.PULSE_PRIVATE_TERMS ? String(env.PULSE_PRIVATE_TERMS).split(/[,;\n]+/) : [];
53
+ const fromConfig = Array.isArray(config.privacy?.terms) ? config.privacy.terms : [];
54
+ const marker = String(config.privacy?.marker ?? env.PULSE_PRIVATE_MARKER ?? PRIVACY_MARKER).trim() || PRIVACY_MARKER;
55
+ return { terms: normalizeTerms([...fromEnv, ...fromConfig]), marker, envWins: fromEnv.length > 0 };
56
+ }
57
+
58
+ export class Privacy {
59
+ #terms = [];
60
+ #pattern = null;
61
+ #marker = PRIVACY_MARKER;
62
+
63
+ constructor({ terms = [], marker = PRIVACY_MARKER } = {}) {
64
+ this.set(terms, marker);
65
+ }
66
+
67
+ set(terms, marker = this.#marker) {
68
+ this.#terms = normalizeTerms(terms);
69
+ this.#pattern = privacyPattern(this.#terms);
70
+ this.#marker = String(marker ?? PRIVACY_MARKER).trim() || PRIVACY_MARKER;
71
+ return this;
72
+ }
73
+
74
+ get terms() { return [...this.#terms]; }
75
+ get marker() { return this.#marker; }
76
+ get enabled() { return this.#pattern !== null; }
77
+
78
+ // How many private terms a text carries.
79
+ hits(text) {
80
+ if (!this.#pattern || typeof text !== 'string' || !text) return 0;
81
+ return (text.match(this.#pattern) ?? []).length;
82
+ }
83
+
84
+ // The text with every private term replaced by the marker, and the count.
85
+ redact(text) {
86
+ if (!this.#pattern || typeof text !== 'string' || !text) return { text, hits: 0 };
87
+ let hits = 0;
88
+ const out = text.replace(this.#pattern, () => { hits += 1; return this.#marker; });
89
+ return { text: out, hits };
90
+ }
91
+
92
+ // Every string inside a value (an event payload, a plan), replaced in place of
93
+ // a copy. Ids and numbers are untouched; only prose can carry a name.
94
+ redactDeep(value) {
95
+ if (!this.#pattern) return { value, hits: 0 };
96
+ let hits = 0;
97
+ const walk = (node) => {
98
+ if (typeof node === 'string') { const r = this.redact(node); hits += r.hits; return r.text; }
99
+ if (Array.isArray(node)) return node.map(walk);
100
+ if (node && typeof node === 'object') {
101
+ const out = {};
102
+ for (const [key, item] of Object.entries(node)) out[key] = walk(item);
103
+ return out;
104
+ }
105
+ return node;
106
+ };
107
+ return { value: walk(value), hits };
108
+ }
109
+ }
@@ -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
+ }