@jossuealcala/madre 1.0.0 → 1.0.2

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 (174) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +1945 -0
  3. package/CHANGELOG.md +78 -1
  4. package/CONTRIBUTING.md +5 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +34 -3
  7. package/README.en.md +245 -0
  8. package/README.md +23 -13
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +82 -0
  11. package/bin/madre.mjs +2 -0
  12. package/docs/INTERNALS.md +8 -2
  13. package/docs/REFERENCE.md +17 -7
  14. package/docs/en/INTERNALS.md +103 -0
  15. package/docs/en/REFERENCE.md +339 -0
  16. package/docs/en/SDK.md +306 -0
  17. package/docs/en/training/README.md +59 -0
  18. package/docs/madre-banner.png +0 -0
  19. package/docs/training/README.md +20 -8
  20. package/docs/training/train.sh +2 -1
  21. package/package.json +14 -5
  22. package/public/abduction.js +2 -0
  23. package/public/app.js +2066 -488
  24. package/public/attribution.js +71 -0
  25. package/public/brands.js +3 -1
  26. package/public/editor.js +2 -0
  27. package/public/es.js +1706 -1226
  28. package/public/i18n.js +25 -1
  29. package/public/index.html +13 -10
  30. package/public/inquiry.js +2 -0
  31. package/public/loader.js +2 -0
  32. package/public/macroverso.js +24 -2
  33. package/public/nostromo-engine.js +2 -0
  34. package/public/resay.js +6 -0
  35. package/public/review-ask.js +2 -0
  36. package/public/routing.js +23 -1
  37. package/public/salas-mac.css +539 -28
  38. package/public/salas-mac.html +30 -22
  39. package/public/salas-mac.js +1498 -144
  40. package/public/salas.css +2 -0
  41. package/public/salas.html +1 -0
  42. package/public/salas.js +4 -2
  43. package/public/styles.css +1123 -20
  44. package/public/syntax.js +2 -0
  45. package/public/touch.js +2 -0
  46. package/public/tour-chapters.js +164 -52
  47. package/public/tour.js +85 -12
  48. package/public/troubleshooting.js +20 -18
  49. package/src/adapters/claude.mjs +2 -0
  50. package/src/adapters/codex.mjs +2 -0
  51. package/src/adapters/gemini.mjs +3 -1
  52. package/src/adapters/madre.mjs +3 -1
  53. package/src/adapters/opencode.mjs +7 -3
  54. package/src/adapters/process.mjs +2 -0
  55. package/src/asking.mjs +2 -0
  56. package/src/auth-probe.mjs +32 -7
  57. package/src/bridge/devices.mjs +2 -0
  58. package/src/bridge/handoff.mjs +2 -0
  59. package/src/bridge/host.mjs +2 -0
  60. package/src/bridge/network.mjs +2 -0
  61. package/src/bridge/policy.mjs +3 -1
  62. package/src/bridge/qr.mjs +2 -0
  63. package/src/bridge/server.mjs +2 -0
  64. package/src/capabilities.mjs +2 -0
  65. package/src/chats.mjs +2 -0
  66. package/src/checkpoint.mjs +2 -0
  67. package/src/choices.mjs +2 -0
  68. package/src/cli-args.mjs +2 -0
  69. package/src/cold.mjs +2 -0
  70. package/src/commands.mjs +2 -0
  71. package/src/config.mjs +2 -0
  72. package/src/conversation-context.mjs +15 -1
  73. package/src/credentials.mjs +112 -3
  74. package/src/dataset.mjs +2 -0
  75. package/src/directives.mjs +8 -6
  76. package/src/distiller.mjs +2 -0
  77. package/src/embeddings.mjs +2 -0
  78. package/src/enlaces.mjs +6 -4
  79. package/src/event-store.mjs +2 -0
  80. package/src/exam.mjs +6 -4
  81. package/src/extensions.mjs +33 -10
  82. package/src/eyecat-watch.mjs +2 -0
  83. package/src/eyecat.mjs +2 -0
  84. package/src/file-ops.mjs +2 -0
  85. package/src/files.mjs +32 -0
  86. package/src/github.mjs +2 -0
  87. package/src/heartbeat.mjs +57 -0
  88. package/src/i18n.mjs +15 -1
  89. package/src/image-studio.mjs +2 -0
  90. package/src/json-file.mjs +2 -0
  91. package/src/launch.mjs +2 -0
  92. package/src/lease.mjs +5 -1
  93. package/src/lobby-work.mjs +2 -0
  94. package/src/local-ai.mjs +50 -0
  95. package/src/looks.mjs +40 -5
  96. package/src/maturity.mjs +3 -1
  97. package/src/mcp/connect.mjs +345 -0
  98. package/src/mcp/github-server.mjs +2 -0
  99. package/src/mcp/image-server.mjs +2 -0
  100. package/src/mcp/memory-server.mjs +2 -0
  101. package/src/mcp/proxy.mjs +153 -0
  102. package/src/mcp/smtp-server.mjs +2 -0
  103. package/src/memory-dashboard.mjs +2 -0
  104. package/src/memory-tools.mjs +2 -0
  105. package/src/memory.mjs +2 -0
  106. package/src/models.mjs +83 -3
  107. package/src/modules/ahp.mjs +2 -0
  108. package/src/modules/ash-policy.mjs +2 -0
  109. package/src/modules/ash.mjs +3 -1
  110. package/src/modules/bishop-plan.mjs +5 -3
  111. package/src/modules/bishop.mjs +13 -11
  112. package/src/modules/choices.mjs +2 -0
  113. package/src/modules/correo.mjs +2 -0
  114. package/src/modules/enlaces.mjs +2 -0
  115. package/src/modules/git-pulse.mjs +2 -0
  116. package/src/modules/github.mjs +2 -0
  117. package/src/modules/helpers.mjs +2 -0
  118. package/src/modules/image-studio.mjs +3 -1
  119. package/src/modules/index.mjs +4 -1
  120. package/src/modules/intents-calibration.mjs +2 -0
  121. package/src/modules/intents-desk.mjs +4 -2
  122. package/src/modules/intents-observe.mjs +2 -0
  123. package/src/modules/intents-policy.mjs +4 -1
  124. package/src/modules/intents-try.mjs +2 -0
  125. package/src/modules/intents.mjs +8 -6
  126. package/src/modules/mcp-connect.mjs +293 -0
  127. package/src/modules/ollama.mjs +17 -3
  128. package/src/modules/playwright.mjs +25 -10
  129. package/src/modules/ripley.mjs +2 -0
  130. package/src/modules/runs.mjs +5 -3
  131. package/src/modules/sdk.mjs +2 -0
  132. package/src/modules/updates.mjs +2 -0
  133. package/src/mother.mjs +2 -0
  134. package/src/ollama.mjs +2 -0
  135. package/src/outbound.mjs +24 -5
  136. package/src/override.mjs +2 -0
  137. package/src/ports.mjs +2 -0
  138. package/src/prices.mjs +2 -0
  139. package/src/privacy.mjs +2 -0
  140. package/src/quota-monitor.mjs +2 -0
  141. package/src/quota-sources.mjs +2 -0
  142. package/src/request-guard.mjs +2 -0
  143. package/src/review-ask.mjs +2 -0
  144. package/src/room/archivist.mjs +2 -0
  145. package/src/room/attachments.mjs +2 -0
  146. package/src/room/budget.mjs +2 -0
  147. package/src/room/context.mjs +2 -0
  148. package/src/room/control.mjs +7 -1
  149. package/src/room/copilot.mjs +76 -0
  150. package/src/room/economy.mjs +2 -0
  151. package/src/room/escalation.mjs +3 -0
  152. package/src/room/ghost.mjs +2 -0
  153. package/src/room/guard.mjs +2 -0
  154. package/src/room/prompt.mjs +13 -4
  155. package/src/room/returns.mjs +82 -0
  156. package/src/room/runs.mjs +4 -0
  157. package/src/room/vectors.mjs +2 -0
  158. package/src/room.mjs +264 -45
  159. package/src/router.mjs +2 -0
  160. package/src/runs.mjs +2 -0
  161. package/src/runtime-detection.mjs +2 -0
  162. package/src/salas.mjs +2 -0
  163. package/src/sentinel-errors.mjs +2 -0
  164. package/src/server-sentinel.mjs +2 -0
  165. package/src/server.mjs +485 -55
  166. package/src/setup.mjs +2 -0
  167. package/src/smtp.mjs +2 -0
  168. package/src/tour.mjs +26 -9
  169. package/src/updates.mjs +2 -0
  170. package/src/usage-sentinel.mjs +2 -0
  171. package/src/vault.mjs +2 -0
  172. package/src/verdict.mjs +3 -1
  173. package/src/whats-new.mjs +12 -10
  174. package/docs/madre-banner.svg +0 -25
