@jossuealcala/madre 0.2.2 → 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.
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +32 -1
- package/README.md +39 -6
- package/SECURITY.md +1 -1
- package/bin/madre.mjs +37 -3
- package/package.json +4 -3
- package/public/app.js +247 -7
- package/public/brands.js +18 -0
- package/public/index.html +5 -0
- package/public/styles.css +20 -0
- package/public/troubleshooting.js +24 -2
- package/src/adapters/madre.mjs +85 -0
- package/src/auth-probe.mjs +1 -0
- package/src/capabilities.mjs +1 -0
- package/src/dataset.mjs +85 -0
- package/src/distiller.mjs +16 -6
- package/src/embeddings.mjs +7 -1
- package/src/extensions.mjs +24 -224
- package/src/memory.mjs +3 -2
- package/src/modules/ahp.mjs +64 -0
- package/src/modules/ashcode.mjs +28 -0
- package/src/modules/git-pulse.mjs +26 -0
- package/src/modules/helpers.mjs +30 -0
- package/src/modules/image-studio.mjs +39 -0
- package/src/modules/index.mjs +21 -0
- package/src/modules/ollama.mjs +56 -0
- package/src/modules/ripley.mjs +20 -0
- package/src/modules/sdk.mjs +78 -0
- package/src/ollama.mjs +118 -0
- package/src/room/archivist.mjs +141 -0
- package/src/room/attachments.mjs +15 -0
- package/src/room/budget.mjs +83 -0
- package/src/room/context.mjs +31 -0
- package/src/room/control.mjs +66 -0
- package/src/room/escalation.mjs +41 -0
- package/src/room/ghost.mjs +32 -0
- package/src/room/guard.mjs +52 -0
- package/src/room/prompt.mjs +62 -0
- package/src/room/vectors.mjs +47 -0
- package/src/room.mjs +126 -388
- package/src/server.mjs +190 -53
- package/src/setup.mjs +25 -7
package/src/server.mjs
CHANGED
|
@@ -12,16 +12,34 @@ import { createEmbedder } from './embeddings.mjs';
|
|
|
12
12
|
import { memoryServerFor } from './memory-tools.mjs';
|
|
13
13
|
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
14
14
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
15
|
+
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
|
|
16
|
+
import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
|
|
17
|
+
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
18
|
+
import { exportDataset } from './dataset.mjs';
|
|
15
19
|
|
|
16
20
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
17
21
|
let crashHandlersInstalled = false;
|
|
22
|
+
|
|
23
|
+
// RIPLEY's bridge, injected into every page it renders: reports the page's path
|
|
24
|
+
// and title to the viewer and forwards runtime errors and failed resources.
|
|
25
|
+
// It talks only upward through postMessage; the frame stays an opaque origin.
|
|
26
|
+
export const RIPLEY_BRIDGE = '<script data-ripley>(function(){var p=location.pathname;function send(m){try{parent.postMessage(Object.assign({ripley:1},m),"*")}catch(e){}}send({type:"page",path:p,title:document.title});addEventListener("DOMContentLoaded",function(){send({type:"page",path:p,title:document.title})});addEventListener("error",function(e){if(e.target&&e.target!==window&&!e.message){var u=e.target.src||e.target.href||"";send({type:"error",message:"Failed to load "+(u||e.target.tagName.toLowerCase()),source:u,line:0});return}send({type:"error",message:String(e.message||"error"),source:e.filename||"",line:e.lineno||0})},true);addEventListener("unhandledrejection",function(e){var r=e.reason;send({type:"error",message:"Unhandled promise rejection: "+String(r&&r.message||r),source:"",line:0})})})();</script>';
|
|
27
|
+
export function injectRipleyBridge(body) {
|
|
28
|
+
const text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
|
|
29
|
+
if (text.includes('data-ripley')) return Buffer.from(text);
|
|
30
|
+
const head = text.match(/<head[^>]*>/i);
|
|
31
|
+
if (head) return Buffer.from(text.slice(0, head.index + head[0].length) + RIPLEY_BRIDGE + text.slice(head.index + head[0].length));
|
|
32
|
+
const html = text.match(/<html[^>]*>/i);
|
|
33
|
+
if (html) return Buffer.from(text.slice(0, html.index + html[0].length) + RIPLEY_BRIDGE + text.slice(html.index + html[0].length));
|
|
34
|
+
return Buffer.from(RIPLEY_BRIDGE + text);
|
|
35
|
+
}
|
|
18
36
|
// The author's collector: SEND and AUTO-REPORT are available out of the box; AUTO-REPORT stays off until the human turns it on.
|
|
19
37
|
const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
|
|
20
38
|
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
21
39
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
22
40
|
import { Room } from './room.mjs';
|
|
23
41
|
import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
24
|
-
import { extensionById,
|
|
42
|
+
import { extensionById, runInstaller } from './extensions.mjs';
|
|
25
43
|
import { loginPlanFor, probeAll } from './auth-probe.mjs';
|
|
26
44
|
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
27
45
|
import { discoverModels } from './models.mjs';
|
|
@@ -97,12 +115,14 @@ export async function createPulseServer({
|
|
|
97
115
|
imageKey = resolveGeminiKey,
|
|
98
116
|
// The sentinel's outbound channel; tests hand in a fake.
|
|
99
117
|
reportFetch = globalThis.fetch,
|
|
118
|
+
// Ollama detection; tests hand in a fake probe. PULSE_OLLAMA=0 leaves Ollama alone entirely.
|
|
119
|
+
ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
|
|
100
120
|
}) {
|
|
101
121
|
const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
|
|
102
122
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
103
123
|
applyConfigToEnv(await loadConfig(root));
|
|
104
|
-
// RIPLEY renders HTML in the viewer only while the human has it switched on.
|
|
105
|
-
|
|
124
|
+
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
125
|
+
const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
106
126
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
107
127
|
const agents = providedAgents ?? await detectAgents();
|
|
108
128
|
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
@@ -115,21 +135,68 @@ export async function createPulseServer({
|
|
|
115
135
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
116
136
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
117
137
|
// and the memory as MCP tools for every agent's turn.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
// Ollama, when it is there: local embeddings and a local archivist. Probed at
|
|
139
|
+
// start and again from MODULES; wired live, no restart.
|
|
140
|
+
let ollama = { running: false, host: null, models: [], embedModel: null, chatModel: null };
|
|
141
|
+
const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
|
|
142
|
+
async function wireOllama({ probe: doProbe = true } = {}) {
|
|
143
|
+
if (doProbe) ollama = await ollamaProbe();
|
|
144
|
+
// A model trained on this room (docs/training) is named madre-<project>: @madre uses it when it exists.
|
|
145
|
+
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
146
|
+
const trained = (ollama.models ?? []).map((model) => model.name).find((name) => name.startsWith(`madre-${slug}`)) ?? null;
|
|
147
|
+
ollama = { ...ollama, madreModel: trained };
|
|
148
|
+
const settings = await ollamaSettings();
|
|
149
|
+
const useEmbeddings = ollama.running && settings.enabled && settings.embeddings && ollama.embedModel;
|
|
150
|
+
const useArchivist = ollama.running && settings.enabled && settings.archivist && ollama.chatModel;
|
|
151
|
+
if (memory) {
|
|
152
|
+
const key = process.env.PULSE_EMBED === '0' ? null : await imageKey().catch(() => null);
|
|
153
|
+
const embedder = createEmbedder({ key, env: embedEnvFor(await memoryConfig()), ollama: useEmbeddings ? ollama : null, ollamaEmbedder });
|
|
154
|
+
if (room) room.setEmbedder(embedder); else memory.attachEmbedder(embedder);
|
|
155
|
+
}
|
|
156
|
+
if (room) room.setInvoker('ollama', useArchivist ? ollamaInvoker({ host: ollama.host, model: ollama.chatModel }) : null);
|
|
157
|
+
// @madre: the fifth agent, present whenever Ollama has a chat model and the human left it on.
|
|
158
|
+
const fifth = madreAgent({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }, { enabled: settings.enabled && settings.agent !== false });
|
|
159
|
+
const index = agents.findIndex((agent) => agent.id === MADRE_AGENT_ID);
|
|
160
|
+
let changed = false;
|
|
161
|
+
if (fifth.ready && index < 0) { agents.push(fifth); changed = true; }
|
|
162
|
+
else if (fifth.ready && (agents[index].version !== fifth.version)) { Object.assign(agents[index], fifth); changed = true; }
|
|
163
|
+
else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
|
|
164
|
+
if (room) {
|
|
165
|
+
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
166
|
+
if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
167
|
+
}
|
|
168
|
+
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
121
169
|
}
|
|
170
|
+
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
171
|
+
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, ...((await readConfig(root)).memory ?? {}) });
|
|
172
|
+
const embedEnvFor = (cfg) => {
|
|
173
|
+
const view = { ...process.env };
|
|
174
|
+
if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
|
|
175
|
+
if (cfg.embedProvider === 'off') view.PULSE_EMBED = '0'; else if (cfg.embedProvider && cfg.embedProvider !== 'auto') view.PULSE_EMBED_PROVIDER = cfg.embedProvider;
|
|
176
|
+
}
|
|
177
|
+
return view;
|
|
178
|
+
};
|
|
179
|
+
let room = null;
|
|
180
|
+
await wireOllama();
|
|
122
181
|
const memoryServer = memory ? memoryServerFor({ dbFile: memory.file, projectRoot: canonicalProjectRoot }) : null;
|
|
123
182
|
// MOTHER's channel to the crew: her seal's fingerprint lives in the memory
|
|
124
183
|
// file, so a deleted or edited .pulse/mother.env is noticed.
|
|
125
184
|
const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
|
|
126
185
|
const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
|
|
127
|
-
const
|
|
186
|
+
const startupMemory = await memoryConfig();
|
|
187
|
+
room = new Room({
|
|
128
188
|
store,
|
|
129
189
|
agents,
|
|
130
190
|
projectRoot,
|
|
131
191
|
softTokenBudget,
|
|
132
192
|
contextMaxChars,
|
|
193
|
+
recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
|
|
194
|
+
distill: {
|
|
195
|
+
every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
|
|
196
|
+
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
|
|
197
|
+
agent: process.env.PULSE_DISTILL_AGENT ?? (startupMemory.archivist && startupMemory.archivist !== 'auto' ? startupMemory.archivist : null),
|
|
198
|
+
allowed: Array.isArray(startupMemory.archivists) && startupMemory.archivists.length ? startupMemory.archivists : null,
|
|
199
|
+
},
|
|
133
200
|
memory,
|
|
134
201
|
memoryServer,
|
|
135
202
|
mother: motherOutcome ? mother : null,
|
|
@@ -140,6 +207,7 @@ export async function createPulseServer({
|
|
|
140
207
|
delegation,
|
|
141
208
|
maxPlanSteps,
|
|
142
209
|
});
|
|
210
|
+
await wireOllama({ probe: false });
|
|
143
211
|
const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
|
|
144
212
|
embedKick?.unref?.();
|
|
145
213
|
if (motherOutcome === 'deleted' || motherOutcome === 'altered') setTimeout(() => void room.motherTampered(motherOutcome).catch(() => {}), 500).unref?.();
|
|
@@ -167,6 +235,19 @@ export async function createPulseServer({
|
|
|
167
235
|
geminiIdleMs: Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
|
|
168
236
|
geminiRetries: Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
|
|
169
237
|
capabilities: room.capabilities(),
|
|
238
|
+
memory: memorySettingsView(),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
242
|
+
function memorySettingsView() {
|
|
243
|
+
const distill = room.distillSettings();
|
|
244
|
+
const candidates = [...(distill.ollama ? [{ id: 'ollama', label: `Ollama · ${ollama.chatModel ?? 'local'}`, local: true }] : []), ...agents.filter((agent) => agent.detected && !agent.local).map((agent) => ({ id: agent.id, label: agent.label, local: false }))];
|
|
245
|
+
return {
|
|
246
|
+
enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare,
|
|
247
|
+
candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
|
|
248
|
+
ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
|
|
249
|
+
stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
|
|
250
|
+
envWins: Boolean(process.env.PULSE_DISTILL_EVERY || process.env.PULSE_DISTILL_AGENT || process.env.PULSE_RECALL_SHARE || process.env.PULSE_EMBED_PROVIDER || process.env.PULSE_EMBED),
|
|
170
251
|
};
|
|
171
252
|
}
|
|
172
253
|
async function applySettings(patch) {
|
|
@@ -194,6 +275,21 @@ export async function createPulseServer({
|
|
|
194
275
|
if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
|
|
195
276
|
if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
|
|
196
277
|
}
|
|
278
|
+
if (patch.memory && typeof patch.memory === 'object') {
|
|
279
|
+
const current = await memoryConfig();
|
|
280
|
+
const next = { ...current };
|
|
281
|
+
const m = patch.memory;
|
|
282
|
+
if (Number(m.every) >= 1) next.every = Math.trunc(Number(m.every));
|
|
283
|
+
if (Number(m.idleMinutes) >= 1) next.idleMinutes = Math.trunc(Number(m.idleMinutes));
|
|
284
|
+
if (typeof m.archivist === 'string') next.archivist = m.archivist === 'auto' ? 'auto' : m.archivist;
|
|
285
|
+
if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
|
|
286
|
+
if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
|
|
287
|
+
if (Number.isFinite(Number(m.recallShare))) next.recallShare = Math.min(0.6, Math.max(0, Number(m.recallShare)));
|
|
288
|
+
if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
|
|
289
|
+
config.memory = next;
|
|
290
|
+
room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
|
|
291
|
+
live.memory = true;
|
|
292
|
+
}
|
|
197
293
|
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
198
294
|
const current = (await readConfig(root)).scopes ?? {};
|
|
199
295
|
config.scopes = { ...current };
|
|
@@ -212,6 +308,7 @@ export async function createPulseServer({
|
|
|
212
308
|
}
|
|
213
309
|
room.configure(live);
|
|
214
310
|
await updateConfig(root, config);
|
|
311
|
+
if (live.memory) await wireOllama({ probe: false });
|
|
215
312
|
await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
|
|
216
313
|
return effectiveSettings();
|
|
217
314
|
}
|
|
@@ -355,38 +452,52 @@ export async function createPulseServer({
|
|
|
355
452
|
poller.unref();
|
|
356
453
|
|
|
357
454
|
let installing = null;
|
|
358
|
-
|
|
455
|
+
// The context a module works in: the project, its settings, the room, and what this server offers.
|
|
456
|
+
async function moduleContext() {
|
|
457
|
+
const config = await readConfig(root);
|
|
458
|
+
return {
|
|
459
|
+
projectRoot: canonicalProjectRoot, stateRoot: root, config, env: process.env, agents, room,
|
|
460
|
+
readConfig: () => readConfig(root),
|
|
461
|
+
updateConfig: (patch) => updateConfig(root, patch),
|
|
462
|
+
record: (type, payload) => room.record(type, payload),
|
|
463
|
+
services: {
|
|
464
|
+
imageKey,
|
|
465
|
+
setImageModule,
|
|
466
|
+
ollama: {
|
|
467
|
+
state: () => ollama,
|
|
468
|
+
wire: (options) => wireOllama(options),
|
|
469
|
+
// Pulls stream into the room like a module install; one at a time.
|
|
470
|
+
pull: async (model) => {
|
|
471
|
+
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
472
|
+
installing = 'ollama';
|
|
473
|
+
await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: `ollama pull ${model}`, platforms: [], alreadyInstalled: false });
|
|
474
|
+
void (async () => {
|
|
475
|
+
let pending = [];
|
|
476
|
+
let flushTimer = null;
|
|
477
|
+
const flush = async () => { flushTimer = null; if (!pending.length) return; const batch = pending; pending = []; await room.record('extension.install.output', { id: 'ollama', lines: batch }); };
|
|
478
|
+
try {
|
|
479
|
+
await pullModel({ host: ollama.host, model, fetchImpl: reportFetch, onLine: (line) => { pending.push(line); if (!flushTimer) flushTimer = setTimeout(() => void flush(), 400); } });
|
|
480
|
+
await flush();
|
|
481
|
+
const status = await wireOllama();
|
|
482
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: true, installed: true, detail: `pulled ${model}`, ollama: status });
|
|
483
|
+
} catch (error) {
|
|
484
|
+
await flush();
|
|
485
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: false, installed: false, detail: error.message });
|
|
486
|
+
} finally { installing = null; }
|
|
487
|
+
})();
|
|
488
|
+
return { ok: true };
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function installExtension(id, payload = {}) {
|
|
496
|
+
const { confirm } = payload;
|
|
359
497
|
const extension = extensionById(id);
|
|
360
498
|
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const enabled = !Boolean(current.modules?.ripley?.enabled);
|
|
364
|
-
await updateConfig(root, { modules: { ...current.modules, ripley: { enabled } } });
|
|
365
|
-
ripleyEnabled = enabled;
|
|
366
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled });
|
|
367
|
-
return { status: 200, body: { enabled } };
|
|
368
|
-
}
|
|
369
|
-
if (extension.id === 'ashcode') {
|
|
370
|
-
const current = await readConfig(root);
|
|
371
|
-
const enabled = !Boolean(current.modules?.ashCode?.enabled);
|
|
372
|
-
if (enabled && confirm !== true) return { status: 400, body: { error: 'AshCode is beta and can change meaning. Send { "confirm": true } to enable it.' } };
|
|
373
|
-
await updateConfig(root, { modules: { ...current.modules, ashCode: { enabled } } });
|
|
374
|
-
room.setAshCode(enabled);
|
|
375
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled, beta: true });
|
|
376
|
-
return { status: 200, body: { enabled, beta: true, warning: 'AshCode beta may alter meaning; review the original. Character reduction is not verified token savings.' } };
|
|
377
|
-
}
|
|
378
|
-
if (extension.kind === 'builtin') {
|
|
379
|
-
// Image Studio: a switch in config.json, nothing written to the project.
|
|
380
|
-
const current = await readConfig(root);
|
|
381
|
-
const enabled = !(current.modules?.imageStudio?.enabled);
|
|
382
|
-
const key = await imageKey();
|
|
383
|
-
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.' } };
|
|
384
|
-
const model = typeof arguments[1]?.model === 'string' && arguments[1].model ? arguments[1].model : (current.modules?.imageStudio?.model ?? extension.models[0]);
|
|
385
|
-
await updateConfig(root, { modules: { ...current.modules, imageStudio: { enabled, model } } });
|
|
386
|
-
setImageModule({ enabled, model });
|
|
387
|
-
await room.record('extension.toggled', { id, name: extension.name, enabled, model });
|
|
388
|
-
return { status: 200, body: { enabled, model, capabilities: room.capabilities() } };
|
|
389
|
-
}
|
|
499
|
+
// Built-ins are switches the module itself defines.
|
|
500
|
+
if (extension.toggle) return extension.toggle(await moduleContext(), payload);
|
|
390
501
|
if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
|
|
391
502
|
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
392
503
|
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
@@ -460,7 +571,7 @@ export async function createPulseServer({
|
|
|
460
571
|
projectRoot,
|
|
461
572
|
agents,
|
|
462
573
|
ashCode: { enabled: room.ashCodeEnabled() },
|
|
463
|
-
ripley: { enabled:
|
|
574
|
+
ripley: { enabled: await ripleyOn() },
|
|
464
575
|
softTokenBudget,
|
|
465
576
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
466
577
|
delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
|
|
@@ -523,26 +634,34 @@ export async function createPulseServer({
|
|
|
523
634
|
});
|
|
524
635
|
return response.end(file.body);
|
|
525
636
|
}
|
|
526
|
-
// RIPLEY: the
|
|
527
|
-
// nothing
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (!
|
|
637
|
+
// RIPLEY: the project rendered as a site, inside a frame that runs scripts but
|
|
638
|
+
// reaches nothing: an opaque origin (sandbox without same-origin), no network
|
|
639
|
+
// (connect-src none), no forms, no top navigation, assets only from MADRE's
|
|
640
|
+
// own host. Path-based, so a page's relative links to its CSS, JS and images
|
|
641
|
+
// resolve to the same route.
|
|
642
|
+
const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
643
|
+
if (previewMatch) {
|
|
644
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
|
|
645
|
+
const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
646
|
+
const relative = decodeURIComponent(previewMatch[2]);
|
|
534
647
|
const file = await readServable(which, relative);
|
|
535
648
|
if (file.status !== 200) return sendJson(response, file.status, { error: file.error });
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
const isPage = /\.(html?|xhtml)$/i.test(relative);
|
|
650
|
+
const origin = `${request.headers['x-forwarded-proto'] ?? 'http'}://${request.headers.host ?? '127.0.0.1'}`;
|
|
651
|
+
// Pages carry RIPLEY's bridge: one inline script that tells the viewer which
|
|
652
|
+
// page loaded and forwards the page's own errors. Nothing else is touched.
|
|
653
|
+
const bodyOut = isPage ? injectRipleyBridge(file.body) : file.body;
|
|
654
|
+
const headers = {
|
|
655
|
+
'content-type': isPage ? 'text/html; charset=utf-8' : file.contentType,
|
|
656
|
+
'content-length': Buffer.byteLength(bodyOut),
|
|
539
657
|
'cache-control': 'no-store',
|
|
540
658
|
'x-content-type-options': 'nosniff',
|
|
541
659
|
'referrer-policy': 'no-referrer',
|
|
542
|
-
'content-security-policy': "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'self' data:; form-action 'none'; base-uri 'none'",
|
|
543
660
|
'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
|
|
544
|
-
}
|
|
545
|
-
|
|
661
|
+
};
|
|
662
|
+
if (isPage) headers['content-security-policy'] = `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' ${origin}; style-src 'unsafe-inline' ${origin}; img-src ${origin} data: blob:; font-src ${origin} data:; media-src ${origin} data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'`;
|
|
663
|
+
response.writeHead(200, headers);
|
|
664
|
+
return response.end(bodyOut);
|
|
546
665
|
}
|
|
547
666
|
if (request.method === 'POST' && url.pathname === '/api/attachments') {
|
|
548
667
|
let bytes;
|
|
@@ -580,6 +699,24 @@ export async function createPulseServer({
|
|
|
580
699
|
const outcome = await sentinel.send(sentinelMatch[1]);
|
|
581
700
|
return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
|
|
582
701
|
}
|
|
702
|
+
// Routes a module serves, before MADRE's own: the module answers with { status, body }.
|
|
703
|
+
const moduleRoute = findModuleRoute(request.method, url.pathname);
|
|
704
|
+
if (moduleRoute) {
|
|
705
|
+
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
706
|
+
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
|
|
707
|
+
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
708
|
+
}
|
|
709
|
+
// The dataset behind MADRE AI: exported on demand next to the ledger.
|
|
710
|
+
if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
|
|
711
|
+
const dir = join(roomDir, 'dataset');
|
|
712
|
+
if (request.method === 'GET') {
|
|
713
|
+
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
714
|
+
return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null });
|
|
715
|
+
}
|
|
716
|
+
const result = await exportDataset({ events: await store.readAll(), notes: memory ? memory.memories({ limit: 5000 }) : [], dir, project: basename(canonicalProjectRoot), home: homedir() });
|
|
717
|
+
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
|
|
718
|
+
return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null });
|
|
719
|
+
}
|
|
583
720
|
if (request.method === 'GET' && url.pathname === '/api/mother') {
|
|
584
721
|
return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
|
|
585
722
|
}
|
|
@@ -651,7 +788,7 @@ export async function createPulseServer({
|
|
|
651
788
|
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
652
789
|
}
|
|
653
790
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
654
|
-
return sendJson(response, 200, { installing, extensions: await
|
|
791
|
+
return sendJson(response, 200, { installing, extensions: await describeModules(await moduleContext()) });
|
|
655
792
|
}
|
|
656
793
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
657
794
|
if (installMatch) {
|
package/src/setup.mjs
CHANGED
|
@@ -48,13 +48,21 @@ const glyphFor = (agent, probe) => {
|
|
|
48
48
|
return '◌';
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
// Ollama, when it runs with a chat model, is an agent of its own: @madre. Tests hand in a probe.
|
|
52
|
+
export async function localIntelligence(env = process.env) {
|
|
53
|
+
if (env.PULSE_OLLAMA === '0') return { running: false, chatModel: null, embedModel: null, disabled: true };
|
|
54
|
+
const { probeOllama } = await import('./ollama.mjs');
|
|
55
|
+
return probeOllama({ env });
|
|
56
|
+
}
|
|
57
|
+
export const madreOnline = (ollama) => Boolean(ollama?.running && ollama.chatModel);
|
|
58
|
+
|
|
51
59
|
export function isOnline(agent, probe) {
|
|
52
60
|
return Boolean(agent.ready && probe?.state === 'signed-in');
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/* ---------- report (pure, testable) ---------- */
|
|
56
64
|
|
|
57
|
-
export function renderReport({ agents, probes, projectRoot, config = {}, palette = makePalette({ colors: false }), width = 72 }) {
|
|
65
|
+
export function renderReport({ agents, probes, projectRoot, config = {}, palette = makePalette({ colors: false }), width = 72, ollama = null }) {
|
|
58
66
|
const p = palette;
|
|
59
67
|
const bar = p.phosphor('▌');
|
|
60
68
|
const lines = [];
|
|
@@ -83,11 +91,19 @@ export function renderReport({ agents, probes, projectRoot, config = {}, palette
|
|
|
83
91
|
lines.push(` ${p.dim('│')} ${p.dim(model ? `model ${model}` : 'model: provider default (set one if the default provider has no session)')}`);
|
|
84
92
|
}
|
|
85
93
|
}
|
|
94
|
+
if (ollama && !ollama.disabled) {
|
|
95
|
+
const local = madreOnline(ollama);
|
|
96
|
+
const status = local ? p.phosphor('RUNNING ') : ollama.running ? p.warn('NO MODEL ') : p.dim('NOT FOUND ');
|
|
97
|
+
const detail = local ? `@madre in the room · ${ollama.chatModel}${ollama.embedModel ? ` · embeddings ${ollama.embedModel}` : ''}` : ollama.running ? 'pull a chat model: ollama pull qwen2.5:3b' : 'optional: ollama serve · local memory, embeddings and @madre';
|
|
98
|
+
lines.push(` ${p.dim('│')} ${p.phosphor('◉')} ${p.phosphor('OLLAMA'.padEnd(9))} ${p.dim('local'.padEnd(10))} ${status} ${p.dim(detail)}`);
|
|
99
|
+
}
|
|
86
100
|
lines.push(` ${p.dim('└' + '─'.repeat(Math.max(4, width - 4)))}`);
|
|
87
101
|
const online = agents.filter((agent) => isOnline(agent, probes[agent.id]));
|
|
102
|
+
const local = madreOnline(ollama);
|
|
88
103
|
const verdict = online.length
|
|
89
|
-
? `${online.length} OF ${agents.length} AGENTS ONLINE. ROOM CAN OPEN.`
|
|
90
|
-
:
|
|
104
|
+
? `${online.length} OF ${agents.length} AGENTS ONLINE${local ? ' · @MADRE LOCAL' : ''}. ROOM CAN OPEN.`
|
|
105
|
+
: local ? 'NO CLI ONLINE · @MADRE (LOCAL) IS. THE ROOM CAN OPEN WITH MEMORY ALONE; CONNECT A CLI TO WORK ON FILES.'
|
|
106
|
+
: `NO AGENT ONLINE. CONFIGURE ONE TO OPEN THE ROOM.`;
|
|
91
107
|
lines.push('');
|
|
92
108
|
lines.push(` ${p.phosphor('MOTHER')}${p.dim(' ›')} ${p.bold(verdict)}`);
|
|
93
109
|
return lines.join('\n');
|
|
@@ -119,12 +135,14 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
119
135
|
let agents = await detectAgents();
|
|
120
136
|
let probes = await probeAll(agents);
|
|
121
137
|
let config = await loadConfig(stateRoot);
|
|
122
|
-
|
|
138
|
+
const ollama = await localIntelligence();
|
|
139
|
+
log(renderReport({ agents, probes, projectRoot, config, palette: p, ollama }));
|
|
140
|
+
const anyoneOnline = () => agents.some((agent) => isOnline(agent, probes[agent.id])) || madreOnline(ollama);
|
|
123
141
|
|
|
124
142
|
if (!interactive) {
|
|
125
143
|
// Without a terminal there is nobody to walk through; report and hand
|
|
126
144
|
// back. `online` lets the caller decide (exit codes, CI checks).
|
|
127
|
-
return { action: 'report', online:
|
|
145
|
+
return { action: 'report', online: anyoneOnline(), agents, probes, ollama };
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -136,7 +154,7 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
136
154
|
const answer = (await ask('select')).trim().toLowerCase();
|
|
137
155
|
if (answer === 'q') return { action: 'quit', agents, probes };
|
|
138
156
|
if (answer === 's') {
|
|
139
|
-
if (!
|
|
157
|
+
if (!anyoneOnline()) {
|
|
140
158
|
log(` ${p.warn('MOTHER ›')} no agent online; the room would open empty. Configure one first or press s again to open anyway.`);
|
|
141
159
|
const again = (await ask('start anyway? [y/N]')).trim().toLowerCase();
|
|
142
160
|
if (again !== 'y') continue;
|
|
@@ -147,7 +165,7 @@ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isT
|
|
|
147
165
|
agents = await detectAgents();
|
|
148
166
|
probes = await probeAll(agents);
|
|
149
167
|
config = await loadConfig(stateRoot);
|
|
150
|
-
log(renderReport({ agents, probes, projectRoot, config, palette: p }));
|
|
168
|
+
log(renderReport({ agents, probes, projectRoot, config, palette: p, ollama }));
|
|
151
169
|
continue;
|
|
152
170
|
}
|
|
153
171
|
if (answer === 'm') {
|