@jossuealcala/madre 1.0.1 → 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 (189) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +2013 -0
  3. package/CHANGELOG.md +102 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +241 -0
  8. package/README.md +21 -12
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.cjs +40 -0
  12. package/bin/madre.mjs +7 -0
  13. package/docs/INTERNALS.md +6 -0
  14. package/docs/REFERENCE.md +40 -5
  15. package/docs/SDK.md +4 -2
  16. package/docs/en/INTERNALS.md +103 -0
  17. package/docs/en/REFERENCE.md +365 -0
  18. package/docs/en/SDK.md +308 -0
  19. package/docs/en/training/README.md +59 -0
  20. package/docs/training/README.md +48 -9
  21. package/docs/training/train.sh +7 -2
  22. package/package.json +13 -5
  23. package/public/abduction.js +2 -0
  24. package/public/app.js +3154 -539
  25. package/public/attribution.js +114 -0
  26. package/public/brands.js +23 -6
  27. package/public/code-tools.js +143 -0
  28. package/public/editor.js +22 -4
  29. package/public/es.js +1004 -416
  30. package/public/hardware.js +50 -0
  31. package/public/i18n.js +25 -1
  32. package/public/index.html +18 -11
  33. package/public/inquiry.js +2 -0
  34. package/public/loader.js +2 -0
  35. package/public/macroverso.js +189 -96
  36. package/public/market-art.js +161 -0
  37. package/public/market-view.js +529 -0
  38. package/public/market.js +189 -0
  39. package/public/neural.js +110 -0
  40. package/public/nostromo-engine.js +414 -47
  41. package/public/power.js +100 -0
  42. package/public/resay.js +11 -0
  43. package/public/review-ask.js +2 -0
  44. package/public/routing.js +23 -1
  45. package/public/salas-mac.css +119 -18
  46. package/public/salas-mac.html +14 -2
  47. package/public/salas-mac.js +358 -56
  48. package/public/salas.css +2 -0
  49. package/public/salas.html +1 -0
  50. package/public/salas.js +4 -2
  51. package/public/styles.css +1793 -77
  52. package/public/syntax.js +2 -0
  53. package/public/touch.js +2 -0
  54. package/public/tour-chapters.js +2 -0
  55. package/public/tour.js +84 -5
  56. package/public/troubleshooting.js +24 -17
  57. package/public/wallpaper.js +275 -0
  58. package/src/adapters/claude.mjs +2 -0
  59. package/src/adapters/codex.mjs +2 -0
  60. package/src/adapters/cursor.mjs +96 -0
  61. package/src/adapters/gemini.mjs +13 -4
  62. package/src/adapters/madre.mjs +5 -2
  63. package/src/adapters/opencode.mjs +7 -3
  64. package/src/adapters/process.mjs +2 -0
  65. package/src/asking.mjs +2 -0
  66. package/src/auth-probe.mjs +145 -12
  67. package/src/bridge/devices.mjs +2 -0
  68. package/src/bridge/handoff.mjs +2 -0
  69. package/src/bridge/host.mjs +2 -0
  70. package/src/bridge/network.mjs +2 -0
  71. package/src/bridge/policy.mjs +3 -1
  72. package/src/bridge/qr.mjs +2 -0
  73. package/src/bridge/server.mjs +2 -0
  74. package/src/capabilities.mjs +11 -0
  75. package/src/chats.mjs +2 -0
  76. package/src/checkpoint.mjs +18 -1
  77. package/src/choices.mjs +2 -0
  78. package/src/cli-args.mjs +2 -0
  79. package/src/cold.mjs +2 -0
  80. package/src/commands.mjs +2 -0
  81. package/src/config.mjs +2 -0
  82. package/src/conversation-context.mjs +15 -1
  83. package/src/credentials.mjs +35 -3
  84. package/src/dataset.mjs +78 -4
  85. package/src/directives.mjs +2 -0
  86. package/src/distiller.mjs +2 -0
  87. package/src/embeddings.mjs +2 -0
  88. package/src/enlaces.mjs +214 -6
  89. package/src/event-store.mjs +2 -0
  90. package/src/exam.mjs +6 -4
  91. package/src/extensions.mjs +13 -3
  92. package/src/eyecat-watch.mjs +2 -0
  93. package/src/eyecat.mjs +2 -0
  94. package/src/file-ops.mjs +2 -0
  95. package/src/files.mjs +32 -0
  96. package/src/github.mjs +2 -0
  97. package/src/heartbeat.mjs +2 -0
  98. package/src/i18n.mjs +31 -1
  99. package/src/image-studio.mjs +2 -0
  100. package/src/json-file.mjs +2 -0
  101. package/src/launch.mjs +2 -0
  102. package/src/lease.mjs +5 -1
  103. package/src/lobby-work.mjs +2 -0
  104. package/src/local-ai.mjs +2 -0
  105. package/src/looks.mjs +127 -2
  106. package/src/market-key.mjs +8 -0
  107. package/src/market-remote.mjs +239 -0
  108. package/src/maturity.mjs +3 -1
  109. package/src/mcp/connect.mjs +345 -0
  110. package/src/mcp/github-server.mjs +2 -0
  111. package/src/mcp/image-server.mjs +2 -0
  112. package/src/mcp/memory-server.mjs +2 -0
  113. package/src/mcp/proxy.mjs +153 -0
  114. package/src/mcp/smtp-server.mjs +2 -0
  115. package/src/memory-dashboard.mjs +2 -0
  116. package/src/memory-tools.mjs +2 -0
  117. package/src/memory.mjs +2 -0
  118. package/src/models.mjs +49 -3
  119. package/src/module-scope.mjs +68 -0
  120. package/src/modules/ahp.mjs +104 -7
  121. package/src/modules/ash-policy.mjs +2 -0
  122. package/src/modules/ash.mjs +3 -1
  123. package/src/modules/bishop-exam.mjs +122 -0
  124. package/src/modules/bishop-plan.mjs +5 -3
  125. package/src/modules/bishop.mjs +84 -23
  126. package/src/modules/choices.mjs +2 -0
  127. package/src/modules/correo.mjs +2 -0
  128. package/src/modules/enlaces.mjs +89 -6
  129. package/src/modules/git-pulse.mjs +2 -0
  130. package/src/modules/github.mjs +2 -0
  131. package/src/modules/helpers.mjs +2 -0
  132. package/src/modules/image-studio.mjs +13 -2
  133. package/src/modules/index.mjs +4 -1
  134. package/src/modules/intents-calibration.mjs +2 -0
  135. package/src/modules/intents-desk.mjs +4 -2
  136. package/src/modules/intents-observe.mjs +2 -0
  137. package/src/modules/intents-policy.mjs +4 -1
  138. package/src/modules/intents-try.mjs +2 -0
  139. package/src/modules/intents.mjs +11 -7
  140. package/src/modules/local-models.mjs +124 -0
  141. package/src/modules/mcp-connect.mjs +293 -0
  142. package/src/modules/ollama.mjs +15 -4
  143. package/src/modules/playwright.mjs +88 -15
  144. package/src/modules/ripley.mjs +2 -0
  145. package/src/modules/runs.mjs +5 -3
  146. package/src/modules/sdk.mjs +63 -9
  147. package/src/modules/updates.mjs +2 -0
  148. package/src/mother.mjs +2 -0
  149. package/src/npm-launch.mjs +18 -0
  150. package/src/ollama.mjs +8 -5
  151. package/src/outbound.mjs +16 -6
  152. package/src/override.mjs +2 -0
  153. package/src/ports.mjs +6 -2
  154. package/src/prices.mjs +2 -0
  155. package/src/privacy.mjs +2 -0
  156. package/src/quota-monitor.mjs +2 -0
  157. package/src/quota-sources.mjs +2 -0
  158. package/src/request-guard.mjs +2 -0
  159. package/src/review-ask.mjs +2 -0
  160. package/src/room/archivist.mjs +2 -0
  161. package/src/room/attachments.mjs +2 -0
  162. package/src/room/budget.mjs +2 -0
  163. package/src/room/context.mjs +2 -0
  164. package/src/room/control.mjs +7 -1
  165. package/src/room/copilot.mjs +76 -0
  166. package/src/room/economy.mjs +2 -0
  167. package/src/room/escalation.mjs +2 -0
  168. package/src/room/ghost.mjs +2 -0
  169. package/src/room/guard.mjs +2 -0
  170. package/src/room/prompt.mjs +25 -5
  171. package/src/room/returns.mjs +82 -0
  172. package/src/room/runs.mjs +4 -0
  173. package/src/room/vectors.mjs +2 -0
  174. package/src/room.mjs +223 -35
  175. package/src/router.mjs +3 -1
  176. package/src/runs.mjs +6 -1
  177. package/src/runtime-detection.mjs +11 -0
  178. package/src/salas.mjs +28 -0
  179. package/src/sentinel-errors.mjs +2 -0
  180. package/src/server-sentinel.mjs +2 -0
  181. package/src/server.mjs +757 -81
  182. package/src/setup.mjs +3 -1
  183. package/src/smtp.mjs +2 -0
  184. package/src/tour.mjs +2 -0
  185. package/src/updates.mjs +2 -0
  186. package/src/usage-sentinel.mjs +2 -0
  187. package/src/vault.mjs +2 -0
  188. package/src/verdict.mjs +3 -1
  189. package/src/whats-new.mjs +2 -0
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Intenciones: how hard each agent should think for the message being written.
2
4
  //