package/src/choices.mjs CHANGED
@@ -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
  // An agent asking the human to choose.
2
4
  //
3
5
  // The room already has one fenced channel, ```pulse, and it puts other agents to work. This is
package/src/cli-args.mjs CHANGED
@@ -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
  // Tiny argv reader for the MADRE CLI. Accepts `--name value` and
2
4
  // `--name=value`, and remembers which options the user set explicitly so the
3
5
  // room can choose sensible defaults (like the next free port) only when they
package/src/cold.mjs CHANGED
@@ -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
  // Cold zones: the part of the archive nothing has ever reached for.
2
4
  //
3
5
  // A memory is cold when three things are true at once, and the three together are what make the
package/src/commands.mjs CHANGED
@@ -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
  import { execFile } from 'node:child_process';
2
4
  import { access } from 'node:fs/promises';
3
5
  import { join } from 'node:path';
package/src/config.mjs CHANGED
@@ -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
  import { randomUUID } from 'node:crypto';
2
4
  import { copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
5
  import { homedir } from 'node:os';
@@ -1,3 +1,17 @@
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 a command's output may weigh in the shared window, and which part of it. It used to be the
4
+ // first 6000 characters: five outputs filled the 16 000 of the window, the request that asked for
5
+ // them went out of it, and the agent lost the thread (2026-10-14). Now it is the start and the end
6
+ // — a test run says what it is at the top and how it went at the bottom — and the agent that asked
7
+ // gets the fuller output in a turn of its own (src/room/returns.mjs).
8
+ const COMMAND_HEAD = 300;
9
+ const COMMAND_TAIL = 900;
10
+ function commandEnds(text) {
11
+ if (text.length <= COMMAND_HEAD + COMMAND_TAIL) return text;
12
+ return `${text.slice(0, COMMAND_HEAD)}\n… ${text.length - COMMAND_HEAD - COMMAND_TAIL} characters left out …\n${text.slice(-COMMAND_TAIL)}`;
13
+ }
14
+
1
15
  export function messageEntry(event) {
2
16
  if (event.type === 'message.failed') {
3
17
  // Failures are part of the conversation: an orchestrator closing a plan
@@ -9,7 +23,7 @@ export function messageEntry(event) {
9
23
  if (event.type === 'command.output') {
10
24
  const { name, title, text } = event.payload;
11
25
  if (typeof text !== 'string' || !text.trim()) return null;
12
- return { sequence: event.sequence, messageId: `${event.id}:command`, role: 'command', sender: `/${name}`, target: 'room', text: `${title}\n${text.trim()}`.slice(0, 6000) };
26
+ return { sequence: event.sequence, messageId: `${event.id}:command`, role: 'command', sender: `/${name}`, target: 'room', text: `${title}\n${commandEnds(text.trim())}` };
13
27
  }
14
28
  if (event.type !== 'message.created') return null;
15
29
  const { messageId, role, sender, target, text } = event.payload;
@@ -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
  // Handing a provider key to a CLI that signs in from its own prompt.
2
4
  //
3
5
  // MADRE keeps its promise: it never stores a credential of its own. The key the human pastes is
@@ -36,8 +38,46 @@ export const OPENCODE_PROVIDERS = [
36
38
  { id: 'google', label: 'Google AI Studio', keyUrl: 'https://aistudio.google.com/app/apikey' },
37
39
  ];
38
40
 
41
+ // The rest of the providers OpenCode knows (2026-10-14): its own catalogue, models.dev, which it
42
+ // caches itself. Those a single key opens can be wired from MADRE; the ones that need an account
43
+ // id, a region or a second secret stay with `opencode auth login`. Read only, never fetched.
44
+ export const OPENCODE_FEATURED = ['anthropic', 'openai', 'google', 'openrouter', 'groq', 'deepseek', 'mistral', 'xai', 'togetherai', 'fireworks-ai', 'cerebras', 'perplexity', 'huggingface', 'deepinfra', 'moonshotai', 'zai', 'cohere', 'vercel'];
45
+ export async function readOpenCodeCatalog({ home = homedir(), env = process.env } = {}) {
46
+ const raw = await readIfThere(join(env.XDG_CACHE_HOME || join(home, '.cache'), 'opencode', 'models.json'));
47
+ let data = {};
48
+ try { data = JSON.parse(raw ?? '{}') ?? {}; } catch { data = {}; }
49
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return [];
50
+ const out = [];
51
+ for (const [id, one] of Object.entries(data)) {
52
+ if (!/^[\w.-]{1,60}$/.test(id) || !one || typeof one !== 'object' || !Array.isArray(one.env) || one.env.length !== 1) continue;
53
+ out.push({ id, label: typeof one.name === 'string' && one.name.trim() ? one.name.trim().slice(0, 60) : id, docUrl: typeof one.doc === 'string' && /^https:\/\//.test(one.doc) ? one.doc : null });
54
+ }
55
+ return out;
56
+ }
57
+ // Every provider a key can be pasted for: MADRE's own four first (their key pages), then the
58
+ // catalogue's, each once, the popular ones marked.
59
+ export async function openCodeKeyProviders({ home = homedir(), env = process.env } = {}) {
60
+ const catalog = await readOpenCodeCatalog({ home, env });
61
+ const byId = new Map(OPENCODE_PROVIDERS.map((one) => [one.id, { ...one, featured: true }]));
62
+ for (const one of catalog) if (!byId.has(one.id)) byId.set(one.id, { id: one.id, label: one.label, keyUrl: one.docUrl, featured: OPENCODE_FEATURED.includes(one.id) });
63
+ const rank = (one) => { const at = OPENCODE_FEATURED.indexOf(one.id); return at < 0 ? Infinity : at; };
64
+ return [...byId.values()].sort((a, b) => Number(b.featured) - Number(a.featured) || rank(a) - rank(b));
65
+ }
66
+
39
67
  // What the room offers for each agent that cannot be signed in with a click.
40
68
  export function keyPlanFor(id) {
69
+ if (id === 'claude') {
70
+ return {
71
+ agent: 'claude',
72
+ label: 'Anthropic API key',
73
+ keyUrl: 'https://console.anthropic.com/settings/keys',
74
+ writesTo: '~/.claude/settings.json',
75
+ // Said plainly because it is true: this file is the Claude CLI's own, not MADRE's. A key
76
+ // there is the one every Claude Code on this computer will use, MADRE's turns included.
77
+ note: 'The key is written where the Claude CLI looks for it, readable only by you. MADRE keeps no copy. It becomes the account Claude Code uses on this computer, here and in your own terminal, until you remove it.',
78
+ providers: null,
79
+ };
80
+ }
41
81
  if (id === 'gemini') {
42
82
  return {
43
83
  agent: 'gemini',
@@ -99,9 +139,36 @@ async function applyGeminiKey(key, { home, probe }) {
99
139
  }
100
140
  }
101
141
 
142
+ // ---- Claude: `env.ANTHROPIC_API_KEY` in its own settings, confirmed by `claude auth status`.
143
+ // The CLI reads that env block before it runs, which is why a key there is a session: its own
144
+ // status answers `api_key` afterwards. Everything else in the file is left as it was.
145
+ async function applyClaudeKey(key, { home, probe }) {
146
+ const dir = join(home, '.claude');
147
+ const file = join(dir, 'settings.json');
148
+ await mkdir(dir, { recursive: true });
149
+ const before = await readIfThere(file);
150
+ try {
151
+ let settings = {};
152
+ try { settings = JSON.parse(before ?? '{}') ?? {}; } catch { settings = {}; }
153
+ if (typeof settings !== 'object' || Array.isArray(settings)) settings = {};
154
+ settings.env = { ...(settings.env ?? {}), ANTHROPIC_API_KEY: key };
155
+ await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, { mode: OWNER_ONLY });
156
+ await chmod(file, OWNER_ONLY).catch(() => {});
157
+ const state = await probe();
158
+ if (state?.state !== 'signed-in') {
159
+ await restore(file, before);
160
+ return { ok: false, error: `The key was written but Claude still reads as ${state?.state ?? 'unknown'}${state?.detail ? ` (${state.detail})` : ''}. Nothing was changed.` };
161
+ }
162
+ return { ok: true, detail: state.detail ?? 'via API key' };
163
+ } catch (error) {
164
+ await restore(file, before);
165
+ return { ok: false, error: `The key could not be written: ${error.message}` };
166
+ }
167
+ }
168
+
102
169
  // ---- OpenCode: an entry in its auth.json, confirmed by asking `opencode auth list`.
103
170
  async function applyOpenCodeKey(key, { home, provider, executable, run }) {
104
- const known = OPENCODE_PROVIDERS.some((item) => item.id === provider);
171
+ const known = typeof provider === 'string' ? (await openCodeKeyProviders({ home })).find((item) => item.id === provider) : null;
105
172
  if (!known) return { ok: false, error: 'Pick the provider this key belongs to.' };
106
173
  const file = join(home, '.local', 'share', 'opencode', 'auth.json');
107
174
  await mkdir(join(home, '.local', 'share', 'opencode'), { recursive: true });
@@ -114,8 +181,9 @@ async function applyOpenCodeKey(key, { home, provider, executable, run }) {
114
181
  await chmod(file, OWNER_ONLY).catch(() => {});
115
182
  // OpenCode itself is the judge: if its own listing does not show the provider, the shape
116
183
  // MADRE wrote is not the shape it reads, and nothing should be left behind.
117
- const listed = await run(executable, ['auth', 'list']);
118
- if (!new RegExp(provider, 'i').test(listed)) {
184
+ // It lists the provider by its name («Together AI»), sometimes by its id.
185
+ const listed = String(await run(executable, ['auth', 'list'])).toLowerCase();
186
+ if (!listed.includes(provider.toLowerCase()) && !listed.includes(String(known.label).toLowerCase())) {
119
187
  await restore(file, before);
120
188
  return { ok: false, error: `OpenCode does not list ${provider} after the key was written, so MADRE put the file back. Run \`opencode auth login\` in a terminal for this one.` };
121
189
  }
@@ -139,7 +207,48 @@ const runOpenCode = async (executable, args) => {
139
207
  export async function applyKey({ agent, key, provider = null, home = homedir(), probe, executable = 'opencode', run = runOpenCode }) {
140
208
  const checked = normalizeKey(key);
141
209
  if (!checked.ok) return checked;
210
+ if (agent === 'claude') return applyClaudeKey(checked.key, { home, probe });
142
211
  if (agent === 'gemini') return applyGeminiKey(checked.key, { home, probe });
143
212
  if (agent === 'opencode') return applyOpenCodeKey(checked.key, { home, provider, executable, run });
144
213
  return { ok: false, error: `${agent} does not take a key here: it signs in with a click.` };
145
214
  }
215
+
216
+ // ---- OpenCode's providers, as its auth.json says them: who is signed in and how, never the key.
217
+ // `api` is a key (MADRE's or one pasted in a terminal), `oauth` an account signed in from
218
+ // `opencode auth login`. Anything unreadable reads as nobody.
219
+ export async function readOpenCodeProviders({ home = homedir() } = {}) {
220
+ const raw = await readIfThere(join(home, '.local', 'share', 'opencode', 'auth.json'));
221
+ let auth = {};
222
+ try { auth = JSON.parse(raw ?? '{}') ?? {}; } catch { auth = {}; }
223
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return [];
224
+ return Object.entries(auth)
225
+ .filter(([id, entry]) => /^[\w.-]{1,60}$/.test(id) && entry && typeof entry === 'object')
226
+ .map(([id, entry]) => ({ id, type: entry.type === 'api' || entry.type === 'oauth' ? entry.type : 'other' }));
227
+ }
228
+
229
+ // Takes a provider's key out of OpenCode's auth.json, the other entries untouched. Only a key
230
+ // (`api`): an account signed in with OpenCode's own login is signed out where it was signed in.
231
+ // OpenCode is the judge again: if it still lists the provider, the file goes back as it was.
232
+ export async function removeOpenCodeKey({ provider, home = homedir(), executable = 'opencode', run = runOpenCode } = {}) {
233
+ if (typeof provider !== 'string' || !/^[\w.-]{1,60}$/.test(provider)) return { ok: false, error: 'Pick the provider whose key goes.' };
234
+ const file = join(home, '.local', 'share', 'opencode', 'auth.json');
235
+ const before = await readIfThere(file);
236
+ let auth = {};
237
+ try { auth = JSON.parse(before ?? '{}') ?? {}; } catch { return { ok: false, error: 'OpenCode\'s auth.json could not be read, so nothing was changed.' }; }
238
+ if (!auth[provider]) return { ok: false, error: `OpenCode has no key for ${provider}.` };
239
+ if (auth[provider].type !== 'api') return { ok: false, error: `${provider} is signed in with its account, not a key: sign it out with \`opencode auth logout\` in a terminal.` };
240
+ try {
241
+ delete auth[provider];
242
+ await writeFile(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: OWNER_ONLY });
243
+ await chmod(file, OWNER_ONLY).catch(() => {});
244
+ const listed = await run(executable, ['auth', 'list']);
245
+ if (new RegExp(`\\b${provider.replace(/[.-]/g, '\\$&')}\\b`, 'i').test(listed.replace(/auth\.json/gi, ''))) {
246
+ await restore(file, before);
247
+ return { ok: false, error: `OpenCode still lists ${provider}, so MADRE put the file back. Remove it with \`opencode auth logout\` in a terminal.` };
248
+ }
249
+ return { ok: true };
250
+ } catch (error) {
251
+ await restore(file, before);
252
+ return { ok: false, error: `The key could not be removed: ${error.message}` };
253
+ }
254
+ }
package/src/dataset.mjs CHANGED
@@ -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
  // The dataset behind MADRE AI: every exchange the room kept (never GHOST),
2
4
  // redacted, as chat pairs a small model can learn from, plus the distilled
3
5
  // notes. Written next to the room's ledger in the format mlx-lm and most
@@ -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
  // Agent-to-agent delegation. An agent answering in the room may end its reply
2
4
  // with a fenced block that puts other agents to work, in order:
3
5
  //
@@ -9,9 +11,9 @@
9
11
  //
10
12
  // MADRE runs the steps sequentially as regular room turns, each recorded,
11
13
  // each subject to handoff, budget and timeout, and each stoppable by the
12
- // human. A step addressed to the orchestrator itself becomes its closing
13
- // turn once the others have answered. Delegated agents cannot delegate
14
- // further (depth is capped at one), so a plan always terminates.
14
+ // human. The orchestrator gets a closing turn after the others have answered.
15
+ // Delegated agents cannot delegate further (depth is capped at one); the
16
+ // orchestrator may start another bounded wave after reviewing their results.
15
17
 
16
18
  export const DELEGATION_HELP = (self, others, maxSteps) => [
17
19
  `You may put other agents to work: ${others.map((id) => `@${id}`).join(', ')}.`,
@@ -21,8 +23,8 @@ export const DELEGATION_HELP = (self, others, maxSteps) => [
21
23
  `@${others[0] ?? 'codex'}: <question for that agent>`,
22
24
  `@${self}: <what you will do with their answers, optional closing turn for you>`,
23
25
  '```',
24
- 'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE caps it at your own mode and at that agent\'s MAX MODE. Without a number a step inherits the plan\'s mode.',
25
- `MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you the closing turn if you asked for one.`,
26
+ 'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE uses the human permission for this turn as the ceiling. A higher mode pauses for the human to unlock it for that agent, and #4 always does. Without a number a step only reads, or creates files when the plan may create; ask for #3 by name.',
27
+ `MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you a closing turn to review progress. If work remains within the human permission, you may end that turn with another pulse plan; at most three waves run from one request.`,
26
28
  'The plan block must be the very last thing in your reply: nothing after it, not even a closing sentence. Say everything else before it.',
27
29
  'Address each agent once. Do not delegate what you can answer yourself.',
28
30
  ].join('\n');
@@ -47,7 +49,7 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
47
49
  const line = raw.trim();
48
50
  if (!line || line.startsWith('#')) continue;
49
51
  // "@agent: text" or "@agent #2: text": the mode is a request, capped by the plan's ceiling and the agent's MAX MODE.
50
- const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-3]))?\s*[::]\s*(.+)$/i);
52
+ const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-4]))?\s*[::]\s*(.+)$/i);
51
53
  if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text" or "@agent #2: text")' }); continue; }
52
54
  const agent = match[1].toLowerCase();
53
55
  const instruction = match[3].trim();
package/src/distiller.mjs CHANGED
@@ -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
  // The archivist's turn: every so often the cheapest agent in the room reads
2
4
  // the exchanges nobody has distilled yet and writes the few notes worth
3
5
  // keeping for every later turn. Pure functions here; the room schedules and
@@ -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
  // Embeddings for the room memory: meaning-aware recall on top of the lexical
2
4
  // index. Gemini's embedding model through the user's own key (the same key
3
5
  // Image Studio and the Gemini CLI use); no key, no embeddings, and recall stays
package/src/enlaces.mjs CHANGED
@@ -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
  // Links between rooms (docs/MACROVERSO.md, phase 3b): the contract two rooms sign, the key that
2
4
  // proves a caller is a MADRE process on this Mac, the side that asks, and the counters of what
3
5
  // crossed. A link reads; nothing here writes in another room.
@@ -108,7 +110,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
108
110
  if (!Array.isArray(raw.kinds) || !raw.kinds.length) fail('A connection names the kinds that may cross.');
109
111
  const kinds = [...new Set(raw.kinds.map(String))];
110
112
  for (const kind of kinds) {
111
- if (kind === 'aberration') fail('An aberration never crosses a connection.');
113
+ if (kind === 'aberration') fail('A doubtful memory never crosses a connection.');
112
114
  if (!LINK_KINDS.includes(kind)) fail(`"${kind}" is not a kind of memory.`);
113
115
  }
114
116
  kinds.sort((x, y) => LINK_KINDS.indexOf(x) - LINK_KINDS.indexOf(y));
@@ -124,7 +126,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
124
126
  const revoked = isoOrNull(raw.revoked, 'revoked');
125
127
  // Fase 3e: the room that reads also gets, on each turn, up to RECALL_NOTES of these notes without
126
128
  // asking. Off unless the human ticks it; a reading like any other, counted against `perHour`.
127
- if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('recall is on or off.');
129
+ if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('automatic recall can only be on or off.');
128
130
  const recall = raw.recall === true;
129
131
 
130
132
  const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
@@ -136,8 +138,8 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
136
138
  const allowed = Boolean(orders.allowed);
137
139
  if (salas) {
138
140
  const serves = direction === 'both' ? [a, b] : direction === 'a→b' ? [a] : [b];
139
- if (serves.includes(salas)) fail('The Coordinator\'s project never serves a connection: the Coordinator gathers every project and is nobody\'s source.');
140
- if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator\'s project at one end of the connection.');
141
+ if (serves.includes(salas)) fail('The Coordinator never shares through a connection: it gathers every project and is nobody\'s source.');
142
+ if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator at one end of the connection.');
141
143
  }
142
144
 
143
145
  // Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
@@ -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
  import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
4
  import { dirname, join } from 'node:path';
3
5
  import { randomUUID } from 'node:crypto';
package/src/exam.mjs CHANGED
@@ -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
  // The three tests that answer "is this room ready to be developed with?"
2
4
  //
3
5
  // The maturity reading counts what the archive is made of. It cannot tell you whether the archive
@@ -174,9 +176,9 @@ export function consistencyExam({ findings = [], notes = [], now = Date.now() }
174
176
  open, refuted, standing: standing.length, aberrations: aberrations.length, trend,
175
177
  passed,
176
178
  says: open
177
- ? t('EYECAT is holding {n} contradictions nobody has settled.', { n: open })
179
+ ? (open === 1 ? t('The memory reviewer found 1 contradiction nobody has settled.') : t('The memory reviewer found {n} contradictions nobody has settled.', { n: open }))
178
180
  : trend === 'rising'
179
- ? t('Nothing is open, but aberrations are being filed more often lately than they used to be.')
181
+ ? t('Nothing is open, but doubtful memories are being filed more often lately than they used to be.')
180
182
  : t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: standing.length, refuted }),
181
183
  };
