@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.
package/src/server.mjs CHANGED
@@ -12,6 +12,10 @@ 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;
@@ -35,7 +39,7 @@ import { QuotaMonitor } from './quota-monitor.mjs';
35
39
  import { defaultQuotaSources } from './quota-sources.mjs';
36
40
  import { Room } from './room.mjs';
37
41
  import { applyConfigToEnv, loadConfig } from './config.mjs';
38
- import { extensionById, listExtensions, runInstaller } from './extensions.mjs';
42
+ import { extensionById, runInstaller } from './extensions.mjs';
39
43
  import { loginPlanFor, probeAll } from './auth-probe.mjs';
40
44
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
41
45
  import { discoverModels } from './models.mjs';
@@ -111,12 +115,14 @@ export async function createPulseServer({
111
115
  imageKey = resolveGeminiKey,
112
116
  // The sentinel's outbound channel; tests hand in a fake.
113
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,
114
120
  }) {
115
121
  const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
116
122
  // ~/.pulse/config.json fills in whatever the environment did not set.
117
123
  applyConfigToEnv(await loadConfig(root));
118
- // RIPLEY renders HTML in the viewer only while the human has it switched on.
119
- let ripleyEnabled = Boolean((await readConfig(root)).modules?.ripley?.enabled);
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);
120
126
  agentTimeouts ??= agentTimeoutsFromEnv();
121
127
  const agents = providedAgents ?? await detectAgents();
122
128
  const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
@@ -129,21 +135,68 @@ export async function createPulseServer({
129
135
  .catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
130
136
  // Meaning-aware recall through the user's own Gemini key, when there is one;
131
137
  // and the memory as MCP tools for every agent's turn.
132
- if (memory) {
133
- const key = process.env.PULSE_EMBED === '0' ? null : await imageKey().catch(() => null);
134
- memory.attachEmbedder(createEmbedder({ key }));
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 };
135
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();
136
181
  const memoryServer = memory ? memoryServerFor({ dbFile: memory.file, projectRoot: canonicalProjectRoot }) : null;
137
182
  // MOTHER's channel to the crew: her seal's fingerprint lives in the memory
138
183
  // file, so a deleted or edited .pulse/mother.env is noticed.
139
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 });
140
185
  const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