3
5
  // It reads the request, the conversation it belongs to and what this project has measured, and
@@ -17,6 +19,7 @@ import { join } from 'node:path';
17
19
  import { defineModule } from './sdk.mjs';
18
20
  import { t } from '../i18n.mjs';
19
21
  import { IntentsDesk } from './intents-desk.mjs';
22
+ import { moduleOnIn } from '../module-scope.mjs';
20
23
 
21
24
  const desks = new Map();
22
25
  // One desk per project room, made the first time anything asks for it.
@@ -38,7 +41,8 @@ export function deskFor(ctx) {
38
41
  return desks.get(where.roomDir);
39
42
  }
40
43
 
41
- 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);
42
46
  const count = (value) => Math.max(0, Math.min(50, Number.parseInt(value, 10) || 0));
43
47
  const modeOf = (value) => (Number.isInteger(value) && value >= 0 && value <= 4 ? value : null);
44
48
  const nameOrNull = (value) => (typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,120}$/.test(value) ? value : null);
@@ -46,10 +50,10 @@ const nameOrNull = (value) => (typeof value === 'string' && /^[A-Za-z0-9][A-Za-z
46
50
  export default defineModule({
47
51
  id: 'intents',
48
52
  configKey: 'intents',
49
- name: 'Intenciones',
53
+ name: 'Suggested effort',
50
54
  vendor: 'MADRE',
51
55
  version: '1.0.0',
52
- summary: 'Suggests how hard Claude Code and Codex should think for the message you are writing, from what was measured on this project. It suggests; you choose.',
56
+ summary: 'Suggests how hard Claude Code and Codex should think about your message, based on what worked in this project. You decide.',
53
57
  creates: ['nothing in the project', 'its measurements next to the project, in intents.jsonl'],
54
58
  settings: { enabled: true },
55
59
  card: 'intents',
@@ -64,7 +68,7 @@ export default defineModule({
64
68
  method: 'GET', path: '/api/intents',
65
69
  handler: async (ctx, { url }) => {
66
70
  const desk = deskFor(ctx);
67
- if (!desk) return { status: 503, body: { error: t('Intenciones is not available in this project.') } };
71
+ if (!desk) return { status: 503, body: { error: t('Suggested effort is not available in this project.') } };
68
72
  let models = {};
69
73
  try { models = JSON.parse(url.searchParams.get('models') ?? '{}') ?? {}; } catch { models = {}; }
70
74
  const chosen = Object.fromEntries(Object.entries(models).map(([agent, model]) => [agent, nameOrNull(model)]));
@@ -97,8 +101,8 @@ export default defineModule({
97
101
  method: 'POST', path: '/api/intents/calibrate',
98
102
  handler: async (ctx, { payload }) => {
99
103
  const desk = deskFor(ctx);
100
- if (!desk) return { status: 503, body: { error: t('Intenciones is not available in this project.') } };
101
- if (!enabled(ctx)) return { status: 409, body: { error: t('Turn Intenciones on first.') } };
104
+ if (!desk) return { status: 503, body: { error: t('Suggested effort is not available in this project.') } };
105
+ if (!enabled(ctx)) return { status: 409, body: { error: t('Turn Suggested effort on first.') } };
102
106
  const result = await desk.start({
103
107
  kind: payload?.kind === 'full' ? 'full' : 'quick',
104
108
  agent: String(payload?.agent ?? ''),
@@ -113,7 +117,7 @@ export default defineModule({
113
117
  {
114
118
  method: 'POST', path: '/api/intents/answer',
115
119
  handler: async (ctx, { payload }) => {
116
- const result = deskFor(ctx)?.answer(String(payload?.choice ?? '')) ?? { error: t('Intenciones is not available in this project.') };
120
+ const result = deskFor(ctx)?.answer(String(payload?.choice ?? '')) ?? { error: t('Suggested effort is not available in this project.') };
117
121
  return { status: result.error ? 409 : 200, body: result };
118
122
  },
119
123
  },
@@ -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
+ }
@@ -0,0 +1,293 @@
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
+ // CONNECT MCP: MCP servers a person adds to one project, for its agents (2026-10-14).
4
+ //
5
+ // The settings live in the project's Settings › Connect MCP, and only there: this module has no
6
+ // card in MODULES (`card: 'settings'`). What it does, in the order a server goes through it:
7
+ //
8
+ // 1. Added by hand (a command, or an address with no sign-in, a token or OAuth) or imported from
9
+ // the person's other apps (.mcp.json, Claude Code, Codex, Gemini CLI, Cursor, opencode.json).
10
+ // Secrets go to the vault; src/mcp/connect.mjs says how.
11
+ // 2. Checked at once: MADRE starts it through the same proxy a turn uses, does the MCP
12
+ // handshake and reads its tools. A server that does not answer stays out of every turn and
13
+ // its row says why, so a broken server costs a line in Settings, never a turn.
14
+ // 3. Handed to the turns of this project, through src/mcp/proxy.mjs, with the tools that read
15
+ // from #1 and the ones that act from the mode chosen for it (#4 unless lowered).
16
+
17
+ import { spawn } from 'node:child_process';
18
+ import { randomBytes } from 'node:crypto';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { defineModule } from './sdk.mjs';
21
+ import { summary as vaultSummary } from '../vault.mjs';
22
+ import { declareDestinations } from '../outbound.mjs';
23
+ import { t } from '../i18n.mjs';
24
+ import {
25
+ ACTION_MODES, SERVER_ID, authorizeUrl, cleanServer, discoverOAuth, exchangeCode, forgetServerSecrets,
26
+ grantFrom, importable, keepOAuth, keepSecrets, pkce, readMcp, readOAuth, registerClient, writeMcp,
27
+ } from '../mcp/connect.mjs';
28
+
29
+ export const PROXY = fileURLToPath(new URL('../mcp/proxy.mjs', import.meta.url));
30
+ const CHECK_MS = 30000;
31
+
32
+ // The argv every CLI is given for a connected server: the proxy, the project and the server. No
33
+ // secret, ever; the allowed tools travel so the proxy can refuse the others.
34
+ export const proxyArgs = ({ stateRoot, projectRoot, id, allow = null }) => [PROXY, '--state', stateRoot, '--project', projectRoot, '--server', id, ...(allow ? ['--allow', allow.join(',')] : [])];
35
+
36
+ // Starts the server the way a turn would and asks it what it has. Resolves with its tools — each
37
+ // with whether it only reads — or with the reason it did not answer.
38
+ export function checkServer({ stateRoot, projectRoot, id, timeoutMs = CHECK_MS, command = process.execPath }) {
39
+ return new Promise((resolve) => {
40
+ let child;
41
+ let out = '';
42
+ let err = '';
43
+ let settled = false;
44
+ const finish = (result) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ clearTimeout(timer);
48
+ try { child?.stdin.end(); child?.kill('SIGTERM'); } catch { /* gone */ }
49
+ resolve(result);
50
+ };
51
+ const timer = setTimeout(() => finish({ ok: false, problem: t('it did not answer within {seconds}s', { seconds: Math.round(timeoutMs / 1000) }) }), timeoutMs);
52
+ try {
53
+ child = spawn(command, proxyArgs({ stateRoot, projectRoot, id }), { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], shell: false, windowsHide: true });
54
+ } catch (error) { finish({ ok: false, problem: error.message }); return; }
55
+ const send = (message) => { if (!settled) child.stdin.write(`${JSON.stringify(message)}\n`); };
56
+ child.stdin.on('error', () => {});
57
+ child.stdout.setEncoding('utf8');
58
+ child.stderr.setEncoding('utf8');
59
+ child.stderr.on('data', (chunk) => { err = `${err}${chunk}`.slice(-2000); });
60
+ child.on('error', (error) => finish({ ok: false, problem: error.message }));
61
+ child.on('close', (code) => finish({ ok: false, problem: lastLine(err) ?? t('it exited with code {code} before it spoke', { code }) }));
62
+ let info = null;
63
+ child.stdout.on('data', (chunk) => {
64
+ out += chunk;
65
+ for (let newline = out.indexOf('\n'); newline >= 0; newline = out.indexOf('\n')) {
66
+ const line = out.slice(0, newline).trim();
67
+ out = out.slice(newline + 1);
68
+ let message;
69
+ try { message = JSON.parse(line); } catch { continue; }
70
+ if (message.id === 1) {
71
+ if (message.error) { finish(authFailure(message.error.message) ?? { ok: false, problem: String(message.error.message ?? 'initialize failed').slice(0, 300) }); return; }
72
+ info = message.result?.serverInfo ?? null;
73
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
74
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
75
+ } else if (message.id === 2) {
76
+ if (message.error) { finish(authFailure(message.error.message) ?? { ok: false, problem: String(message.error.message ?? 'tools/list failed').slice(0, 300) }); return; }
77
+ const tools = (message.result?.tools ?? []).filter((tool) => typeof tool?.name === 'string').slice(0, 200).map((tool) => ({
78
+ name: tool.name,
79
+ readOnly: tool.annotations?.readOnlyHint === true,
80
+ description: String(tool.description ?? '').replace(/\s+/g, ' ').trim().slice(0, 160),
81
+ }));
82
+ finish({ ok: true, tools, server: info ? { name: String(info.name ?? ''), version: String(info.version ?? '') } : null });
83
+ return;
84
+ }
85
+ }
86
+ });
87
+ send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'madre', version: '1' } } });
88
+ });
89
+ }
90
+ const lastLine = (text) => String(text ?? '').trim().split('\n').filter(Boolean).at(-1)?.replace(/^MADRE MCP [^:]+:\s*/, '').slice(0, 300) ?? null;
91
+ const authFailure = (message) => (/MADRE_AUTH_REQUIRED/.test(String(message ?? '')) ? { ok: false, needsAuth: true, problem: /refused the credentials/.test(String(message)) ? t('The server refused the credentials. Check the token.') : t('Sign in to this server again.') } : null);
92
+
93
+ // What a turn is handed for one server: the tools that read, and the ones that act when the mode
94
+ // allows them. Nothing for a ghost, nothing unchecked, nothing for an agent it was not given to.
95
+ export function serverForTurn(server, { stateRoot, projectRoot, agent, mode }) {
96
+ if (!server.enabled || server.problem || !server.tools?.length || mode === 0) return null;
97
+ if (server.agents && !server.agents.includes(agent)) return null;
98
+ const reading = server.tools.filter((tool) => tool.readOnly).map((tool) => tool.name);
99
+ const acting = server.tools.filter((tool) => !tool.readOnly).map((tool) => tool.name);
100
+ const actionsFrom = ACTION_MODES.includes(server.actions) ? server.actions : 4;
101
+ const acts = mode >= actionsFrom;
102
+ const tools = acts ? [...reading, ...acting] : reading;
103
+ if (!tools.length) return null;
104
+ return {
105
+ name: server.id,
106
+ command: process.execPath,
107
+ args: proxyArgs({ stateRoot, projectRoot, id: server.id, allow: tools }),
108
+ env: {},
109
+ tools,
110
+ // The core keeps the rule for #4 itself: tools that act and need #4 are declared as sending.
111
+ sends: actionsFrom === 4 ? acting : [],
112
+ brief: `${server.label}${server.server?.name && server.server.name !== server.label ? ` (${server.server.name})` : ''}, connected by the human for this project.${acting.length ? acts ? ' Its tools that act are on in this turn: say what you will do before calling one.' : ` Only its tools that read are on; the ones that act need #${actionsFrom}.` : ''}`,
113
+ };
114
+ }
115
+
116
+ // What Settings shows: everything but values. Whether a secret is held is said by name.
117
+ async function publicServers(ctx) {
118
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
119
+ const held = new Set((await vaultSummary(ctx.stateRoot, 'mcp').catch(() => [])).map((one) => one.name));
120
+ return Promise.all(servers.map(async (server) => ({
121
+ ...server,
122
+ signedIn: server.auth === 'oauth' ? Boolean((await readOAuth(ctx.stateRoot, server.id))?.access_token) : undefined,
123
+ tokenHeld: server.auth === 'token' ? held.has(`${server.id}_token`) : undefined,
124
+ })));
125
+ }
126
+
127
+ async function saveChecked(ctx, server) {
128
+ const check = await checkServer({ stateRoot: ctx.stateRoot, projectRoot: ctx.projectRoot, id: server.id });
129
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
130
+ const next = servers.map((one) => (one.id === server.id ? {
131
+ ...one,
132
+ tools: check.ok ? check.tools : one.tools,
133
+ server: check.ok ? check.server : one.server ?? null,
134
+ checkedAt: new Date().toISOString(),
135
+ problem: check.ok ? null : check.problem,
136
+ needsAuth: check.needsAuth || undefined,
137
+ } : one));
138
+ await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: next });
139
+ await ctx.record('mcp.checked', { id: server.id, ok: check.ok, tools: check.ok ? check.tools.length : undefined, problem: check.ok ? undefined : check.problem });
140
+ return check;
141
+ }
142
+
143
+ // A refusal of cleanServer, in the room's language: its slots filled, a command's reason too.
144
+ const said = ({ error, errorArgs = {} }) => t(error, { ...errorArgs, ...(errorArgs.why ? { why: t(errorArgs.why, errorArgs.whyArgs ?? {}) } : {}) });
145
+ const refuseFromPhone = (viaPhone) => (viaPhone ? { status: 403, body: { error: t('MCP servers are connected from the computer, not from the phone.') } } : null);
146
+ // Sign-ins in flight: the state the server will send back, and what is needed to finish.
147
+ const pending = new Map(); // state -> { id, verifier, clientId, redirectUri, authorization, resource, at }
148
+
149
+ export default defineModule({
150
+ id: 'mcp',
151
+ name: 'Outside tools (MCP)',
152
+ vendor: 'MADRE',
153
+ version: '1.0.0',
154
+ summary: 'MCP servers you add to this project, for its agents: a command on this computer or an address on the internet. Each one is started and checked before any agent can use it; tools that read work from #1, tools that act from the mode you choose.',
155
+ creates: ['the list of servers, kept in MADRE\'s own folder for this project, never inside the project', 'its secrets in MADRE\'s vault, never in the list'],
156
+ requires: ['nothing; a server is checked when you add it'],
157
+ card: 'settings',
158
+ settings: { enabled: true },
159
+ async status(ctx) {
160
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
161
+ const ready = servers.filter((one) => one.enabled && !one.problem && one.tools?.length).length;
162
+ return { status: { installed: true, detail: servers.length ? t('{ready} of {total} tools ready', { ready, total: servers.length }) : t('no tools yet') } };
163
+ },
164
+ async toolsForTurn(ctx, turn) {
165
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
166
+ return servers.map((server) => serverForTurn(server, { stateRoot: ctx.stateRoot, projectRoot: ctx.projectRoot, agent: turn.agent, mode: Number(turn.mode) })).filter(Boolean);
167
+ },
168
+ routes: [
169
+ { method: 'GET', path: '/api/x/mcp/servers', async handler(ctx) { return { status: 200, body: { servers: await publicServers(ctx) } }; } },
170
+ {
171
+ method: 'POST', path: '/api/x/mcp/servers',
172
+ async handler(ctx, { payload, viaPhone }) {
173
+ const refused = refuseFromPhone(viaPhone); if (refused) return refused;
174
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
175
+ const before = servers.find((one) => one.id === payload?.id);
176
+ const made = cleanServer({ ...payload, replace: Boolean(payload?.replace && before) }, { existing: servers });
177
+ if (made.error) return { status: 400, body: { error: said(made) } };
178
+ const kept = await keepSecrets(ctx.stateRoot, made.secrets);
179
+ if (!kept.ok) return { status: 400, body: { error: t(kept.error) } };
180
+ // An edit keeps what the check learned until it is checked again.
181
+ const server = before ? { ...made.server, tools: before.tools, server: before.server } : made.server;
182
+ await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: [...servers.filter((one) => one.id !== server.id), server] });
183
+ await ctx.record('mcp.connected', { id: server.id, transport: server.transport, auth: server.auth });
184
+ const check = server.auth === 'oauth' && !(await readOAuth(ctx.stateRoot, server.id))?.access_token ? { ok: false, needsAuth: true } : await saveChecked(ctx, server);
185
+ return { status: 200, body: { check, servers: await publicServers(ctx) } };
186
+ },
187
+ },
188
+ {
189
+ method: 'POST', path: /^\/api\/x\/mcp\/servers\/([a-z][a-z0-9-]{0,30})\/(check|toggle|actions|agents|remove)$/,
190
+ async handler(ctx, { payload, params: [id, what], viaPhone }) {
191
+ const refused = refuseFromPhone(viaPhone); if (refused) return refused;
192
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
193
+ const server = servers.find((one) => one.id === id);
194
+ if (!server) return { status: 404, body: { error: t('That server is not connected in this project.') } };
195
+ if (what === 'check') return { status: 200, body: { check: await saveChecked(ctx, server), servers: await publicServers(ctx) } };
196
+ if (what === 'remove') {
197
+ await forgetServerSecrets(ctx.stateRoot, server);
198
+ await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: servers.filter((one) => one.id !== id) });
199
+ await ctx.record('mcp.removed', { id });
200
+ return { status: 200, body: { servers: await publicServers(ctx) } };
201
+ }
202
+ const change = what === 'toggle' ? { enabled: payload?.enabled === true }
203
+ : what === 'actions' ? { actions: ACTION_MODES.includes(Number(payload?.actions)) ? Number(payload.actions) : server.actions }
204
+ : { agents: Array.isArray(payload?.agents) && payload.agents.length ? payload.agents.filter((one) => /^[a-z0-9_-]+$/.test(one)) : null };
205
+ await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: servers.map((one) => (one.id === id ? { ...one, ...change } : one)) });
206
+ return { status: 200, body: { servers: await publicServers(ctx) } };
207
+ },
208
+ },
209
+ // What the person's other apps already have: names, places and what kind; never a value.
210
+ {
211
+ method: 'GET', path: '/api/x/mcp/import',
212
+ async handler(ctx, { viaPhone }) {
213
+ const refused = refuseFromPhone(viaPhone); if (refused) return refused;
214
+ const found = await importable({ projectRoot: ctx.projectRoot });
215
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
216
+ return { status: 200, body: { found: found.map((one, index) => ({ index, source: one.source, name: one.name, id: one.id, transport: one.transport, where: one.url ?? one.command?.join(' ') ?? '', env: Object.keys(one.env ?? {}), headers: Object.keys(one.headers ?? {}), disabled: one.disabled, taken: servers.some((server) => server.id === one.id) })) } };
217
+ },
218
+ },
219
+ {
220
+ method: 'POST', path: '/api/x/mcp/import',
221
+ async handler(ctx, { payload, viaPhone }) {
222
+ const refused = refuseFromPhone(viaPhone); if (refused) return refused;
223
+ const found = await importable({ projectRoot: ctx.projectRoot });
224
+ const picks = (Array.isArray(payload?.picks) ? payload.picks : []).map(Number).filter((index) => Number.isInteger(index) && found[index]);
225
+ const results = [];
226
+ for (const index of picks) {
227
+ const one = found[index];
228
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
229
+ // Every value an app kept is treated as a secret: the vault is the only place it goes.
230
+ const quote = (word) => (/[\s"'\\$`|&;<>()*?[#~]/.test(word) ? `'${String(word).replace(/'/g, '')}'` : word);
231
+ const made = cleanServer({
232
+ id: one.id, label: one.name, transport: one.transport, url: one.url, auth: one.url && Object.keys(one.headers ?? {}).some((name) => name.toLowerCase() === 'authorization') ? 'none' : 'none',
233
+ command: one.command ? one.command.map(quote).join(' ') : '',
234
+ env: Object.entries(one.env ?? {}).map(([name, value]) => ({ name, value: String(value), secret: true })),
235
+ headers: Object.entries(one.headers ?? {}).map(([name, value]) => ({ name, value: String(value), secret: true })),
236
+ enabled: !one.disabled,
237
+ }, { existing: servers });
238
+ if (made.error) { results.push({ id: one.id, ok: false, problem: said(made) }); continue; }
239
+ const kept = await keepSecrets(ctx.stateRoot, made.secrets);
240
+ if (!kept.ok) { results.push({ id: one.id, ok: false, problem: t(kept.error) }); continue; }
241
+ await writeMcp(ctx.stateRoot, ctx.projectRoot, { servers: [...servers, made.server] });
242
+ await ctx.record('mcp.connected', { id: made.server.id, transport: made.server.transport, auth: made.server.auth, from: one.source });
243
+ const check = await saveChecked(ctx, made.server);
244
+ results.push({ id: made.server.id, ok: check.ok, problem: check.problem, needsAuth: check.needsAuth });
245
+ }
246
+ return { status: 200, body: { results, servers: await publicServers(ctx) } };
247
+ },
248
+ },
249
+ // OAuth: the page asks for the address to open; the server comes back to the callback below.
250
+ {
251
+ method: 'POST', path: /^\/api\/x\/mcp\/servers\/([a-z][a-z0-9-]{0,30})\/signin$/,
252
+ async handler(ctx, { params: [id], viaPhone, url }) {
253
+ const refused = refuseFromPhone(viaPhone); if (refused) return refused;
254
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
255
+ const server = servers.find((one) => one.id === id && one.transport === 'http');
256
+ if (!server) return { status: 404, body: { error: t('That server is not connected in this project.') } };
257
+ // MADRE's own process reaches this server to sign in: the list of what left the machine
258
+ // names it as the server the person connected, not as an address nothing declares.
259
+ declareDestinations('mcp', [{ id: server.id, host: new URL(server.url).hostname, to: t('{name} · an MCP server you connected', { name: server.label }), what: t('the sign-in: how to sign in, the registration of MADRE and the token'), when: t('when you press Sign in on it'), where: t('Settings › Outside tools (MCP)') }]);
260
+ const found = await discoverOAuth(server.url).catch((error) => ({ ok: false, problem: error.message }));
261
+ if (!found.ok) return { status: 409, body: { error: t(found.problem) } };
262
+ const redirectUri = `${url.origin}/api/x/mcp/oauth/callback`;
263
+ const client = await registerClient(found.authorization, redirectUri).catch((error) => ({ ok: false, problem: error.message }));
264
+ if (!client.ok) return { status: 409, body: { error: t('The server did not let MADRE register: {problem}', { problem: client.problem }) } };
265
+ const { verifier, challenge } = pkce();
266
+ const state = randomBytes(18).toString('hex');
267
+ for (const [key, one] of pending) if (Date.now() - one.at > 15 * 60000) pending.delete(key);
268
+ pending.set(state, { id, verifier, clientId: client.clientId, redirectUri, authorization: found.authorization, resource: found.resource, at: Date.now() });
269
+ return { status: 200, body: { open: authorizeUrl({ authorization: found.authorization, clientId: client.clientId, redirectUri, challenge, state, resource: found.resource, scopes: found.scopes }) } };
270
+ },
271
+ },
272
+ {
273
+ method: 'GET', path: '/api/x/mcp/oauth/callback',
274
+ async handler(ctx, { url }) {
275
+ const page = (title, text) => ({ status: 200, html: `<!doctype html><meta charset="utf-8"><title>MADRE</title><body style="font:15px system-ui;margin:12vh auto;max-width:34em;padding:0 16px;color:#1d2321"><h1 style="font-size:20px">${title}</h1><p>${text}</p></body>` });
276
+ const state = url.searchParams.get('state') ?? '';
277
+ const flow = pending.get(state);
278
+ pending.delete(state);
279
+ if (!flow) return page(t('This sign-in expired'), t('Sign in again from Settings › Outside tools (MCP).'));
280
+ if (url.searchParams.get('error')) return page(t('The sign-in was not completed'), t('The server said: {problem}', { problem: String(url.searchParams.get('error_description') ?? url.searchParams.get('error')).replace(/[<>&]/g, '') }));
281
+ const done = await exchangeCode({ authorization: flow.authorization, clientId: flow.clientId, redirectUri: flow.redirectUri, code: url.searchParams.get('code') ?? '', verifier: flow.verifier, resource: flow.resource }).catch((error) => ({ ok: false, problem: error.message }));
282
+ if (!done.ok) return page(t('The sign-in was not completed'), t('The server said: {problem}', { problem: String(done.problem).replace(/[<>&]/g, '') }));
283
+ await keepOAuth(ctx.stateRoot, flow.id, { ...grantFrom(done.tokens), token_endpoint: flow.authorization.token_endpoint, client_id: flow.clientId, resource: flow.resource });
284
+ const { servers } = await readMcp(ctx.stateRoot, ctx.projectRoot);
285
+ const server = servers.find((one) => one.id === flow.id);
286
+ const check = server ? await saveChecked(ctx, server) : { ok: false };
287
+ return page(check.ok ? t('Connected') : t('Signed in'), check.ok ? t('{name} is connected to this project with {n} tools. You can close this tab and go back to MADRE.', { name: server?.label ?? flow.id, n: check.tools.length }) : t('You are signed in, but the server did not answer yet: {problem}. Check it again from Settings › Outside tools (MCP).', { problem: String(check.problem ?? '').replace(/[<>&]/g, '') }));
288
+ },
289
+ },
290
+ ],
291
+ });
292
+
293
+ export { SERVER_ID };
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // OLLAMA: local intelligence. Embeddings and distillation on this machine when
2
4
  // Ollama runs. The server offers the wiring through ctx.services.ollama.
3
5
 
@@ -5,6 +7,15 @@ import { defineModule } from './sdk.mjs';
5
7
  import { t } from '../i18n.mjs';
6
8
  import { findOnPath } from './helpers.mjs';
7
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
+ }
8
19
 