182
184
  }
@@ -231,9 +233,9 @@ export function saysFor(result) {
231
233
  }
232
234
  if (result.id === 'consistency' && Number.isFinite(result.open)) {
233
235
  return result.open
234
- ? t('EYECAT is holding {n} contradictions nobody has settled.', { n: result.open })
236
+ ? (result.open === 1 ? t('The memory reviewer found 1 contradiction nobody has settled.') : t('The memory reviewer found {n} contradictions nobody has settled.', { n: result.open }))
235
237
  : result.trend === 'rising'
236
- ? t('Nothing is open, but aberrations are being filed more often lately than they used to be.')
238
+ ? t('Nothing is open, but doubtful memories are being filed more often lately than they used to be.')
237
239
  : t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: result.standing, refuted: result.refuted });
238
240
  }
239
241
  return result.says ?? null;
@@ -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
  import { spawn } from 'node:child_process';
2
4
  import { MODULES, moduleById, describeModules } from './modules/index.mjs';
3
5
  export { gitToplevel, findOnPath } from './modules/helpers.mjs';
@@ -25,38 +27,59 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
25
27
  // Runs one installer inside the project, streaming output lines. `runner`
26
28
  // lets tests substitute the real installer with a local script.
27
29
  // `npm` and other Node CLIs are .cmd shims on Windows, which cannot be spawned directly.
