@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.
- package/CHANGELOG.en.md +69 -1
- package/CHANGELOG.md +69 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1626 -151
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +393 -25
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +98 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +307 -30
- package/public/styles.css +838 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +4 -2
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server.mjs +617 -48
- package/src/setup.mjs +1 -1
package/src/modules/bishop.mjs
CHANGED
|
@@ -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 ||
|
|
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
|
-
:
|
|
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) ||
|
|
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 && !
|
|
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
|
-
|
|
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
|
}
|
package/src/modules/enlaces.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() } };
|
package/src/modules/intents.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/modules/ollama.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
107
|
-
{ method: 'POST', path: '/api/ollama/probe', handler: async (ctx) => ({ status: 200, body: { ollama: await ollamaView(await ctx.services.ollama.wire(), ctx.settings), 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];
|