@jossuealcala/madre 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +69 -1
- package/CHANGELOG.md +69 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1626 -151
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +393 -25
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +98 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +307 -30
- package/public/styles.css +838 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +4 -2
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server.mjs +617 -48
- package/src/setup.mjs +1 -1
package/src/auth-probe.mjs
CHANGED
|
@@ -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.
|
|
91
|
-
//
|
|
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
|
-
|
|
126
|
-
|
|
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: '
|
|
130
|
-
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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:
|
|
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
|
}
|
package/src/bridge/policy.mjs
CHANGED
|
@@ -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',
|
package/src/capabilities.mjs
CHANGED
|
@@ -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.' },
|
package/src/checkpoint.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ?? []) {
|
package/src/credentials.mjs
CHANGED
|
@@ -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
|
}
|