28
- export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env }) {
30
+ //
31
+ // A sign-in is the same spawn with a human in the middle of it, so three knobs exist for it:
32
+ // `stdin: 'pipe'` opens a way back in (a CLI that asks for a pasted code reads it there),
33
+ // `onChild` hands the caller that way in, and `flushIdleMs` emits a line that never ends in a
34
+ // newline — a prompt like `Paste code here > ` — once the output has been still for that long.
35
+ // Without it the prompt sits in the buffer and the room shows a sign-in that says nothing.
36
+ export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
29
37
  return new Promise((resolve) => {
30
- const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
38
+ const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
31
39
  const started = Date.now();
32
40
  let lastOutput = started;
41
+ let buffer = '';
42
+ const emit = (line, stream) => { lastOutput = Date.now(); onLine?.(line, stream); };
33
43
  // Installers go quiet for long stretches (npm install, git status); say so.
34
44
  const heartbeat = setInterval(() => {
45
+ // A half-line waiting on a human is said before the silence is reported, so the room shows
46
+ // the question instead of the heartbeat that would otherwise be the only thing on screen.
47
+ if (flushIdleMs && buffer.trim() && Date.now() - lastOutput >= flushIdleMs) { const partial = buffer.trim(); buffer = ''; emit(partial, 'stdout'); return; }
35
48
  if (Date.now() - lastOutput >= heartbeatMs) {
36
- onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s · installer is quiet (npm install or git status can take a while)`, 'heartbeat');
49
+ onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s · ${quietNote}`, 'heartbeat');
37
50
  lastOutput = Date.now();
38
51
  }
39
- }, Math.min(heartbeatMs, 2000));
52
+ }, Math.min(flushIdleMs || heartbeatMs, heartbeatMs, 2000));
40
53
  heartbeat.unref?.();