9
20
  // Ollama is not an npm package, so each system has its own way in. Where MADRE can run it, the
10
21
  // command is shown on the button before it runs; where it cannot, it hands over the download.
@@ -49,7 +60,7 @@ export default defineModule({
49
60
  name: 'Local AI',
50
61
  vendor: 'MADRE · LOCAL INTELLIGENCE',
51
62
  tracks: { name: 'ollama', github: 'ollama/ollama' },
52
- summary: 'Recall by meaning and memory distillation on this machine, through Ollama: no provider tokens, nothing leaves.',
63
+ summary: 'Searches and sums up the project\'s memory with local AI (Ollama): free, and nothing leaves your computer.',
53
64
  creates: ['nothing in the project', 'a block in ~/.pulse/config.json', 'models in Ollama\'s own store when you press PULL'],
54
65
  requires: ['Ollama running (the app, or ollama serve)', 'an embedding model and a chat model \u00b7 MADRE can pull the recommended ones'],
55
66
  settings: { enabled: true, embeddings: true, archivist: true, agent: true },
@@ -71,7 +82,7 @@ export default defineModule({
71
82
  models: probe.models.map((model) => model.name),
72
83
  status: { installed: settings.enabled && project.on && probe.running && roles.length > 0, detail },
73
84
  ollama: { ...view, project },
74
- recommended: RECOMMENDED,
85
+ recommended: await recommendedHere(),
75
86
  preflight: probe.running ? { ok: true, problems: [] } : { ok: false, problems: ['Ollama is not running: open the Ollama app or run `ollama serve`, then RECHECK.'] },
76
87
  install: { display: settings.enabled ? 'disable Ollama' : 'enable Ollama (config.json)', platforms: [] },
77
88
  };
@@ -101,8 +112,8 @@ export default defineModule({
101
112
  return { status: 200, body: { enabled, ollama: status } };
102
113
  },
103
114
  routes: [
104
- { 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 } }) },
105
- { 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() } }) },
106
117
  { method: 'POST', path: '/api/ollama/settings', handler: async (ctx, { payload }) => {
107
118
  const next = { ...(ctx.config.modules?.ollama ?? {}) };
108
119
  for (const key of ['embeddings', 'archivist', 'agent', 'enabled']) if (typeof payload[key] === 'boolean') next[key] = payload[key];