@jossuealcala/madre 1.0.2 → 1.0.3

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 (76) hide show
  1. package/CHANGELOG.en.md +69 -1
  2. package/CHANGELOG.md +69 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1626 -151
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +393 -25
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +98 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +307 -30
  33. package/public/styles.css +838 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +4 -2
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server.mjs +617 -48
  76. package/src/setup.mjs +1 -1
@@ -27,6 +27,8 @@ import { BISHOP_SIGN_DAYS, CHANNEL_HEADER, MAX_ORDER_MODE, NO_SUCH_LINK, activeC
27
27
  import { t } from '../i18n.mjs';
28
28
  import { askRoom, ensureSalasProject, isSalasRoom, knownRooms, projectRoomId, roomComesUp, salasRoomId, startRoom } from '../salas.mjs';
29
29
  import { defineModule } from './sdk.mjs';
30
+ import { ollamaGenerate, ollamaHost } from '../ollama.mjs';
31
+ import { LOCAL_ORDER_MODE, buildExam, examHolds, examPairs, keepExam, readExam, runExam } from './bishop-exam.mjs';
30
32
  import {
31
33
  BISHOP_MODES, CONSULT_HEADER, MAX_ORDERS, ORDER_CHARS, ORDER_HEADER, PLAN_FENCE, RESULT_CHARS, changePlans, clean, closing, consultHeader,
32
34
  newPlanId, orderHeader, parsePlan, planById, publicLink, publicOrder, resolveLinks, resolveOrder, resolveOrders,
@@ -57,6 +59,18 @@ const SIGNER_WORDS = { bishop: 'The Coordinator signs connections to read', you:
57
59
  const signerOf = (ctx) => (ctx.config?.modules?.bishop?.links === 'you' ? 'you' : 'bishop');
58
60
  // S-030: @madre as BISHOP only answers. It is still the human's choice of who BISHOP is.
59
61
  export const onlyAnswers = (agent) => agent === 'madre';
62
+ // D-S18: unless the local model it answers with passed the exam (src/modules/bishop-exam.mjs), and
63
+ // it is still that very model. Then it gives orders up to #1 and proposes no connections.
64
+ const localModel = (ctx) => {
65
+ const probe = ctx.services?.ollama?.state?.() ?? null;
66
+ const name = probe?.madreModel ?? probe?.chatModel ?? null;
67
+ return name ? { name, digest: (probe.models ?? []).find((model) => model.name === name)?.digest ?? null } : null;
68
+ };
69
+ async function answersOnlyNow(ctx, agent) {
70
+ if (!onlyAnswers(agent)) return false;
71
+ return !examHolds(await readExam(ctx.stateRoot).catch(() => null), localModel(ctx));
72
+ }
73
+ let examining = null;
60
74
  const reply = (status, body) => ({ status, body });
61
75
 
62
76
  /* ---------- the channel ---------- */
@@ -120,6 +134,7 @@ async function sendOrder(ctx, planId, step, { auto = false } = {}) {
120
134
  if (!contract) return back(404, t('No signed connection lets the Coordinator give orders to that project.'));
121
135
  const ceiling = Math.min(contract.orders.maxMode, MAX_ORDER_MODE);
122
136
  if (order.mode > ceiling) return back(403, t('That connection allows orders up to #{n}.', { n: ceiling }));
137
+ if (onlyAnswers(plan.agent) && (order.mode > LOCAL_ORDER_MODE || await answersOnlyNow(ctx, plan.agent))) return back(403, t('The Coordinator\'s local model gives orders up to #1, and only after passing its exam.'));
123
138
  const target = (await knownRooms(ctx.stateRoot)).find((room) => room.id === order.room);
124
139
  if (!target?.open) return back(409, t('That project is asleep. Wake it from Home to send it orders.'));
125
140
  const key = await channelKey(ctx.stateRoot).catch(() => null);
@@ -159,7 +174,7 @@ async function deliverProposal(ctx, planId, step, { auto = false } = {}) {
159
174
  // proposal that cannot leave on its own is held: it says why and waits for the human's press.
160
175
  async function advance(ctx, planId) {
161
176
  const plan = await planById(ctx.stateRoot, planId);
162
- if (!plan || plan.halted || onlyAnswers(plan.agent)) return;
177
+ if (!plan || plan.halted || await answersOnlyNow(ctx, plan.agent)) return;
163
178
  const hold = async (order, why) => {
164
179
  await changePlans(ctx.stateRoot, (data) => { const one = data.plans[plan.id]?.orders.find((item) => item.step === order.step); if (one) one.held = why; });
165
180
  await ctx.services.recordIn(plan.chat, 'bishop.order.held', { plan: plan.id, step: order.step, why });
@@ -170,7 +185,7 @@ async function advance(ctx, planId) {
170
185
  if (delivered.status !== 202) await hold(order, delivered.body.error);
171
186
  }
172
187
  if (plan.mode === 'manual') return;
173
- const ceiling = plan.mode === 'auto' ? 2 : 1;
188
+ const ceiling = Math.min(plan.mode === 'auto' ? 2 : 1, onlyAnswers(plan.agent) ? LOCAL_ORDER_MODE : 2);
174
189
  const busy = new Set(plan.orders.filter((order) => ['sending', 'sent'].includes(order.status)).map((order) => order.room));
175
190
  for (const order of plan.orders) {
176
191
  if (order.status !== 'ready' || order.held || order.mode > ceiling || busy.has(order.room)) continue;
@@ -243,7 +258,8 @@ const base = defineModule({
243
258
  // which the card's translation (exact sentences only) could never match.
244
259
  const detail = !ctx.settings.enabled ? t('off')
245
260
  : !agent ? t('No ready agent. Choose one when it is available.')
246
- : onlyAnswers(agent) ? t('on · @{agent} · only answers', { agent })
261
+ : await answersOnlyNow(ctx, agent) ? t('on · @{agent} · only answers', { agent })
262
+ : onlyAnswers(agent) ? t('on · @{agent} · passed its exam: orders up to #1', { agent })
247
263
  : t('on · @{agent} · {orders} · {links}', { agent, orders: t(ORDER_WORDS[modeOf(ctx)]), links: t(SIGNER_WORDS[signerOf(ctx)]) });
248
264
  return { status: { installed: Boolean(ctx.settings.enabled && agent), detail } };
249
265
  },
@@ -293,8 +309,13 @@ const base = defineModule({
293
309
  }
294
310
  if (event?.type !== 'message.created') return;
295
311
  if (said.role !== 'assistant' || said.status !== 'completed' || said.target !== 'you' || said.planId || said.synthetic || said.sender !== chosen(ctx)) return;
296
- if (relayed.has(said.parentMessageId) || onlyAnswers(said.sender)) return;
312
+ if (relayed.has(said.parentMessageId) || await answersOnlyNow(ctx, said.sender)) return;
297
313
  const parsed = parsePlan(said.text);
314
+ // D-S18: a local model that passed its exam gives orders up to #1 and never proposes connections.
315
+ if (parsed && onlyAnswers(said.sender)) {
316
+ for (const link of parsed.links) parsed.ignored.push({ line: `${link.from} → ${link.to}`, why: 'the local model proposes no connections' });
317
+ parsed.links = [];
318
+ }
298
319
  if (!parsed) return;
299
320
  if (!parsed.orders.length && !parsed.links.length) {
300
321
  await ctx.record('bishop.plan.ignored', { responseMessageId: said.messageId, agent: said.sender, reasons: parsed.ignored.map((item) => item.why) });
@@ -303,7 +324,8 @@ const base = defineModule({
303
324
  const salas = salasRoomId(ctx.stateRoot);
304
325
  const rooms = await knownRooms(ctx.stateRoot);
305
326
  const contracts = activeContracts(ctx.config, { salas });
306
- const orders = resolveOrders({ orders: parsed.orders, rooms, contracts, salas, agents: ctx.agents });
327
+ const orders = resolveOrders({ orders: parsed.orders, rooms, contracts, salas, agents: ctx.agents })
328
+ .map((order) => (onlyAnswers(said.sender) && order.mode > LOCAL_ORDER_MODE && ['ready', 'proposal'].includes(order.status) ? { ...order, status: 'blocked', why: 'The Coordinator\'s local model gives orders up to #1.' } : order));
307
329
  const links = resolveLinks({ links: parsed.links, rooms, contracts, salas });
308
330
  const plan = { id: newPlanId(), chat: ctx.chat ?? null, responseMessageId: said.messageId, agent: said.sender, reason: parsed.reason, mode: modeOf(ctx), createdAt: new Date().toISOString(), closed: false, halted: null, orders, links, asked: parsed.orders };
309
331
  await changePlans(ctx.stateRoot, (data) => { data.plans[plan.id] = plan; });
@@ -318,6 +340,31 @@ const base = defineModule({
318
340
  await advance(ctx, plan.id);
319
341
  },
320
342
  routes: [
343
+ // D-S18 · the exam of the local model, from the page of Home: started here, run in the
344
+ // background (a local model is slow), its result kept beside the room and recorded.
345
+ { method: 'GET', path: '/api/x/bishop/exam', handler: async (ctx, { viaPhone }) => pageDoor(ctx, viaPhone) ?? reply(200, { exam: await readExam(ctx.stateRoot), running: Boolean(examining), model: localModel(ctx), holds: examHolds(await readExam(ctx.stateRoot), localModel(ctx)) }) },
346
+ { method: 'POST', path: '/api/x/bishop/exam', handler: async (ctx, { viaPhone }) => {
347
+ const refused = pageDoor(ctx, viaPhone);
348
+ if (refused) return refused;
349
+ if (examining) return reply(409, { error: t('The exam is already running.') });
350
+ const model = localModel(ctx);
351
+ if (!model) return reply(412, { error: t('Local AI has no chat model to examine. Download one in Local AI, or train the Coordinator\'s.') });
352
+ const salas = salasRoomId(ctx.stateRoot);
353
+ const rooms = await knownRooms(ctx.stateRoot);
354
+ const contracts = activeContracts(ctx.config, { salas });
355
+ const ordering = contracts.map((contract) => receivesOrders(contract, salas)).filter(Boolean).map((id) => rooms.find((room) => room.id === id)).filter(Boolean);
356
+ const cases = buildExam({ ...(await examPairs(join(ctx.stateRoot, 'rooms', salas, 'dataset'))), rooms, ordering });
357
+ const where = { rooms, contracts, salas, agents: ctx.agents };
358
+ const crew = readyAgents(ctx).map((one) => one.id);
359
+ const system = (kind) => formatBishop({ rooms, contracts, links: readsFrom(contracts, salas), local: true, salas, crew, answersOnly: kind === 'answer', localOrders: kind === 'order', signer: 'you' });
360
+ const ask = async (prompt, question) => (await ollamaGenerate({ host: ollamaHost(), model: model.name, system: prompt, prompt: question, timeoutMs: 120000, temperature: 0.1 })).text;
361
+ await ctx.record('bishop.exam.started', { model: model.name, known: cases.known.length, unknown: cases.unknown.length, orders: cases.orders.length });
362
+ examining = runExam({ model, cases, ask, system, where })
363
+ .then(async (result) => { await keepExam(ctx.stateRoot, result); await ctx.record('bishop.exam.finished', { model: result.model, passed: result.passed, scores: result.scores, why: result.why }); })
364
+ .catch(async (error) => { await ctx.record('bishop.exam.failed', { model: model.name, error: String(error.message).slice(0, 300) }); })
365
+ .finally(() => { examining = null; });
366
+ return reply(202, { started: true, model: model.name, cases: { known: cases.known.length, unknown: cases.unknown.length, orders: cases.orders.length } });
367
+ } },
321
368
  // The human pressed «Send» on one order of a plan, in the page of Salas.
322
369
  { method: 'POST', path: '/api/x/bishop/send', handler: async (ctx, { payload, viaPhone }) => pageDoor(ctx, viaPhone) ?? sendOrder(ctx, payload?.plan, payload?.step) },
323
370
  // «Take it to its room»: a #3/#4 proposal, for the human to arm there.
@@ -524,13 +571,13 @@ const bishop = Object.freeze({
524
571
  choices: agents.map((one) => ({ value: one.id, label: onlyAnswers(one.id) ? '@madre · only answers' : `@${one.id}` })),
525
572
  note: 'The ready agent the Coordinator speaks through. With @madre, the Coordinator only answers: it proposes neither orders nor connections.',
526
573
  }] : [];
527
- if (agent && !onlyAnswers(agent)) {
574
+ if (agent && !(await answersOnlyNow(ctx, agent))) {
528
575
  controls.push({
529
576
  key: 'mode', label: 'ORDERS', type: 'select', options: BISHOP_MODES, value: modeOf(ctx),
530
577
  choices: BISHOP_MODES.map((value) => ({ value, label: ORDER_CHOICES[value] })),
531
578
  note: 'Always within what the connection allows. #3 and #4 always wait for your confirmation in their project; an error or STOP ALL halts the orders that go out on their own.',
532
579
  });
533
- controls.push({
580
+ if (!onlyAnswers(agent)) controls.push({
534
581
  key: 'links', label: 'CONNECTIONS TO READ', type: 'select', options: LINK_SIGNERS, value: signerOf(ctx),
535
582
  choices: LINK_SIGNERS.map((value) => ({ value, label: SIGNER_CHOICES[value] })),
536
583
  note: 'A connection with orders is always signed by you. A connection the Coordinator signed is revoked with one press, in Home.',
@@ -562,7 +609,7 @@ const bishop = Object.freeze({
562
609
  export default bishop;
563
610
 
564
611
  // Pure formatting makes the 1500-character ceiling inspectable. Names are data, never orders.
565
- export function formatBishop({ rooms, glances = new Map(), contracts = [], links = [], notes = [], unread = [], local = false, salas = null, crew = [], answersOnly = false, signer = 'bishop' }) {
612
+ export function formatBishop({ rooms, glances = new Map(), contracts = [], links = [], notes = [], unread = [], local = false, salas = null, crew = [], answersOnly = false, signer = 'bishop', localOrders = false }) {
566
613
  const names = new Map(rooms.map((room) => [room.id, clean(room.name, 40)]));
567
614
  const lead = 'BISHOP: guardián de las salas. Responde sobre proyectos, decisiones y movimientos; cita siempre la sala de cada hecho. Si una sala no se pudo leer, dilo. Nunca muevas un proyecto por dentro.';
568
615
  const ordering = contracts.filter((contract) => receivesOrders(contract, salas));
@@ -571,8 +618,18 @@ export function formatBishop({ rooms, glances = new Map(), contracts = [], links
571
618
  const unlinked = rooms.filter((room) => room.id !== salas && !linked.has(room.id));
572
619
  const never = 'Nunca pidas llaves, propongas publicar ni nombres rutas privadas.';
573
620
  let end;
574
- if (answersOnly) end = `Solo consulta: responde y cita la sala de cada dato; di qué no pudiste leer. Sin bloques de órdenes ni de enlaces. ${never}`;
575
- else {
621
+ if (answersOnly) end = `Solo consulta: responde y cita la sala de cada dato; di qué no pudiste leer; si ninguna sala lo discutió, dilo. Sin bloques de órdenes ni de enlaces. ${never}`;
622
+ else if (localOrders) {
623
+ // D-S18: the local model that passed its exam — orders up to #1, the shape shown with a room and
624
+ // an agent that are really here, and no connections.
625
+ const sample = ordering.length ? (names.get(receivesOrders(ordering[0], salas)) ?? receivesOrders(ordering[0], salas)) : 'sala';
626
+ const helper = crew.find((id) => id !== 'madre') ?? crew[0] ?? 'codex';
627
+ end = [
628
+ 'Si el humano pide una orden, cierra tu respuesta con este bloque, y nada después:',
629
+ `\`\`\`${PLAN_FENCE}`, 'Motivo del plan en una línea', `@${sample}/${helper} #1: la tarea, en una frase`, '```',
630
+ `Órdenes: hasta 4, «@sala/agente #0-#1: tarea», solo a salas con órdenes${crew.length ? `; agentes: ${crew.join(', ')}` : ''}. Nunca a una sala que no está en los contratos. Sin enlaces. ${never}`,
631
+ ].join('\n');
632
+ } else {
576
633
  // A small model copies a placeholder word for word («@sala/agente»), so the shape is shown with
577
634
  // rooms that are really there and an agent that is really here.
578
635
  const sample = ordering.length ? (names.get(receivesOrders(ordering[0], salas)) ?? receivesOrders(ordering[0], salas)) : null;
@@ -591,7 +648,7 @@ export function formatBishop({ rooms, glances = new Map(), contracts = [], links
591
648
  const a = names.get(contract.rooms.a) ?? clean(contract.rooms.a, 40);
592
649
  const b = names.get(contract.rooms.b) ?? clean(contract.rooms.b, 40);
593
650
  const receives = receivesOrders(contract, salas);
594
- lines.push(`- ${a} ${contract.direction} ${b}; lee:${contract.kinds.map((kind) => clean(kind, 12)).join(',')}; órdenes:${receives ? `a ${names.get(receives) ?? clean(receives, 40)} hasta #${Math.min(contract.orders.maxMode, MAX_ORDER_MODE)}` : 'no'}`);
651
+ lines.push(`- ${a} ${contract.direction} ${b}; lee:${contract.kinds.map((kind) => clean(kind, 12)).join(',')}; órdenes:${receives ? `a ${names.get(receives) ?? clean(receives, 40)} hasta #${Math.min(contract.orders.maxMode, MAX_ORDER_MODE)}` : 'no'}${contract.state ? '; estado:sí' : ''}${contract.deliver ? '; entrega:sí' : ''}`);
595
652
  }
596
653
  if (!contracts.length) lines.push('- ninguno');
597
654
  lines.push(`Lectura para esta sala: ${links.map(({ from }) => names.get(from) ?? clean(from, 40)).join(', ') || 'ninguna'}. Solo notas con contrato firmado; ${local ? 'las notas disponibles para esta pregunta van abajo' : 'usa pulse-enlaces para buscar'} y cita la sala.`);
@@ -643,5 +700,7 @@ export async function bishopBlock(ctx, { agent, text = '', mode = 1, preview = f
643
700
  else unread.push({ room: source.name, why: answer.status === 429 ? 'tope de lecturas por hora' : answer.status === 423 ? 'archivo sellado' : 'no respondió' });
644
701
  }
645
702
  }
646
- return formatBishop({ rooms, glances, contracts, links, notes: notes.slice(0, MAX_NOTES), unread, local: agent === 'madre', salas, crew: readyAgents(ctx).map((one) => one.id), answersOnly: onlyAnswers(agent) || CONSULT_HEADER.test(text), signer: signerOf(ctx) });
703
+ const consult = CONSULT_HEADER.test(text);
704
+ const answersOnly = consult || await answersOnlyNow(ctx, agent);
705
+ return formatBishop({ rooms, glances, contracts, links, notes: notes.slice(0, MAX_NOTES), unread, local: agent === 'madre', salas, crew: readyAgents(ctx).map((one) => one.id), answersOnly, localOrders: !answersOnly && onlyAnswers(agent), signer: signerOf(ctx) });
647
706
  }
@@ -22,19 +22,21 @@ import { realpath } from 'node:fs/promises';
22
22
  import { basename, join } from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { loadConfig } from '../config.mjs';
25
- import { CHANNEL_HEADER, DAY_MS, LINK_KINDS, MAX_LIMIT, RECALL_NOTES, activeContracts, askEnlace, channelKey, countRecent, readsFrom } from '../enlaces.mjs';
25
+ import { CHANNEL_HEADER, DAY_MS, DELIVER_CHARS, DELIVER_MAX, LINK_KINDS, MAX_LIMIT, RECALL_NOTES, activeContracts, askEnlace, channelKey, countRecent, deliversTo, readInbox, readsFrom } from '../enlaces.mjs';
26
+ import { continuityText } from './ahp.mjs';
26
27
  import { t } from '../i18n.mjs';
27
28
  import { knownRooms, projectRoomId, salasRoomId } from '../salas.mjs';
28
29
  import { defineModule } from './sdk.mjs';
30
+ import { moduleOnIn } from '../module-scope.mjs';
29
31
 
30
32
  const SELF = fileURLToPath(import.meta.url);
31
33
  export const SERVER_NAME = 'pulse-enlaces';
32
34
  export const SERVER_VERSION = '0.1.0';
33
- export const ENLACE_TOOLS = ['enlace_rooms', 'enlace_search'];
35
+ export const ENLACE_TOOLS = ['enlace_rooms', 'enlace_search', 'enlace_state', 'enlace_deliver', 'enlace_inbox', 'enlace_accept'];
34
36
 
35
37
  // What the agent is told of the server, in the language every brief is written in. Read-only is
36
38
  // said twice on purpose: once as a fact, once as what to do with it.
37
- export const BRIEF = 'Distilled notes of other rooms on this Mac that a signed link lets this room read (MCP server pulse-enlaces). Read-only: nothing you write here reaches those rooms, and nothing of theirs enters this room unless you ask for it. enlace_rooms says which rooms you may read and which kinds of notes; enlace_search reads them. Say where each borrowed note comes from, as in «according to room <name>».';
39
+ export const BRIEF = 'Distilled notes of other rooms on this Mac that a signed link lets this room read (MCP server pulse-enlaces). Read-only when you read: nothing you read writes in those rooms, and nothing of theirs enters this room unless you ask for it. enlace_rooms says which rooms you may read, which kinds of notes, and what else each link allows; enlace_search reads notes; enlace_state reads a room\'s canonical state (Continuity) when its link shares it. Where a link allows deliveries, enlace_deliver hands a few notes to that room, where they wait until that room accepts them. enlace_inbox lists the notes other rooms delivered here; enlace_accept accepts or declines one, only in #3 or #4. Say where each borrowed note comes from, as in «according to room <name>».';
38
40
 
39
41
  // The links that let a room read, from the config every room on this Mac shares.
40
42
  const linksFor = (config, stateRoot, here, now = new Date()) => readsFrom(activeContracts(config, { now, salas: salasRoomId(stateRoot) }), here, { now });
@@ -78,7 +80,9 @@ export default defineModule({
78
80
  async toolsForTurn(ctx, turn) {
79
81
  if (!(Number(turn?.mode) >= 1) || !turn?.roomDir || !turn?.port || !ctx.stateRoot) return [];
80
82
  const here = basename(turn.roomDir);
81
- if (!linksFor(ctx.config, ctx.stateRoot, here).length) return [];
83
+ // A room with notes delivered to it gets the tools too, to read them and, in #3/#4, decide them.
84
+ const waiting = (await readInbox(turn.roomDir).catch(() => [])).some((note) => note.status === 'pending');
85
+ if (!linksFor(ctx.config, ctx.stateRoot, here).length && !waiting) return [];
82
86
  return [{
83
87
  name: SERVER_NAME,
84
88
  command: process.execPath,
@@ -99,8 +103,9 @@ export default defineModule({
99
103
  // hourly limit, a sealed archive or a room asleep simply brings nothing this turn.
100
104
  export async function recallForTurn(ctx, { agent = null, mode = 1, text = '', turn = null, preview = false, roomDir = null, record = null } = {}) {
101
105
  if (!(Number(mode) >= 1) || !ctx?.stateRoot || !roomDir) return null;
102
- if (ctx.config?.modules?.enlaces?.enabled === false) return null;
103
106
  const here = basename(roomDir);
107
+ // Off in this project, or for every project and not turned on here (module-scope.mjs).
108
+ if (!moduleOnIn(ctx.config?.modules?.enlaces ?? {}, here, true)) return null;
104
109
  const links = linksFor(ctx.config, ctx.stateRoot, here).filter(({ contract }) => contract.recall);
105
110
  if (!links.length) return null;
106
111
  if (preview) return { text: null, dynamic: true };
@@ -140,6 +145,29 @@ export const TOOLS = [
140
145
  limit: { type: 'integer', description: 'Max notes (1-20, default 20).' },
141
146
  }, required: ['room'] },
142
147
  },
148
+ {
149
+ name: 'enlace_state',
150
+ description: 'Read the canonical state of another room — objective, phase, next action, blockers, its last handoff, accepted decisions and what is still open — as Continuity (AHP+) keeps it there, when the link shares it. Read-only, through that room\'s privacy guard. Cite it as that room\'s state on its date.',
151
+ inputSchema: { type: 'object', properties: { room: { type: 'string', description: 'The room, by its name as enlace_rooms lists it.' } }, required: ['room'] },
152
+ },
153
+ {
154
+ name: 'enlace_deliver',
155
+ description: `Hand up to ${DELIVER_MAX} notes to a room this room reads from, when the link allows deliveries. Each note waits in that room until it is accepted there; nothing enters its memory by itself. Only knowledge that stands (decision, fact, preference, question), up to ${DELIVER_CHARS} characters each, with the reason in one line.`,
156
+ inputSchema: { type: 'object', properties: {
157
+ room: { type: 'string', description: 'The room, by its name as enlace_rooms lists it.' },
158
+ notes: { type: 'array', maxItems: DELIVER_MAX, items: { type: 'object', properties: { kind: { type: 'string', enum: LINK_KINDS }, text: { type: 'string' }, why: { type: 'string' } }, required: ['kind', 'text'] } },
159
+ }, required: ['room', 'notes'] },
160
+ },
161
+ {
162
+ name: 'enlace_inbox',
163
+ description: 'The notes other rooms delivered to this room that are still waiting: id, sender, kind, text and why. Read-only.',
164
+ inputSchema: { type: 'object', properties: {} },
165
+ },
166
+ {
167
+ name: 'enlace_accept',
168
+ description: 'Accept (or decline, with accept: false) one delivered note by its id. Accepted, it enters this room\'s memory as a new note, held first like any other. Only in #3 or #4; below that, the human decides.',
169
+ inputSchema: { type: 'object', properties: { id: { type: 'string' }, accept: { type: 'boolean' } }, required: ['id'] },
170
+ },
143
171
  ];
144
172
 
145
173
  function clamp(n, min, max, fallback) { const value = Number(n); return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback; }
@@ -161,7 +189,8 @@ export async function callTool(name, args = {}, { env = process.env, fetchImpl =
161
189
  return links.map(({ contract, from }) => {
162
190
  const room = rooms.find((one) => one.id === from) ?? null;
163
191
  const state = room?.open ? 'open' : room ? 'asleep (ask the human to wake it from Salas)' : 'not on this Mac any more';
164
- return `- ${room?.name ?? from} · ${contract.kinds.join(', ')} · ${contract.expires ? `until ${contract.expires.slice(0, 10)}` : 'no expiry'} · ${state}`;
192
+ const also = [contract.state ? 'shares its state (enlace_state)' : null, contract.deliver ? 'accepts deliveries (enlace_deliver)' : null].filter(Boolean);
193
+ return `- ${room?.name ?? from} · ${contract.kinds.join(', ')} · ${contract.expires ? `until ${contract.expires.slice(0, 10)}` : 'no expiry'} · ${state}${also.length ? ` · ${also.join(' · ')}` : ''}`;
165
194
  }).join('\n');
166
195
  }
167
196
  case 'enlace_search': {
@@ -194,6 +223,58 @@ export async function callTool(name, args = {}, { env = process.env, fetchImpl =
194
223
  if (!notes.length) return `Room ${from} has no note${kinds.length ? ` of kind ${kinds.join(', ')}` : ''}${query ? ` that matches "${query}"` : ' that travels'} right now.`;
195
224
  return notes.map((note) => `- [${from} · ${note.kind} · ${note.zone} · ${span(note)}] ${note.text}`).join('\n');
196
225
  }
226
+ case 'enlace_state':
227
+ case 'enlace_deliver': {
228
+ const room = String(args.room ?? '').trim();
229
+ if (!room) return 'Say which room; enlace_rooms lists them.';
230
+ if (!stateRoot || !port) return 'This turn was started without a room: nothing can be read.';
231
+ const key = await channelKey(stateRoot).catch(() => null);
232
+ if (!key) return 'Nothing was done: this Mac has no key for its links.';
233
+ const sending = name === 'enlace_deliver';
234
+ const notes = sending ? (Array.isArray(args.notes) ? args.notes : []).slice(0, DELIVER_MAX).map((note) => ({ kind: String(note?.kind ?? ''), text: String(note?.text ?? ''), why: note?.why ? String(note.why) : null })) : null;
235
+ if (sending && !notes.length) return `Nothing was delivered: give between 1 and ${DELIVER_MAX} notes.`;
236
+ let response;
237
+ try {
238
+ response = await fetchImpl(`http://127.0.0.1:${port}${sending ? '/api/enlace/send' : '/api/enlace/ask-state'}`, {
239
+ method: 'POST',
240
+ headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: key },
241
+ body: JSON.stringify({ room, notes, agent: env.PULSE_ENLACES_AGENT ?? null, turn: env.PULSE_ENLACES_TURN ?? null }),
242
+ signal: AbortSignal.timeout(timeoutMs),
243
+ });
244
+ } catch {
245
+ return `Nothing was ${sending ? 'delivered' : 'read'}: this room did not answer.`;
246
+ }
247
+ const answer = await response.json().catch(() => ({}));
248
+ if (!response.ok) return `Nothing was ${sending ? 'delivered' : 'read'}: ${typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`}`;
249
+ const from = typeof answer?.from === 'string' && answer.from ? answer.from : typeof answer?.to === 'string' && answer.to ? answer.to : room;
250
+ if (sending) return `${answer.delivered} note${answer.delivered === 1 ? '' : 's'} delivered to room ${from}. They wait there until that room accepts them; nothing is in its memory yet.`;
251
+ return continuityText(from, answer.state) ?? `Room ${from} keeps no canonical state (Continuity is not installed there).`;
252
+ }
253
+ case 'enlace_inbox': {
254
+ if (!stateRoot || !here) return 'This turn was started without a room: nothing can be read.';
255
+ const pending = (await readInbox(join(stateRoot, 'rooms', here))).filter((note) => note.status === 'pending');
256
+ if (!pending.length) return 'No delivered note is waiting in this room.';
257
+ return pending.map((note) => `- ${note.id} · from ${note.name}${note.agent ? ` (@${note.agent})` : ''} · ${note.kind}: ${note.text}${note.why ? ` — why: ${note.why}` : ''}`).join('\n');
258
+ }
259
+ case 'enlace_accept': {
260
+ if (!port) return 'This turn was started without a room: nothing can be decided.';
261
+ const key = await channelKey(stateRoot).catch(() => null);
262
+ if (!key) return 'Nothing was decided: this Mac has no key for its links.';
263
+ let response;
264
+ try {
265
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/inbox/decide`, {
266
+ method: 'POST',
267
+ headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: key },
268
+ body: JSON.stringify({ id: String(args.id ?? ''), accept: args.accept !== false, agent: env.PULSE_ENLACES_AGENT ?? null, turn: env.PULSE_ENLACES_TURN ?? null }),
269
+ signal: AbortSignal.timeout(timeoutMs),
270
+ });
271
+ } catch {
272
+ return 'Nothing was decided: this room did not answer.';
273
+ }
274
+ const answer = await response.json().catch(() => ({}));
275
+ if (!response.ok) return `Nothing was decided: ${typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`}`;
276
+ return answer.note?.status === 'accepted' ? `Accepted: it enters this room's memory as a new note, held first like any other.` : 'Declined: it stays out of this room\'s memory.';
277
+ }
197
278
  default:
198
279
  throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
199
280
  }
@@ -4,6 +4,7 @@
4
4
  // the CLIs that cannot draw natively. A switch and a model in config.json.
5
5
 
6
6
  import { defineModule } from './sdk.mjs';
7
+ import { switchIn } from '../module-scope.mjs';
7
8
  import { t } from '../i18n.mjs';
8
9
 
9
10
  const MODELS = ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'];
@@ -34,12 +35,20 @@ export default defineModule({
34
35
  install: { display: ctx.settings.enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
35
36
  };
36
37
  },
38
+ // Before it turns on, in one project or in all: a key to bill the images against.
39
+ async ready(ctx) {
40
+ if (await ctx.services.imageKey()) return null;
41
+ 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.' } };
42
+ },
43
+ // The room's own image tools follow its switch, wherever the switch was moved from.
44
+ async onToggle(ctx, enabled) { ctx.services.setImageModule({ enabled, model: ctx.settings.model ?? MODELS[0] }); return null; },
37
45
  async toggle(ctx, payload) {
38
46
  const enabled = !ctx.settings.enabled;
39
47
  const key = await ctx.services.imageKey();
40
48
  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.' } };
41
49
  const model = typeof payload?.model === 'string' && payload.model ? payload.model : (ctx.settings.model ?? MODELS[0]);
42
- await ctx.updateConfig({ modules: { imageStudio: { enabled, model } } });
50
+ // A project's switch is that project's (module-scope.mjs); the model is every project's.
51
+ await ctx.updateConfig({ modules: { imageStudio: { ...switchIn(ctx.config.modules?.imageStudio ?? {}, ctx.roomId ?? null, enabled), model } } });
43
52
  ctx.services.setImageModule({ enabled, model });
44
53
  await ctx.record('extension.toggled', { id: 'image-studio', name: 'Image Studio', enabled, model });
45
54
  return { status: 200, body: { enabled, model, capabilities: ctx.room.capabilities() } };
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
  import { defineModule } from './sdk.mjs';
20
20
  import { t } from '../i18n.mjs';
21
21
  import { IntentsDesk } from './intents-desk.mjs';
22
+ import { moduleOnIn } from '../module-scope.mjs';
22
23
 
23
24
  const desks = new Map();
24
25
  // One desk per project room, made the first time anything asks for it.
@@ -40,7 +41,8 @@ export function deskFor(ctx) {
40
41
  return desks.get(where.roomDir);
41
42
  }
42
43
 
43
- const enabled = (ctx) => ctx.config?.modules?.intents?.enabled !== false;
44
+ // On unless turned off, for every project or for this one (module-scope.mjs).
45
+ const enabled = (ctx) => moduleOnIn(ctx.config?.modules?.intents ?? {}, ctx.roomId ?? null, true);
44
46
  const count = (value) => Math.max(0, Math.min(50, Number.parseInt(value, 10) || 0));
45
47
  const modeOf = (value) => (Number.isInteger(value) && value >= 0 && value <= 4 ? value : null);
46
48
  const nameOrNull = (value) => (typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,120}$/.test(value) ? value : null);
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // What this computer can do with a local AI, measured and not assumed (2026-10-16).
4
+ //
5
+ // The local AI was sized for one machine: a 16 GB Mac ran a 3B model and trained a 1.5B one, and
6
+ // a 32 GB one trained a 3B. A person with a 128 GB Mac Studio or a 24 GB graphics card can run and
7
+ // train far larger models, and a person with 8 GB should not be offered what will not load. This
8
+ // reads the hardware once — the memory the model would live in and what would compute — and sets
9
+ // it against the minimums of each model size, so the room can say, model by model and feature by
10
+ // feature, what this computer runs, what it trains and what it should leave off.
11
+ //
12
+ // The minimums are deliberately conservative, for the whole computer, not the model alone:
13
+ // · run a 4-bit model in Ollama with an 8k window, the system and the browser still open;
14
+ // · train a LoRA with mlx-lm (on a 4-bit base from 3B up, QLoRA; at full precision below that).
15
+ // On a Mac the memory is unified and the GPU may use about three quarters of it; on an NVIDIA card
16
+ // what counts is its own memory (VRAM). Without either, a model still runs on the processor —
17
+ // slower — and nothing is trained: a LoRA on a processor takes days.
18
+
19
+ import { execFile } from 'node:child_process';
20
+ import { arch as osArch, cpus as osCpus, platform as osPlatform, totalmem as osTotalmem } from 'node:os';
21
+
22
+ const GB = 1024 ** 3;
23
+
24
+ // One family, every size, so a recommendation is a step up or down a single ladder and a model
25
+ // trained at one size is the same model at another. Qwen2.5 Instruct: multilingual (it answers in
26
+ // the human's language), open weights, an Ollama tag and an MLX 4-bit build at every size.
27
+ export const MODEL_TIERS = [
28
+ { id: '0.5b', params: 0.5, label: 'Qwen2.5 0.5B', ollama: 'qwen2.5:0.5b', base: 'Qwen/Qwen2.5-0.5B-Instruct', runGb: 4, trainGb: 8 },
29
+ { id: '1.5b', params: 1.5, label: 'Qwen2.5 1.5B', ollama: 'qwen2.5:1.5b', base: 'Qwen/Qwen2.5-1.5B-Instruct', runGb: 6, trainGb: 12 },
30
+ { id: '3b', params: 3, label: 'Qwen2.5 3B', ollama: 'qwen2.5:3b', base: 'mlx-community/Qwen2.5-3B-Instruct-4bit', quantized: true, runGb: 8, trainGb: 16 },
31
+ { id: '7b', params: 7, label: 'Qwen2.5 7B', ollama: 'qwen2.5:7b', base: 'mlx-community/Qwen2.5-7B-Instruct-4bit', quantized: true, runGb: 12, trainGb: 24 },
32
+ { id: '14b', params: 14, label: 'Qwen2.5 14B', ollama: 'qwen2.5:14b', base: 'mlx-community/Qwen2.5-14B-Instruct-4bit', quantized: true, runGb: 20, trainGb: 36 },
33
+ { id: '32b', params: 32, label: 'Qwen2.5 32B', ollama: 'qwen2.5:32b', base: 'mlx-community/Qwen2.5-32B-Instruct-4bit', quantized: true, runGb: 36, trainGb: 64 },
34
+ { id: '72b', params: 72, label: 'Qwen2.5 72B', ollama: 'qwen2.5:72b', base: 'mlx-community/Qwen2.5-72B-Instruct-4bit', quantized: true, runGb: 64, trainGb: 128 },
35
+ ];
36
+
37
+ // What the local AI does in MADRE, and the smallest model each one is worth turning on with. Below
38
+ // it the feature works but answers poorly enough that it is better left off.
39
+ export const LOCAL_FEATURES = [
40
+ { id: 'search', needs: null }, // search by meaning: an embedding model, ~0.3 GB
41
+ { id: 'archivist', needs: '3b' }, // the notes it distils from each conversation
42
+ { id: 'agent', needs: '3b' }, // @madre answering in a project
43
+ { id: 'coordinator', needs: '7b' }, // the Coordinator answering about every project
44
+ { id: 'train', needs: '1.5b', train: true }, // training your own model on what the project knows
45
+ ];
46
+
47
+ const run = (file, args, { timeoutMs = 2500, exec = execFile } = {}) => new Promise((resolve) => {
48
+ try {
49
+ exec(file, args, { timeout: timeoutMs, windowsHide: true }, (error, stdout) => resolve(error ? null : String(stdout ?? '')));
50
+ } catch { resolve(null); }
51
+ });
52
+
53
+ // NVIDIA's own tool, if the card and its driver are there: name and memory of each card, in MiB.
54
+ export function parseNvidia(output) {
55
+ return String(output ?? '').split('\n').map((line) => line.split(',').map((part) => part.trim())).filter(([name, mib]) => name && Number.isFinite(Number(mib)))
56
+ .map(([name, mib]) => ({ name, vramGb: Math.round(Number(mib) / 1024) }));
57
+ }
58
+
59
+ // The hardware, read once. Everything is injectable so the reading can be tested on any machine.
60
+ export async function detectHardware({ platform = osPlatform(), arch = osArch(), totalmem = osTotalmem(), cpus = osCpus(), exec = execFile } = {}) {
61
+ const memoryGb = Math.round(totalmem / GB);
62
+ const cores = Array.isArray(cpus) ? cpus.length : 0;
63
+ let chip = Array.isArray(cpus) && cpus[0]?.model ? String(cpus[0].model).trim() : null;
64
+ if (platform === 'darwin') {
65
+ const brand = await run('sysctl', ['-n', 'machdep.cpu.brand_string'], { exec });
66
+ if (brand?.trim()) chip = brand.trim();
67
+ }
68
+ // Apple silicon: one memory for the processor and the GPU, which MLX and Ollama both use.
69
+ if (platform === 'darwin' && arch === 'arm64') return { platform, arch, chip, cores, memoryGb, accelerator: 'apple', acceleratorGb: memoryGb, gpus: [] };
70
+ const gpus = parseNvidia(await run('nvidia-smi', ['--query-gpu=name,memory.total', '--format=csv,noheader,nounits'], { exec }));
71
+ if (gpus.length) {
72
+ // A model lives on one card unless it is split; the largest card is what one model may count on.
73
+ const largest = Math.max(...gpus.map((gpu) => gpu.vramGb));
74
+ return { platform, arch, chip, cores, memoryGb, accelerator: 'nvidia', acceleratorGb: largest, gpus };
75
+ }
76
+ return { platform, arch, chip, cores, memoryGb, accelerator: 'cpu', acceleratorGb: 0, gpus: [] };
77
+ }
78
+
79
+ // The memory a model may count on, in the units the minimums are written in (a whole Mac's).
80
+ // A Mac's GPU takes about three quarters of the unified memory, which the minimums already allow
81
+ // for; an NVIDIA card gives the model all of its own, so its VRAM counts for a third more.
82
+ export function usableGb(hardware) {
83
+ if (!hardware) return 0;
84
+ if (hardware.accelerator === 'apple') return hardware.acceleratorGb;
85
+ if (hardware.accelerator === 'nvidia') return Math.round(hardware.acceleratorGb / 0.75);
86
+ return hardware.memoryGb;
87
+ }
88
+
89
+ // Model by model: does it run here (fast, slowly on the processor, or not), does it train here,
90
+ // and why not. Then the one to recommend for each, and what each feature of the local AI may use.
91
+ export function hardwareFit(hardware) {
92
+ const usable = usableGb(hardware);
93
+ const accelerated = hardware?.accelerator === 'apple' || hardware?.accelerator === 'nvidia';
94
+ const models = MODEL_TIERS.map((tier) => {
95
+ const runs = usable >= tier.runGb ? (accelerated ? 'yes' : 'slow') : 'no';
96
+ const trains = accelerated && usable >= tier.trainGb ? 'yes' : 'no';
97
+ const trainWhy = !accelerated ? 'no-accelerator' : trains === 'no' ? 'memory' : null;
98
+ return { ...tier, run: runs, train: trains, runWhy: runs === 'no' ? 'memory' : runs === 'slow' ? 'no-accelerator' : null, trainWhy };
99
+ });
100
+ // Recommended: the largest that runs well, but no larger than 14B for the everyday model of a
101
+ // project — beyond that answers come slowly enough to hold the room up. On a processor alone,
102
+ // no larger than 3B. Training: the largest that trains, no larger than what it then runs.
103
+ const runnable = models.filter((model) => model.run === 'yes');
104
+ const everyday = (accelerated ? runnable.filter((model) => model.params <= 14) : models.filter((model) => model.run === 'slow' && model.params <= 3));
105
+ const run = everyday.at(-1) ?? null;
106
+ const trainable = models.filter((model) => model.train === 'yes' && runnable.includes(model));
107
+ const train = trainable.at(-1) ?? null;
108
+ const largestRun = (accelerated ? runnable : models.filter((model) => model.run === 'slow')).at(-1) ?? null;
109
+ const order = MODEL_TIERS.map((tier) => tier.id);
110
+ const features = LOCAL_FEATURES.map((feature) => {
111
+ if (!feature.needs) return { id: feature.id, ok: true, needs: null };
112
+ const at = order.indexOf(feature.needs);
113
+ const ok = feature.train ? Boolean(train) && order.indexOf(train.id) >= at : Boolean(largestRun) && order.indexOf(largestRun.id) >= at;
114
+ return { id: feature.id, ok, needs: feature.needs, slow: !feature.train && ok && !accelerated };
115
+ });
116
+ return { hardware, usableGb: usable, accelerated, models, recommended: { run, train, largestRun }, features };
117
+ }
118
+
119
+ let measured = null;
120
+ // Read once per process: the hardware does not change under a running room.
121
+ export function hardwareOnce(options) {
122
+ if (!measured) measured = detectHardware(options).then(hardwareFit).catch(() => hardwareFit({ accelerator: 'cpu', acceleratorGb: 0, memoryGb: 0, gpus: [] }));
123
+ return measured;
124
+ }
@@ -7,6 +7,15 @@ import { defineModule } from './sdk.mjs';
7
7
  import { t } from '../i18n.mjs';
8
8
  import { findOnPath } from './helpers.mjs';
9
9
  import { RECOMMENDED } from '../ollama.mjs';
10
+ import { hardwareOnce } from './local-models.mjs';
11
+
12
+ // The chat model to pull is the one this computer runs well (local-models.mjs), not one size for
13
+ // every machine; the embedding model is small enough for all of them. With the hardware, the page
14
+ // says which sizes run here and which features of the local AI are worth turning on.
15
+ export async function recommendedHere() {
16
+ const fit = await hardwareOnce();
17
+ return { ...RECOMMENDED, chat: fit.recommended.run?.ollama ?? RECOMMENDED.chat, hardware: fit };
18
+ }
10
19
 
11
20
  // Ollama is not an npm package, so each system has its own way in. Where MADRE can run it, the
12
21
  // command is shown on the button before it runs; where it cannot, it hands over the download.
@@ -73,7 +82,7 @@ export default defineModule({
73
82
  models: probe.models.map((model) => model.name),
74
83
  status: { installed: settings.enabled && project.on && probe.running && roles.length > 0, detail },
75
84
  ollama: { ...view, project },
76
- recommended: RECOMMENDED,
85
+ recommended: await recommendedHere(),
77
86
  preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
78
87
  install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
79
88
  };
@@ -103,8 +112,8 @@ export default defineModule({
103
112
  return { status: 200, body: { enabled, ollama: status } };
104
113
  },
105
114
  routes: [
106
- { method: 'GET', path: '/api/ollama', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire({ probe: false }), ctx.settings), recommended: RECOMMENDED } }) },
107
- { method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire(), ctx.settings), recommended: RECOMMENDED } }) },
115
+ { method: 'GET', path: '/api/ollama', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire({ probe: false }), ctx.settings), recommended: await recommendedHere() } }) },
116
+ { method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire(), ctx.settings), recommended: await recommendedHere() } }) },
108
117
  { method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
109
118
  const next = { ...(ctx.config.modules?.ollama ?? {}) };
110
119
  for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];