41
- let buffer = '';
42
54
  const feed = (chunk, stream) => {
43
55
  buffer += chunk;
44
56
  let index;
45
57
  while ((index = buffer.indexOf('\n')) >= 0) {
46
58
  const line = buffer.slice(0, index).replace(/\r$/, '');
47
59
  buffer = buffer.slice(index + 1);
48
- if (line.trim()) { lastOutput = Date.now(); onLine?.(line, stream); }
60
+ if (line.trim()) emit(line, stream);
49
61
  }
50
62
  };
51
63
  child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
52
64
  child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
53
65
  const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs);
54
- child.on('error', (error) => { clearTimeout(timer); clearInterval(heartbeat); resolve({ code: 1, error: error.code === 'ENOENT' ? `${error.message} (is the project folder present and ${command} on PATH?)` : error.message }); });
66
+ let done = false;
67
+ const settle = (result) => { if (done) return; done = true; clearTimeout(timer); clearInterval(heartbeat); resolve(result); };
68
+ onChild?.({
69
+ // What the room can do to a sign-in while it runs: answer it, or call it off.
70
+ write: (text) => { try { child.stdin?.write(text); return true; } catch { return false; } },
71
+ cancel: () => {
72
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
73
+ // A terminal UI can ignore a polite ask; it does not get to ignore the second one.
74
+ const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 1500);
75
+ hard.unref?.();
76
+ },
77
+ pid: child.pid ?? null,
78
+ });
79
+ child.on('error', (error) => settle({ code: 1, error: error.code === 'ENOENT' ? `${error.message} (is the project folder present and ${command} on PATH?)` : error.message }));
55
80
  child.on('close', (code) => {
56
- clearTimeout(timer);
57
- clearInterval(heartbeat);
58
81
  if (buffer.trim()) onLine?.(buffer.trim(), 'stdout');
59
- resolve({ code: code ?? 1 });
82
+ settle({ code: code ?? 1 });
60
83
  });