141
- const room = new Room({
186
+ const startupMemory = await memoryConfig();
187
+ room = new Room({
142
188
  store,
143
189
  agents,
144
190
  projectRoot,
145
191
  softTokenBudget,
146
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
+ },
147
200
  memory,
148
201
  memoryServer,
149
202
  mother: motherOutcome ? mother : null,
@@ -154,6 +207,7 @@ export async function createPulseServer({
154
207
  delegation,
155
208
  maxPlanSteps,
156
209
  });
210
+ await wireOllama({ probe: false });
157
211
  const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
158
212
  embedKick?.unref?.();
159
213
  if (motherOutcome === 'deleted' || motherOutcome === 'altered') setTimeout(() => void room.motherTampered(motherOutcome).catch(() => {}), 500).unref?.();
@@ -181,6 +235,19 @@ export async function createPulseServer({
181
235
  geminiIdleMs: Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
182
236
  geminiRetries: Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
183
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),
184
251
  };
185
252
  }
186
253
  async function applySettings(patch) {
@@ -208,6 +275,21 @@ export async function createPulseServer({
208
275
  if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
209
276
  if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
210
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
+ }
211
293
  if (patch.scopes && typeof patch.scopes === 'object') {
212
294
  const current = (await readConfig(root)).scopes ?? {};
213
295
  config.scopes = { ...current };
@@ -226,6 +308,7 @@ export async function createPulseServer({
226
308
  }
227
309
  room.configure(live);
228
310
  await updateConfig(root, config);
311
+ if (live.memory) await wireOllama({ probe: false });
229
312
  await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
230
313
  return effectiveSettings();
231
314
  }
@@ -369,38 +452,52 @@ export async function createPulseServer({
369
452
  poller.unref();
370
453
 
371
454
  let installing = null;
372
- async function installExtension(id, { confirm } = {}) {
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;
373
497
  const extension = extensionById(id);
374
498
  if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
375
- if (extension.id === 'ripley') {
376
- const current = await readConfig(root);
377
- const enabled = !Boolean(current.modules?.ripley?.enabled);
378
- await updateConfig(root, { modules: { ...current.modules, ripley: { enabled } } });
379
- ripleyEnabled = enabled;
380
- await room.record('extension.toggled', { id, name: extension.name, enabled });
381
- return { status: 200, body: { enabled } };
382
- }
383
- if (extension.id === 'ashcode') {
384
- const current = await readConfig(root);
385
- const enabled = !Boolean(current.modules?.ashCode?.enabled);
386
- if (enabled && confirm !== true) return { status: 400, body: { error: 'AshCode is beta and can change meaning. Send { "confirm": true } to enable it.' } };
387
- await updateConfig(root, { modules: { ...current.modules, ashCode: { enabled } } });
388
- room.setAshCode(enabled);
389
- await room.record('extension.toggled', { id, name: extension.name, enabled, beta: true });
390
- return { status: 200, body: { enabled, beta: true, warning: 'AshCode beta may alter meaning; review the original. Character reduction is not verified token savings.' } };
391
- }
392
- if (extension.kind === 'builtin') {
393
- // Image Studio: a switch in config.json, nothing written to the project.
394
- const current = await readConfig(root);
395
- const enabled = !(current.modules?.imageStudio?.enabled);
396
- const key = await imageKey();
397
- 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.' } };
398
- const model = typeof arguments[1]?.model === 'string' && arguments[1].model ? arguments[1].model : (current.modules?.imageStudio?.model ?? extension.models[0]);
399
- await updateConfig(root, { modules: { ...current.modules, imageStudio: { enabled, model } } });
400
- setImageModule({ enabled, model });
401
- await room.record('extension.toggled', { id, name: extension.name, enabled, model });
402
- return { status: 200, body: { enabled, model, capabilities: room.capabilities() } };
403
- }
499
+ // Built-ins are switches the module itself defines.
500
+ if (extension.toggle) return extension.toggle(await moduleContext(), payload);
404
501
  if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
405
502
  if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
406
503
  const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
@@ -474,7 +571,7 @@ export async function createPulseServer({
474
571
  projectRoot,
475
572
  agents,
476
573
  ashCode: { enabled: room.ashCodeEnabled() },
477
- ripley: { enabled: ripleyEnabled },
574
+ ripley: { enabled: await ripleyOn() },
478
575
  softTokenBudget,
479
576
  timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
480
577
  delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
@@ -544,7 +641,7 @@ export async function createPulseServer({
544
641
  // resolve to the same route.
545
642
  const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
546
643
  if (previewMatch) {
547
- if (!ripleyEnabled) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
644
+ if (!(await ripleyOn())) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
548
645
  const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
549
646
  const relative = decodeURIComponent(previewMatch[2]);
550
647
  const file = await readServable(which, relative);
@@ -602,6 +699,24 @@ export async function createPulseServer({
602
699
  const outcome = await sentinel.send(sentinelMatch[1]);
603
700
  return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
604
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
+ }
605
720
  if (request.method === 'GET' && url.pathname === '/api/mother') {
606
721
  return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
607
722
  }
@@ -673,7 +788,7 @@ export async function createPulseServer({
673
788
  return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
674
789
  }
675
790
  if (request.method === 'GET' && url.pathname === '/api/extensions') {
676
- return sendJson(response, 200, { installing, extensions: await listExtensions({ projectRoot: canonicalProjectRoot, agents, config: await readConfig(root), imageKey }) });
791
+ return sendJson(response, 200, { installing, extensions: await describeModules(await moduleContext()) });
677
792
  }
678
793
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
679
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
- : `NO AGENT ONLINE. CONFIGURE ONE TO OPEN THE ROOM.`;
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
- log(renderReport({ agents, probes, projectRoot, config, palette: p }));
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: agents.some((agent) => isOnline(agent, probes[agent.id])), agents, probes };
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 (!agents.some((agent) => isOnline(agent, probes[agent.id]))) {
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') {