@jossuealcala/madre 1.0.2 → 1.0.4

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 (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1635 -159
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +395 -26
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +8 -8
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
@@ -2,7 +2,7 @@
2
2
  // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
3
  import { execFile } from 'node:child_process';
4
4
  import { t } from './i18n.mjs';
5
- import { access, readFile } from 'node:fs/promises';
5
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { promisify } from 'node:util';
@@ -45,6 +45,18 @@ export function parseOpenCodeAuthList({ stdout = '' }) {
45
45
  return { state: 'signed-out', detail: 'no providers configured', providers: [] };
46
46
  }
47
47
 
48
+ // `cursor-agent status` says «Not logged in», or who is signed in («Logged in as …»). A key in the
49
+ // environment (CURSOR_API_KEY) signs every run in on its own, so it counts too.
50
+ export function parseCursorStatus({ stdout = '', stderr = '' }, env = process.env) {
51
+ const said = stripAnsi(`${stdout}\n${stderr}`);
52
+ if (/not\s+logged\s+in|not\s+authenticated|log\s*in\s+first/i.test(said)) {
53
+ return env.CURSOR_API_KEY ? { state: 'signed-in', detail: 'via CURSOR_API_KEY' } : { state: 'signed-out', detail: 'not logged in' };
54
+ }
55
+ const who = said.match(/logged\s+in\s+(?:as\s+)?([^\s]+@[^\s]+|\S+)/i)?.[1];
56
+ if (who) return { state: 'signed-in', detail: `as ${who}` };
57
+ return env.CURSOR_API_KEY ? { state: 'signed-in', detail: 'via CURSOR_API_KEY' } : { state: 'unknown', detail: said.trim().split('\n')[0]?.slice(0, 120) || 'no answer from cursor-agent status' };
58
+ }
59
+
48
60
  export function geminiAuthState({ settings, hasOauth, hasApiKey }) {
49
61
  const type = settings?.security?.auth?.selectedType;
50
62
  if (type === 'gemini-api-key') return hasApiKey ? { state: 'signed-in', detail: 'via API key' } : { state: 'signed-out', detail: 'API key selected but not found' };
@@ -87,8 +99,9 @@ export async function probeGemini({ env = process.env } = {}) {
87
99
  return geminiAuthState({ settings, hasOauth, hasApiKey });
88
100
  }
89
101
 
90
- // What each CLI needs from the user. Commands are run in the user's terminal
91
- // by the setup wizard; install hints are only printed.
102
+ // What each CLI needs from the user. The room runs the sign-in itself; the same command typed in a
103
+ // terminal stays as the other way in (`terminal`, `terminalNote`), offered and never required.
104
+ // The setup wizard runs them in the user's terminal; install hints are only printed.
92
105
  export const AGENT_SETUP = {
93
106
  codex: {
94
107
  install: ['npm install -g @openai/codex', 'or install the ChatGPT desktop app, which bundles codex'],
@@ -116,27 +129,86 @@ export const AGENT_SETUP = {
116
129
  // Its browser flow has no local server to come back to: the page gives a code and the CLI
117
130
  // waits for it on its own input. The room asks for it and types it in.
118
131
  asksForCode: true,
132
+ terminalNote: 'Add --console to bill the work to the Anthropic Console instead of your subscription.',
119
133
  vendor: 'Anthropic',
120
134
  account: 'Signs in with a Claude account or with the Anthropic Console. An Anthropic API key works too.',
121
135
  paid: true,
122
136
  },
123
137
  gemini: {
124
138
  install: ['npm install -g @google/gemini-cli'],
125
- login: [],
126
- loginNote: 'Gemini signs in from your terminal: run `gemini`, type /auth, pick "Use Gemini API key" (get one at aistudio.google.com/app/apikey) or Google login.',
139
+ // Gemini has no sign-in command: it signs in the first time it is asked something with
140
+ // "Login with Google" selected. Without a terminal it asks one yes/no, opens the browser by
141
+ // itself and waits on 127.0.0.1 for Google to come back, so the room needs no code and no
142
+ // address. The one-word question it is launched with is let through once the credentials are
143
+ // written: Google refuses free personal accounts only there, and a refused sign-in is undone
144
+ // (see geminiSignIn and loginAgent in server.mjs).
145
+ login: ['--prompt', 'ok'],
146
+ loginNote: 'Opens your browser to sign in with your Google account.',
147
+ browser: true,
148
+ methods: [
149
+ // The free personal account is refused by Google on this CLI (IneligibleTierError, 2026-10-17);
150
+ // the door stays for the accounts Google still serves, and says so.
151
+ { id: 'google', label: 'Google account', args: ['--prompt', 'ok'], note: 'Opens your browser to sign in with your Google account. Google refuses free personal accounts on this CLI; if it refuses yours, paste a Gemini API key from Google AI Studio instead.' },
152
+ ],
153
+ room: {
154
+ display: 'gemini · Login with Google',
155
+ flow: 'gemini-google',
156
+ // It asks "Do you want to continue? [Y/n]" first; the click on the button was the yes.
157
+ consent: true,
158
+ // With CI set the CLI refuses to open a browser and gives up without a terminal.
159
+ ci: false,
160
+ // A key in the environment would sign it in with the key instead of the account chosen.
161
+ dropEnv: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
162
+ // One process, so cancelling stops the one that holds the browser's way back.
163
+ env: { GEMINI_CLI_NO_RELAUNCH: 'true' },
164
+ },
165
+ // In a terminal it is its own prompt that signs in (the setup wizard hands it over whole).
127
166
  interactive: true,
167
+ terminal: 'gemini',
168
+ terminalNote: 'Then type /auth and pick "Use Gemini API key" with a key from aistudio.google.com/app/apikey. "Login with Google" is refused for free personal accounts.',
128
169
  vendor: 'Google',
129
- account: 'Signs in with a Google account and has a free tier. A Gemini API key from AI Studio works too.',
130
- paid: false,
170
+ account: 'Connects with a Gemini API key from Google AI Studio. A Google account works only where Google still accepts it: free personal accounts are refused.',
171
+ // Whether it costs depends on the key's project, not on the CLI: no «free option» promised.
172
+ paid: null,
131
173
  },
132
174
  opencode: {
133
175
  install: ['brew install opencode', 'or: npm install -g opencode-ai'],
134
176
  login: ['auth', 'login'],
135
177
  loginNote: 'Pick a provider and paste its key or complete its OAuth flow.',
178
+ // Its keys are pasted from the room (see openCodeProviders); these are the providers it signs
179
+ // in to with an account. `-p` skips its provider list, so the room never draws a menu. Only
180
+ // the ones whose owner says a third party may sign in this way: GitHub supports Copilot in
181
+ // OpenCode since 2026-01. Left out on purpose: a Claude subscription (Anthropic does not allow
182
+ // claude.ai sign-ins in other tools), and ChatGPT and GitLab, until OpenAI or GitLab say so in
183
+ // writing. They stay one `opencode auth login` away, in the terminal, for whoever chooses them.
184
+ browser: true,
185
+ methods: [
186
+ {
187
+ id: 'github-copilot',
188
+ label: 'GitHub Copilot',
189
+ args: ['auth', 'login', '-p', 'github-copilot'],
190
+ note: 'Gives you a code to type at github.com/login/device: the work goes against your Copilot subscription (Pro, Pro+, Business or Enterprise).',
191
+ // It asks which GitHub first; the room takes github.com. Enterprise is in the terminal.
192
+ room: { deviceCode: true, answers: [{ prompt: 'Select GitHub deployment type', send: '\r' }] },
193
+ },
194
+ ],
195
+ terminalNote: 'Pick a provider, then paste its key or sign in with its account. GitHub Enterprise and the other providers are in its list.',
136
196
  vendor: 'OpenCode',
137
197
  account: 'Brings no model of its own: you point it at a provider you already use, in the cloud or on this computer.',
138
198
  paid: null,
139
199
  },
200
+ // Cursor's agent CLI (2026-10-17): `login` opens the browser and waits for Cursor to come back.
201
+ // It has no npm package; its own installer is the way in, typed in a terminal.
202
+ cursor: {
203
+ install: ['curl https://cursor.com/install -fsS | bash'],
204
+ login: ['login'],
205
+ loginNote: 'Opens your browser to sign in with your Cursor account.',
206
+ browser: true,
207
+ terminal: 'cursor-agent login',
208
+ vendor: 'Cursor',
209
+ account: 'Signs in with your Cursor account and uses its plan. For now it reads and answers; it does not write in your project.',
210
+ paid: true,
211
+ },
140
212
  };
141
213
 
142
214
  // Installing a CLI from the room itself. npm is the common denominator: every one of these
@@ -157,6 +229,10 @@ export const TAKES_KEY = new Set(['claude', 'gemini', 'opencode']);
157
229
  export const SIGN_IN_URL = /https?:\/\/\S+/;
158
230
  export const CODE_PROMPT = /paste\s+(?:the\s+)?code|enter\s+(?:the\s+)?code|c[oó]digo\s*[:>]/i;
159
231
  export const looksLikeCodePrompt = (line) => CODE_PROMPT.test(String(line ?? ''));
232
+ // The other way round: a device sign-in shows a code to type on the provider's page
233
+ // ("Enter code: 3475-C193"), and the room has to show it, not ask for one.
234
+ export const DEVICE_CODE = /enter\s+(?:the\s+|this\s+)?code:?\s+([A-Z0-9]{4,}(?:-[A-Z0-9]{4,})+)/i;
235
+ export const deviceCodeIn = (line) => String(line ?? '').match(DEVICE_CODE)?.[1] ?? null;
160
236
 
161
237
  export function accountNoteFor(id) {
162
238
  // Said in the room's language when it is asked for, not when this file is imported.
@@ -185,10 +261,10 @@ export function installPlanFor(agent, { prefix = null } = {}) {
185
261
  };
186
262
  }
187
263
 
188
- // How the room can (re)connect an agent. Codex and Claude sign in with a
189
- // browser flow their own CLI drives, so the server can run them and stream the
190
- // URL; Gemini and OpenCode need their interactive prompt, so the user gets the
191
- // exact command instead.
264
+ // How the room can (re)connect an agent. Codex, Claude and Gemini sign in with a browser flow
265
+ // their own CLI drives, so the server can run them and stream what they say; OpenCode too, for
266
+ // the providers in its `methods`, and its keys are pasted. Every plan also carries `terminal`:
267
+ // the same sign-in typed by hand, for whoever prefers it.
192
268
  export function loginPlanFor(agent, { method = null } = {}) {
193
269
  const setup = AGENT_SETUP[agent.id];
194
270
  if (!setup) return null;
@@ -196,17 +272,71 @@ export function loginPlanFor(agent, { method = null } = {}) {
196
272
  // Which door: the one asked for, the first one the CLI offers, or the single flow it has.
197
273
  const chosen = setup.methods?.find((one) => one.id === method) ?? setup.methods?.[0] ?? null;
198
274
  const args = chosen?.args ?? setup.login;
275
+ const room = headless ? { ...(setup.room ?? {}), ...(chosen?.room ?? {}) } : {};
199
276
  return {
200
277
  headless,
201
278
  command: agent.path ?? agent.id,
202
279
  args: headless ? args : [],
203
- display: headless ? `${agent.id} ${args.join(' ')}` : agent.id === 'gemini' ? 'gemini # then type /auth' : `${agent.id} ${setup.login.join(' ')}`,
280
+ display: room.display ?? `${agent.id} ${(headless ? args : setup.login).join(' ')}`,
204
281
  note: chosen?.note ?? setup.loginNote,
282
+ terminal: { command: setup.terminal ?? `${agent.id} ${setup.login.join(' ')}`, note: setup.terminalNote ?? setup.loginNote },
205
283
  install: setup.install,
206
284
  // What the room offers as a choice, and what it must be ready to be asked for.
207
285
  methods: setup.methods ? setup.methods.map(({ id, label, note }) => ({ id, label, note })) : null,
208
286
  method: chosen?.id ?? null,
209
287
  asksForCode: Boolean(headless && setup.asksForCode),
288
+ // How the room runs it, when the CLI needs more than its arguments (see AGENT_SETUP.gemini.room).
289
+ ...(room.flow ? { flow: room.flow } : {}),
290
+ ...(room.consent ? { consent: true } : {}),
291
+ ...(room.ci === false ? { ci: false } : {}),
292
+ ...(room.env ? { env: room.env } : {}),
293
+ ...(room.dropEnv ? { dropEnv: room.dropEnv } : {}),
294
+ // A code to show rather than one to paste, and the menu questions the click already answered.
295
+ ...(room.deviceCode ? { deviceCode: true } : {}),
296
+ ...(room.answers ? { answers: room.answers } : {}),
297
+ };
298
+ }
299
+
300
+ // The yes/no Gemini asks before opening the browser, when it has no terminal to draw a dialog in.
301
+ export const CONSENT_PROMPT = /do you want to continue\?\s*\[y\/n\]/i;
302
+ export const looksLikeConsentPrompt = (line) => CONSENT_PROMPT.test(String(line ?? ''));
303
+
304
+ const GEMINI_REFUSED = /IneligibleTierError|no longer supported for Gemini Code Assist|migrate to the Antigravity/i;
305
+
306
+ // Gemini's Google sign-in, run from the room. Before: "Login with Google" selected in its own
307
+ // settings, and the account it had put aside, so the run signs in anew instead of answering with
308
+ // the old one. During: `signedIn` says when the credentials are written, which is when the room
309
+ // stops the run. After a sign-in that did not finish: `undo` puts both files back as they were.
310
+ // MADRE never reads the token: it moves the file it is in and checks that it exists.
311
+ export function geminiSignIn({ env = process.env, verifyMs = 30000 } = {}) {
312
+ const dir = join(env.GEMINI_CLI_HOME ?? homedir(), '.gemini');
313
+ const settingsFile = join(dir, 'settings.json');
314
+ const credsFile = join(dir, 'oauth_creds.json');
315
+ let before = null;
316
+ const readIfThere = (path) => readFile(path, 'utf8').catch(() => null);
317
+ const putBack = (path, previous) => (previous === null ? rm(path, { force: true }) : writeFile(path, previous, { mode: 0o600 }));
318
+ return {
319
+ async prepare() {
320
+ await mkdir(dir, { recursive: true });
321
+ before = { settings: await readIfThere(settingsFile), creds: await readIfThere(credsFile) };
322
+ let settings = {};
323
+ try { settings = JSON.parse(before.settings ?? '{}') ?? {}; } catch { settings = {}; }
324
+ settings.security = { ...(settings.security ?? {}), auth: { ...(settings.security?.auth ?? {}), selectedType: 'oauth-personal' } };
325
+ await writeFile(settingsFile, `${JSON.stringify(settings, null, 2)}\n`);
326
+ if (before.creds !== null) await rm(credsFile, { force: true });
327
+ },
328
+ signedIn: () => exists(credsFile),
329
+ // Google lets a free personal account sign in and refuses it at the first question
330
+ // (IneligibleTierError, 2026-10-17): a sign-in is only good once that question was not refused.
331
+ refused: (line) => GEMINI_REFUSED.test(String(line ?? '')),
332
+ // How long that first question is waited for before the run is stopped and the sign-in kept.
333
+ verifyMs,
334
+ async undo() {
335
+ if (!before) return;
336
+ await putBack(settingsFile, before.settings);
337
+ await putBack(credsFile, before.creds);
338
+ before = null;
339
+ },
210
340
  };
211
341
  }
212
342
 
@@ -217,6 +347,7 @@ export async function probeAgentAuth(agent) {
217
347
  case 'codex': return parseCodexLoginStatus(await run(agent.path, ['login', 'status']));
218
348
  case 'claude': return parseClaudeAuthStatus(await run(agent.path, ['auth', 'status', '--json']));
219
349
  case 'opencode': return parseOpenCodeAuthList(await run(agent.path, ['auth', 'list']));
350
+ case 'cursor': return parseCursorStatus(await run(agent.path, ['status']));
220
351
  case 'gemini': return probeGemini();
221
352
  default: return { state: 'unknown', detail: 'no probe for this agent' };
222
353
  }
@@ -17,7 +17,7 @@ import { t } from '../i18n.mjs';
17
17
 
18
18
  // Every file the room's page loads: one missing here and app.js cannot import it, so the whole page
19
19
  // stops on the phone (test/phone-bridge.test.mjs reads app.js's imports to keep this whole).
20
- const STATIC = new Set(['/', '/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/styles.css', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js']);
20
+ const STATIC = new Set(['/', '/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/styles.css', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js', '/power.js', '/wallpaper.js', '/neural.js', '/hardware.js', '/code-tools.js', '/market.js', '/market-view.js', '/market-art.js']);
21
21
 
22
22
  const READ = new Set([
23
23
  '/api/state', '/api/events', '/api/chats', '/api/version', '/api/whats-new', '/api/glance', '/api/handoff',
@@ -35,6 +35,15 @@ export const CAPABILITIES = {
35
35
  imageGen: false,
36
36
  web: { how: 'policy: allow google_web_search, web_fetch', note: 'Off until turned on in Settings › Agents › Web access.' },
37
37
  },
38
+ // Cursor (2026-10-17) reads only, in its own ask mode: writing waits until its confinement has
39
+ // been tried against the real CLI, as OpenCode's was. Attachments are named in the briefing.
40
+ cursor: {
41
+ read: true,
42
+ imageIn: false,
43
+ write: false,
44
+ imageGen: false,
45
+ web: false,
46
+ },
38
47
  opencode: {
39
48
  read: true,
40
49
  imageIn: { how: '-f <file>', note: 'Files attach to the message.' },
@@ -226,7 +226,9 @@ async function diffOne(root, before, prefix = '', exclude = []) {
226
226
  if (!files.length) return { files: [], stat: '' };
227
227
  const only = files.length < listed.length ? ['--', ...files.flatMap((file) => [file.path.slice(prefix.length), ...(file.from ? [file.from.slice(prefix.length)] : [])])] : [];
228
228
  const stat = (await git(root, ['diff', '--no-ext-diff', '--no-textconv', '--stat=100', before.tree, after, ...only], { via })).trim();
229
- return { files, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
229
+ // The tree the turn left is kept with what it changed (the code workspace reads it back): the
230
+ // agent's own lines stay its own even after the human edits the file again.
231
+ return { files, afterTree: after, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
230
232
  }
231
233
 
232
234
  // What changed since the checkpoint, in the project and in every nested repository, paths
@@ -243,6 +245,19 @@ export async function diffCheckpoint(root, checkpoint) {
243
245
  return { afterTree: outer.afterTree ?? null, files, stat, forbidden: files.filter((file) => forbiddenIn(file.path)).map((file) => file.path) };
244
246
  }
245
247
 
248
+ // What a file said at a photograph, or in the tree a turn left behind, read from git and never from
249
+ // the disk. `null` when git no longer has it (a shadow repository gone with a restart, a tree git
250
+ // pruned), when the file was not there, or when the path is not a path inside the project.
251
+ const CHECKPOINT_ID = /^[0-9A-Za-z][\w-]{0,79}$/;
252
+ const TREE_ID = /^[0-9a-f]{40,64}$/;
253
+ export async function readAt(root, { checkpointId = null, tree = null, path = '' } = {}) {
254
+ const inner = String(path ?? '');
255
+ if (!inner || inner.startsWith('/') || inner.startsWith('-') || inner.split('/').includes('..')) return null;
256
+ const rev = checkpointId ? (CHECKPOINT_ID.test(String(checkpointId)) ? `${CHECKPOINT_REF}/${checkpointId}` : null) : (TREE_ID.test(String(tree ?? '')) ? tree : null);
257
+ if (!rev) return null;
258
+ try { return await git(root, ['show', `${rev}:${inner}`]); } catch { return null; }
259
+ }
260
+
246
261
  // Which repository a project-relative path belongs to, and the path inside it.
247
262
  function repoFor(root, checkpoint, path) {
248
263
  for (const part of checkpoint.nested ?? []) {
@@ -86,6 +86,9 @@ export function keyPlanFor(id) {
86
86
  writesTo: '~/.gemini/.env',
87
87
  note: 'The key is written where the Gemini CLI looks for it, readable only by you. MADRE keeps no copy.',
88
88
  providers: null,
89
+ // The way in, ahead of the Google account: Google refuses free personal accounts on this
90
+ // CLI (IneligibleTierError, 2026-10-17), and a key from AI Studio is what works for anyone.
91
+ first: true,
89
92
  };
90
93
  }
91
94
  if (id === 'opencode') {
package/src/dataset.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // Export is the first step of roadmap 2c; training runs outside MADRE, on the
8
8
  // human's machine, from docs/training/.
9
9
 
10
- import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { redact } from './sentinel-errors.mjs';
13
13
  import { ABERRATION, VERDICTS, NEGATIVE_VERDICTS, POSITIVE_VERDICTS } from './memory.mjs';
@@ -121,6 +121,78 @@ export function pairsFromNotes(notes, { project = 'project', home, user, privacy
121
121
  }));
122
122
  }
123
123
 
124
+ // D-S15 · what the Coordinator learns from the projects that let it. Each note of a linked project
125
+ // becomes a pair that names its project — in the question, in the answer and in the citation — so
126
+ // the model learns which project knows what and never blends two projects into one. The notes
127
+ // arrive already cleaned by the project that served them; they are cleaned again here, by Salas.
128
+ export function pairsFromLinked(corpora, { home, user, privacy = null } = {}) {
129
+ const clean = guard(privacy);
130
+ const pairs = [];
131
+ for (const corpus of corpora ?? []) {
132
+ const name = String(corpus?.name ?? corpus?.from ?? 'project');
133
+ for (const note of corpus?.notes ?? []) {
134
+ if (!note || typeof note.text !== 'string' || !note.text.trim() || note.kind === ABERRATION) continue;
135
+ const text = clean(redact(note.text, { home, user }));
136
+ const cite = `${name} #${note.fromSequence}${note.throughSequence && note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}`;
137
+ pairs.push({
138
+ kind: 'linked',
139
+ agent: 'madre',
140
+ from: corpus.from ?? null,
141
+ mode: 1,
142
+ sequence: Number.isInteger(note.throughSequence) ? note.throughSequence : 0,
143
+ at: note.created ?? null,
144
+ messages: [
145
+ { role: 'system', content: LINKED_SYSTEM },
146
+ { role: 'user', content: `What does the project "${name}" remember about this? Kind: ${note.kind}. Topic: ${text.split(/[.;:]/)[0].slice(0, 80)}` },
147
+ { role: 'assistant', content: `In ${name}: ${text} [${cite}]` },
148
+ ],
149
+ });
150
+ }
151
+ // D-S16: the project's canonical state, as a photo with its date. The date is in the question
152
+ // and in the answer, so the model learns «as of», never «now»: for today it asks the project.
153
+ const state = corpus?.state;
154
+ if (state?.project) {
155
+ const date = String(state.project.updatedAt ?? corpus.at ?? '').slice(0, 10) || 'an unknown date';
156
+ const say = (value) => clean(redact(String(value ?? ''), { home, user }));
157
+ const base = Number.isInteger(corpus.notes?.at(-1)?.throughSequence) ? corpus.notes.at(-1).throughSequence : 0;
158
+ const pair = (offset, question, answer) => pairs.push({ kind: 'linked', agent: 'madre', from: corpus.from ?? null, mode: 1, sequence: base + offset, at: state.project.updatedAt ?? null, messages: [{ role: 'system', content: LINKED_SYSTEM }, { role: 'user', content: question }, { role: 'assistant', content: answer }] });
159
+ const where = [state.project.objective && `the objective is ${say(state.project.objective)}`, state.project.phase && `the phase is ${say(state.project.phase)}`, state.project.nextAction && `the next action is ${say(state.project.nextAction)}`, state.project.blockers?.length && `it is blocked by ${say(state.project.blockers.join('; '))}`].filter(Boolean);
160
+ if (where.length) pair(1, `Where did the project "${name}" stand on ${date}?`, `As of ${date}, in ${name}: ${where.join('; ')}. For today, ask ${name}. [${name} · state ${date}]`);
161
+ (state.decisions ?? []).forEach((decision, index) => {
162
+ if (!decision?.title) return;
163
+ pair(2 + index, `What did the project "${name}" decide about ${say(decision.title).slice(0, 80)}?`, `In ${name}, accepted decision as of ${String(decision.at ?? date).slice(0, 10)}: ${say(decision.title)}${decision.description ? `. ${say(decision.description)}` : ''} [${name} · Continuity]`);
164
+ });
165
+ }
166
+ }
167
+ return pairs;
168
+ }
169
+ const LINKED_SYSTEM = 'You are the Coordinator of MADRE. You know the projects connected to you and what each one decided and recorded. Say which project every fact comes from, and never mix two projects.';
170
+
171
+ // Salas keeps the last corpus each project handed over (D-S15), so a project asleep when the
172
+ // Coordinator exports is not lost from its training: what it shared last time is used, and said so.
173
+ // One file per project, beside the dataset; forgotten as soon as the link stops allowing it.
174
+ export const linkedCorpusDir = (datasetDir) => join(datasetDir, 'linked');
175
+ const corpusFile = (datasetDir, room) => join(linkedCorpusDir(datasetDir), `${String(room).replace(/[^\w.-]/g, '_')}.json`);
176
+ export async function keepCorpus(datasetDir, corpus, { now = new Date() } = {}) {
177
+ await mkdir(linkedCorpusDir(datasetDir), { recursive: true });
178
+ await writeFile(corpusFile(datasetDir, corpus.from), `${JSON.stringify({ contract: corpus.contract, from: corpus.from, name: corpus.name, at: new Date(now).toISOString(), notes: corpus.notes, ...(corpus.state ? { state: corpus.state } : {}) })}\n`, { mode: 0o600 });
179
+ }
180
+ export async function keptCorpus(datasetDir, room) {
181
+ try { return JSON.parse(await readFile(corpusFile(datasetDir, room), 'utf8')); } catch { return null; }
182
+ }
183
+ export async function forgetCorpus(datasetDir, room) {
184
+ await rm(corpusFile(datasetDir, room), { force: true });
185
+ }
186
+ // Every kept copy whose project no longer lets the Coordinator learn goes: `allowed` is the set of
187
+ // rooms that still do.
188
+ export async function pruneCorpora(datasetDir, allowed) {
189
+ const names = await readdir(linkedCorpusDir(datasetDir)).catch(() => []);
190
+ for (const name of names) {
191
+ const kept = await keptCorpus(datasetDir, name.replace(/\.json$/, ''));
192
+ if (!kept || !allowed.has(kept.from)) await rm(join(linkedCorpusDir(datasetDir), name), { force: true });
193
+ }
194
+ }
195
+
124
196
  // What the room got wrong, in the shape that trains against it. Each aberration becomes one
125
197
  // preference pair: the same ask, the false claim as what to avoid, the correction as what to say
126
198
  // instead. A negative on its own teaches a model very little; a pair tells it which of two
@@ -169,8 +241,8 @@ export function split(pairs, { validEvery = 10 } = {}) {
169
241
  return { train, valid };
170
242
  }
171
243
 
172
- export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null }) {
173
- const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
244
+ export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null, linked = [] }) {
245
+ const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy }), ...pairsFromLinked(linked, { home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
174
246
  const preferences = preferencesFromAberrations(notes, { project, home, user, privacy });
175
247
  const { train, valid } = split(pairs);
176
248
  await mkdir(dir, { recursive: true });
@@ -182,7 +254,7 @@ export async function exportDataset({ events, notes = [], dir, project = 'projec
182
254
  await writeFile(join(dir, 'preferences.jsonl'), preferences.map((pair) => JSON.stringify({ prompt: pair.prompt, chosen: pair.chosen, rejected: pair.rejected })).join('\n') + (preferences.length ? '\n' : ''));
183
255
  const byAgent = {};
184
256
  for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
185
- const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
257
+ const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, linked: (linked ?? []).map((one) => ({ room: one.from, name: one.name, notes: pairs.filter((p) => p.kind === 'linked' && p.from === one.from).length, source: one.source ?? 'live', at: one.at ?? null })), aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
186
258
  await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
187
259
  return { dir, ...manifest };
188
260
  }