61
84
  });
62
85
  }
@@ -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
  // EYECAT, as the room runs it. The judgement lives in eyecat.mjs; this schedules it, picks who
2
4
  // may answer, and puts what comes back in front of the human.
3
5
  //
package/src/eyecat.mjs CHANGED
@@ -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
  // EYECAT: the watcher that asks whether the room still believes what it wrote down.
2
4
  //
3
5
  // Pure functions here. Nothing in this file talks to a model, reads a file or touches the room;
package/src/file-ops.mjs CHANGED
@@ -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
  // Copying and moving a file, from the human's own hands.
2
4
  //
3
5
  // Everything else in MADRE that writes into a project is an agent under a permission mode. This
package/src/files.mjs CHANGED
@@ -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
  import { createHash, randomUUID } from 'node:crypto';
2
4
  import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
3
5
  import { dirname, extname, join, resolve, sep } from 'node:path';
@@ -96,6 +98,36 @@ export async function walkProject(root, { maxFiles = 20000, ttlMs = 15000 } = {}
96
98
  return files;
97
99
  }
98
100
 
101
+ // What changed on disk since a moment, by modification time (2026-10-14). A restore point sees
102
+ // only what git sees, so a PDF a command wrote into an ignored `output/` was there and nobody said
103
+ // so: the Finder did not refresh and nothing told whose it was. This looks at the files
104
+ // themselves, skipping the same heavy folders the search skips and MADRE's own `.pulse`/`.madre`,
105
+ // bounded so a large project costs a fraction of a second. Newest first.
106
+ const UNWATCHED = new Set([...SHALLOW, '.pulse', '.madre']);
107
+ export async function filesChangedSince(root, since, { maxFiles = 8000, limit = 40 } = {}) {
108
+ const from = Number(since) - 250;
109
+ if (!Number.isFinite(from)) return [];
110
+ const found = [];
111
+ let seen = 0;
112
+ async function walk(dir, prefix, depth) {
113
+ if (seen >= maxFiles || depth > 14) return;
114
+ let entries = [];
115
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
116
+ const files = [];
117
+ for (const entry of entries) {
118
+ if (SKIP.has(entry.name)) continue;
119
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
120
+ if (entry.isDirectory()) { if (!UNWATCHED.has(entry.name)) await walk(join(dir, entry.name), rel, depth + 1); continue; }
121
+ if (entry.isFile()) files.push(rel);
122
+ }
123
+ seen += files.length;
124
+ const stats = await Promise.all(files.map((rel) => stat(join(root, rel)).then((info) => [rel, info.mtimeMs], () => null)));
125
+ for (const one of stats) if (one && one[1] >= from) found.push({ path: one[0], at: one[1] });
126
+ }
127
+ await walk(root, '', 0);
128
+ return found.sort((a, b) => b.at - a.at).slice(0, limit).map((one) => one.path);
129
+ }
130
+
99
131
  export function scoreFile(path, query) {
100
132
  const q = query.toLowerCase();
101
133
  if (!q) return 1;
package/src/github.mjs CHANGED
@@ -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
  // GitHub, as the GITHUB module and its MCP server both speak to it.
2
4
  //
3
5
  // Two ways in, and the order matters. First the `gh` CLI, if this computer has it and it is
@@ -0,0 +1,57 @@
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
+ // «Still in use»: once a day, every copy of MADRE tells madre.run it is on (2026-10-13).
4
+ //
5
+ // What goes, and nothing else: a random identifier this copy made for itself once
6
+ // (~/.pulse/install-id, from crypto.randomUUID — derived from nothing about the person or the
7
+ // machine), the MADRE version, the system (darwin, linux, win32) and Node's major version. No
8
+ // name, no email, no project, no path, no agent, nothing said in a room. madre.run keeps no IP
9
+ // (functions/api/ping.js of the landing). It is how the author knows how many people use MADRE,
10
+ // which versions they run and whether they come back — what npm's download count cannot say.
11
+ //
12
+ // It is said in docs/REFERENCE.md › «Lo que sale de la máquina» and on madre.run, and it goes
13
+ // through the same fetch every other outbound request does, so the room's own list of what left
14
+ // the machine shows it. Offline or behind a firewall it fails in silence and is tried again
15
+ // later: MADRE never waits on it. PULSE_HEARTBEAT_URL points it elsewhere, for development and
16
+ // for the test suite, which must never reach madre.run.
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+
22
+ export const HEARTBEAT_URL = 'https://madre.run/api/ping';
23
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
24
+
25
+ export async function installId(root) {
26
+ const file = join(root, 'install-id');
27
+ const kept = (await readFile(file, 'utf8').catch(() => '')).trim();
28
+ if (ID.test(kept)) return kept;
29
+ const made = randomUUID();
30
+ await mkdir(root, { recursive: true });
31
+ await writeFile(file, `${made}\n`, { mode: 0o600 });
32
+ return made;
33
+ }
34
+
35
+ // What the request carries: built here, in one place, so a test can say exactly what leaves.
36
+ export const heartbeatBody = ({ id, version, platform = process.platform, node = process.versions.node }) => ({
37
+ id, version, os: ['darwin', 'linux', 'win32'].includes(platform) ? platform : 'other', node: String(node).split('.')[0],
38
+ });
39
+
40
+ // Once per UTC day per copy, whichever of its processes gets there first; a day that failed is
41
+ // tried again on the next call.
42
+ export async function heartbeat({ root, version, fetchImpl = globalThis.fetch, url = process.env.PULSE_HEARTBEAT_URL ?? HEARTBEAT_URL, now = new Date() }) {
43
+ const day = now.toISOString().slice(0, 10);
44
+ const stamp = join(root, 'heartbeat.json');
45
+ try {
46
+ if (JSON.parse(await readFile(stamp, 'utf8')).day === day) return { sent: false, reason: 'today' };
47
+ } catch { /* never sent, or unreadable: send */ }
48
+ try {
49
+ const body = heartbeatBody({ id: await installId(root), version });
50
+ const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000) });
51
+ if (!response.ok) return { sent: false, reason: `HTTP ${response.status}` };
52
+ await writeFile(stamp, `${JSON.stringify({ day })}\n`);
53
+ return { sent: true, body };
54
+ } catch (error) {
55
+ return { sent: false, reason: error?.message ?? 'unreachable' };
56
+ }
57
+ }
package/src/i18n.mjs CHANGED
@@ -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
  // The room's language, on this side of the wire.
2
4
  //
3
5
  // The catalogue is the page's: public/es.js, one file for the whole product, because a sentence
@@ -24,9 +26,21 @@ export function setLanguage(id) {
24
26
  return current;
25
27
  }
26
28
 
29
+ // «Mac» only on a Mac (2026-10-15): MADRE installs on Linux too, and a sentence about the machine
30
+ // it runs on says «computer» there. Same rule as public/i18n.js (hostWords); hardware named on
31
+ // purpose («a Mac with an M chip») has no article in front and is left alone.
32
+ let hostIsMac = process.platform === 'darwin';
33
+ export const setHostIsMac = (value) => { hostIsMac = Boolean(value); return hostIsMac; };
34
+ export function hostWords(text) {
35
+ if (hostIsMac || typeof text !== 'string' || !text.includes('Mac')) return text;
36
+ return text
37
+ .replace(/\b(this|your|the|This|Your|The) Mac\b/g, '$1 computer')
38
+ .replace(/(^|[^\p{L}])(esta|tu|la|Esta|Tu|La) Mac\b/gu, '$1$2 computadora');
39
+ }
40
+
27
41
  export function t(text, vars = null) {
28
42
  if (typeof text !== 'string' || !text) return text;
29
- const said = CATALOGUES[current]?.[text] ?? text;
43
+ const said = hostWords(CATALOGUES[current]?.[text] ?? text);
30
44
  if (!vars) return said;
31
45
  return said.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
32
46
  }
@@ -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
  import { fileURLToPath } from 'node:url';
2
4
  import { resolveGeminiKey } from './mcp/image-server.mjs';
3
5
 
package/src/json-file.mjs CHANGED
@@ -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
  // Small files that more than one thing changes at once (2026-10-12, Lote 2 of the 1.0 review): the
2
4
  // tour, the review ask, a project's record of where it is, the list of a room's conversations.
3
5
  // Each was read, changed and written with nothing between two changes, so the second one wrote
package/src/launch.mjs CHANGED
@@ -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
  // What is shown of a command, and what is never shown.
2
4
  //
3
5
  // THE LAUNCH floor in the core prints the exact command MADRE would run, because a person who is