@jossuealcala/madre 0.2.0
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.md +38 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +281 -0
- package/bin/madre.mjs +134 -0
- package/docs/madre-banner.svg +25 -0
- package/package.json +63 -0
- package/public/app.js +4031 -0
- package/public/brands.js +59 -0
- package/public/index.html +231 -0
- package/public/styles.css +976 -0
- package/public/troubleshooting.js +460 -0
- package/src/adapters/claude.mjs +105 -0
- package/src/adapters/codex.mjs +84 -0
- package/src/adapters/gemini.mjs +335 -0
- package/src/adapters/opencode.mjs +148 -0
- package/src/adapters/process.mjs +132 -0
- package/src/ashcode.mjs +64 -0
- package/src/auth-probe.mjs +145 -0
- package/src/capabilities.mjs +126 -0
- package/src/checkpoint.mjs +106 -0
- package/src/cli-args.mjs +38 -0
- package/src/commands.mjs +98 -0
- package/src/config.mjs +59 -0
- package/src/conversation-context.mjs +59 -0
- package/src/directives.mjs +70 -0
- package/src/distiller.mjs +78 -0
- package/src/embeddings.mjs +80 -0
- package/src/event-store.mjs +152 -0
- package/src/extensions.mjs +263 -0
- package/src/files.mjs +177 -0
- package/src/image-studio.mjs +25 -0
- package/src/lease.mjs +83 -0
- package/src/mcp/image-server.mjs +169 -0
- package/src/mcp/memory-server.mjs +221 -0
- package/src/memory-tools.mjs +34 -0
- package/src/memory.mjs +543 -0
- package/src/models.mjs +78 -0
- package/src/mother.mjs +163 -0
- package/src/quota-monitor.mjs +119 -0
- package/src/quota-sources.mjs +161 -0
- package/src/room.mjs +1159 -0
- package/src/router.mjs +13 -0
- package/src/runtime-detection.mjs +85 -0
- package/src/server.mjs +693 -0
- package/src/setup.mjs +192 -0
- package/src/usage-sentinel.mjs +65 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
function signalProcessGroup(child, signal) {
|
|
4
|
+
if (!child.pid) return false;
|
|
5
|
+
try {
|
|
6
|
+
// Negative pid targets the whole process group created by `detached: true`,
|
|
7
|
+
// so relaunched or wrapped CLI children die together with the launcher.
|
|
8
|
+
process.kill(-child.pid, signal);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
try {
|
|
12
|
+
child.kill(signal);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function terminateProcessTree(child, { graceMs = 2000 } = {}) {
|
|
21
|
+
signalProcessGroup(child, 'SIGTERM');
|
|
22
|
+
const escalation = setTimeout(() => signalProcessGroup(child, 'SIGKILL'), graceMs);
|
|
23
|
+
escalation.unref?.();
|
|
24
|
+
child.once('close', () => clearTimeout(escalation));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runReadonlyProcess({
|
|
28
|
+
executable,
|
|
29
|
+
args,
|
|
30
|
+
cwd,
|
|
31
|
+
env = process.env,
|
|
32
|
+
timeoutMs = 120000,
|
|
33
|
+
killGraceMs = 2000,
|
|
34
|
+
// Optional: give up when the process stays silent this long. Streaming
|
|
35
|
+
// adapters use it to tell "thinking" from "hung"; batch ones leave it off.
|
|
36
|
+
idleTimeoutMs = null,
|
|
37
|
+
// Optional: inspect accumulated stderr as it arrives; return an Error to
|
|
38
|
+
// stop the process now instead of waiting for the timeout (e.g. a CLI that
|
|
39
|
+
// retries a 503 forever).
|
|
40
|
+
watchStderr = null,
|
|
41
|
+
label,
|
|
42
|
+
parse,
|
|
43
|
+
signal,
|
|
44
|
+
}) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
reject(new Error(`${label} was interrupted before it started: ${typeof signal.reason === 'string' ? signal.reason : 'MADRE is shutting down'}.`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const child = spawn(executable, args, {
|
|
51
|
+
cwd,
|
|
52
|
+
env,
|
|
53
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
54
|
+
detached: process.platform !== 'win32',
|
|
55
|
+
});
|
|
56
|
+
let stdout = '';
|
|
57
|
+
let stderr = '';
|
|
58
|
+
let settled = false;
|
|
59
|
+
let lastActivity = Date.now();
|
|
60
|
+
let idleTimer = null;
|
|
61
|
+
const armIdle = () => {
|
|
62
|
+
if (!idleTimeoutMs) return;
|
|
63
|
+
clearTimeout(idleTimer);
|
|
64
|
+
idleTimer = setTimeout(() => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
if (Date.now() - lastActivity < idleTimeoutMs) { armIdle(); return; }
|
|
67
|
+
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
68
|
+
const lastLine = `${stderr}\n${stdout}`.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
69
|
+
const error = new Error(`${label} went silent for ${Math.round(idleTimeoutMs / 1000)}s and was stopped.${lastLine ? ` Last output: ${lastLine.slice(0, 200)}` : ''}`);
|
|
70
|
+
error.code = 'IDLE';
|
|
71
|
+
error.partialOutput = stdout;
|
|
72
|
+
error.partialStderr = stderr.slice(-2000);
|
|
73
|
+
finish(() => reject(error));
|
|
74
|
+
}, idleTimeoutMs);
|
|
75
|
+
idleTimer.unref?.();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
80
|
+
const reason = typeof signal?.reason === 'string' ? signal.reason : 'MADRE is shutting down';
|
|
81
|
+
finish(() => reject(new Error(`${label} was interrupted: ${reason}.`)));
|
|
82
|
+
};
|
|
83
|
+
const finish = (operation) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
clearTimeout(idleTimer);
|
|
88
|
+
signal?.removeEventListener('abort', onAbort);
|
|
89
|
+
operation();
|
|
90
|
+
};
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
93
|
+
// The last thing the agent said is usually the reason it was slow.
|
|
94
|
+
const lastLine = `${stderr}\n${stdout}`.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
95
|
+
const error = new Error(`${label} did not respond before the timeout (${Math.round(timeoutMs / 1000)}s).${lastLine ? ` Last output: ${lastLine.slice(0, 200)}` : ''}`);
|
|
96
|
+
error.code = 'TIMEOUT';
|
|
97
|
+
error.partialOutput = stdout;
|
|
98
|
+
error.partialStderr = stderr.slice(-2000);
|
|
99
|
+
finish(() => reject(error));
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
|
|
103
|
+
// Only complete stdout lines count as activity: a CLI's stderr spinner or
|
|
104
|
+
// progress noise must not keep a silent model alive past the idle limit.
|
|
105
|
+
// Blank keep-alive lines are not activity either.
|
|
106
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now(); });
|
|
107
|
+
child.stderr.on('data', (chunk) => {
|
|
108
|
+
stderr += chunk;
|
|
109
|
+
if (!watchStderr || settled) return;
|
|
110
|
+
const verdict = watchStderr(stderr);
|
|
111
|
+
if (!verdict) return;
|
|
112
|
+
terminateProcessTree(child, { graceMs: killGraceMs });
|
|
113
|
+
verdict.code ??= 'STDERR';
|
|
114
|
+
verdict.partialOutput = stdout;
|
|
115
|
+
verdict.partialStderr = stderr.slice(-2000);
|
|
116
|
+
finish(() => reject(verdict));
|
|
117
|
+
});
|
|
118
|
+
armIdle();
|
|
119
|
+
child.on('error', (error) => finish(() => {
|
|
120
|
+
// spawn reports ENOENT for a missing cwd as well as a missing binary.
|
|
121
|
+
if (error.code === 'ENOENT') {
|
|
122
|
+
error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed.`;
|
|
123
|
+
}
|
|
124
|
+
reject(error);
|
|
125
|
+
}));
|
|
126
|
+
child.on('close', (code) => finish(() => {
|
|
127
|
+
const response = parse(stdout);
|
|
128
|
+
if (code === 0 && response.text) return resolve(response);
|
|
129
|
+
reject(new Error(stderr.trim() || response.error || `${label} exited with code ${code}.`));
|
|
130
|
+
}));
|
|
131
|
+
});
|
|
132
|
+
}
|
package/src/ashcode.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// AshCode / ORDER 937 is a deliberately conservative, local text transform.
|
|
2
|
+
// It is lossy, not encryption or a model tokenizer. Keep the original in the
|
|
3
|
+
// event log so the human can inspect what was actually abbreviated.
|
|
4
|
+
|
|
5
|
+
const SPANISH = /\b(?:el|la|los|las|un|una|unos|unas|para|proyecto|página|pagina|imagen|crear|genera|dentro)\b/gi;
|
|
6
|
+
const ENGLISH = /\b(?:the|a|an|for|project|page|image|create|generate|inside|please)\b/gi;
|
|
7
|
+
const PROTECTED = /"[^"\n]*"|'[^'\n]*'|@[\p{L}\p{N}_-]+|![\p{L}\p{N}_./:-]+/gu;
|
|
8
|
+
const RISKY = /```|`|https?:\/\/|\n|[{}<>]|\b(?:no|nunca|sin|excepto|not|never|without|except)\b|\d|(?:^|\s)(?:\.{1,2}\/|\/|[\w.-]+\/)[\w./-]+/iu;
|
|
9
|
+
|
|
10
|
+
export function ashLanguage(input) {
|
|
11
|
+
const text = String(input ?? '');
|
|
12
|
+
const es = (text.match(SPANISH) ?? []).length + (/[áéíóúñ¿¡]/i.test(text) ? 2 : 0);
|
|
13
|
+
const en = (text.match(ENGLISH) ?? []).length;
|
|
14
|
+
if (es === en || Math.max(es, en) < 2) return null;
|
|
15
|
+
return es > en ? 'es' : 'en';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function compressAshCode(input) {
|
|
19
|
+
const original = String(input ?? '').trim();
|
|
20
|
+
const unchanged = (reason) => ({ text: original, original, applied: false, reason, originalChars: original.length, encodedChars: original.length, language: null });
|
|
21
|
+
if (!original) return unchanged('empty');
|
|
22
|
+
if (RISKY.test(original) || /(?:^|\s)\/[A-Za-z_.]/.test(original)) return unchanged('protected-structure');
|
|
23
|
+
const language = ashLanguage(original);
|
|
24
|
+
if (!language) return unchanged('language-uncertain');
|
|
25
|
+
|
|
26
|
+
const protectedSpans = [];
|
|
27
|
+
let text = original.replace(PROTECTED, (span) => {
|
|
28
|
+
const index = protectedSpans.push(span) - 1;
|
|
29
|
+
return `\uE000${index}\uE001`;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (language === 'es') {
|
|
33
|
+
text = text
|
|
34
|
+
.replace(/\b(?:yo\s+)?(?:quiero|necesito|quisiera)\s+que\s+/gi, '')
|
|
35
|
+
.replace(/\bme gustaría que\s+/gi, '')
|
|
36
|
+
.replace(/\bpor favor\b[,.]?\s*/gi, '')
|
|
37
|
+
.replace(/\bdentro del?\b/gi, 'en')
|
|
38
|
+
.replace(/\b(?:el|la|los|las|un|una|unos|unas)\s+/g, '')
|
|
39
|
+
.replace(/\bhome de (?:la )?(?:página|pagina) principal\b/gi, 'home');
|
|
40
|
+
} else {
|
|
41
|
+
text = text
|
|
42
|
+
.replace(/\bI (?:would like|want|need) you to\s+/gi, '')
|
|
43
|
+
.replace(/\b(?:could|can) you\s+/gi, '')
|
|
44
|
+
.replace(/\bplease\b[,.]?\s*/gi, '')
|
|
45
|
+
.replace(/\binside of\b/gi, 'in')
|
|
46
|
+
.replace(/\b(?:the|a|an)\s+/g, '')
|
|
47
|
+
.replace(/\bhome page of (?:the )?main (?:web)?site\b/gi, 'home page');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
text = text
|
|
51
|
+
.replace(/\b([\p{L}]+)(?:\s+\1)+\b/giu, '$1')
|
|
52
|
+
.replace(/\s+([,.;:!?])/g, '$1')
|
|
53
|
+
.replace(/\s{2,}/g, ' ')
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/[.。]$/, '');
|
|
56
|
+
text = text.replace(/\uE000(\d+)\uE001/g, (_, index) => protectedSpans[Number(index)]);
|
|
57
|
+
const encoded = `ASH937/${language}: ${text}`;
|
|
58
|
+
// A shorter character string is not proof of fewer model tokens. Refuse
|
|
59
|
+
// marginal rewrites because the prefix itself may erase any gain.
|
|
60
|
+
if (encoded.length > original.length - 8) return unchanged('no-safe-gain');
|
|
61
|
+
return { text: encoded, original, applied: true, reason: null, originalChars: original.length, encodedChars: encoded.length, language };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const ASHCODE_BETA_WARNING = 'AshCode beta: abbreviation can change meaning or cause errors. Review the original before relying on a result; character reduction is not verified token savings.';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const stripAnsi = (text) => String(text ?? '').replace(/\[[0-9;]*[A-Za-z]/g, '');
|
|
9
|
+
|
|
10
|
+
// Pure parsers, one per CLI, so the probe can be tested without the binaries.
|
|
11
|
+
|
|
12
|
+
export function parseCodexLoginStatus({ stdout = '', stderr = '', code = 0 }) {
|
|
13
|
+
const text = stripAnsi(`${stdout}\n${stderr}`);
|
|
14
|
+
if (/not logged in/i.test(text)) return { state: 'signed-out', detail: 'not logged in' };
|
|
15
|
+
if (code === 0 && /logged in/i.test(text)) {
|
|
16
|
+
const method = text.match(/logged in (?:using|with) ([^\n.]+)/i)?.[1]?.trim();
|
|
17
|
+
return { state: 'signed-in', detail: method ? `via ${method}` : 'logged in' };
|
|
18
|
+
}
|
|
19
|
+
return { state: 'unknown', detail: text.trim().split('\n')[0] || 'no status' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseClaudeAuthStatus({ stdout = '' }) {
|
|
23
|
+
try {
|
|
24
|
+
const status = JSON.parse(stripAnsi(stdout).trim());
|
|
25
|
+
if (status.loggedIn === true) return { state: 'signed-in', detail: status.authMethod ? `via ${status.authMethod}` : 'logged in' };
|
|
26
|
+
if (status.loggedIn === false) return { state: 'signed-out', detail: 'not logged in' };
|
|
27
|
+
} catch {
|
|
28
|
+
// fall through
|
|
29
|
+
}
|
|
30
|
+
return { state: 'unknown', detail: 'no status' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseOpenCodeAuthList({ stdout = '' }) {
|
|
34
|
+
const providers = stripAnsi(stdout)
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((line) => line.match(/^\s*[●○◆◇*-]\s+(.+?)\s{2,}(\S+)\s*$/) ?? line.match(/^\s*[●○◆◇*]\s+(.+?)\s+(oauth|api|apikey|token)\s*$/i))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((match) => ({ name: match[1].trim(), type: match[2].trim() }));
|
|
39
|
+
if (providers.length) {
|
|
40
|
+
return { state: 'signed-in', detail: providers.map((provider) => `${provider.name} (${provider.type})`).join(', '), providers };
|
|
41
|
+
}
|
|
42
|
+
return { state: 'signed-out', detail: 'no providers configured', providers: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function geminiAuthState({ settings, hasOauth, hasApiKey }) {
|
|
46
|
+
const type = settings?.security?.auth?.selectedType;
|
|
47
|
+
if (type === 'gemini-api-key') return hasApiKey ? { state: 'signed-in', detail: 'via API key' } : { state: 'signed-out', detail: 'API key selected but not found' };
|
|
48
|
+
if (type === 'oauth-personal' || type === 'oauth-enterprise') return hasOauth ? { state: 'signed-in', detail: 'via Google account' } : { state: 'signed-out', detail: 'Google login selected but no token' };
|
|
49
|
+
if (type === 'vertex-ai') return { state: 'signed-in', detail: 'via Vertex AI' };
|
|
50
|
+
if (hasApiKey || hasOauth) return { state: 'unknown', detail: 'credentials present, auth type not selected' };
|
|
51
|
+
return { state: 'signed-out', detail: 'never signed in' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run(path, args, timeout = 6000) {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout, stderr } = await execFileAsync(path, args, { timeout, env: { ...process.env, NO_COLOR: '1' } });
|
|
57
|
+
return { stdout, stderr, code: 0 };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { stdout: error.stdout ?? '', stderr: error.stderr ?? error.message, code: error.code ?? 1 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
64
|
+
|
|
65
|
+
async function geminiHasKeychainKey() {
|
|
66
|
+
if (process.platform !== 'darwin') return false;
|
|
67
|
+
const result = await run('/usr/bin/security', ['find-generic-password', '-s', 'gemini-cli-api-key'], 3000);
|
|
68
|
+
return result.code === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function probeGemini({ env = process.env } = {}) {
|
|
72
|
+
const home = env.GEMINI_CLI_HOME ?? homedir();
|
|
73
|
+
const dir = join(home, '.gemini');
|
|
74
|
+
let settings = null;
|
|
75
|
+
try {
|
|
76
|
+
settings = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
|
|
77
|
+
} catch {
|
|
78
|
+
settings = null;
|
|
79
|
+
}
|
|
80
|
+
const hasOauth = await exists(join(dir, 'oauth_creds.json'));
|
|
81
|
+
const hasApiKey = Boolean(env.GEMINI_API_KEY || env.GOOGLE_API_KEY) || await geminiHasKeychainKey();
|
|
82
|
+
return geminiAuthState({ settings, hasOauth, hasApiKey });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// What each CLI needs from the user. Commands are run in the user's terminal
|
|
86
|
+
// by the setup wizard; install hints are only printed.
|
|
87
|
+
export const AGENT_SETUP = {
|
|
88
|
+
codex: {
|
|
89
|
+
install: ['npm install -g @openai/codex', 'or install the ChatGPT desktop app, which bundles codex'],
|
|
90
|
+
login: ['login'],
|
|
91
|
+
loginNote: 'Opens your browser to sign in with ChatGPT.',
|
|
92
|
+
browser: true,
|
|
93
|
+
},
|
|
94
|
+
claude: {
|
|
95
|
+
install: ['npm install -g @anthropic-ai/claude-code', 'or: brew install --cask claude-code'],
|
|
96
|
+
login: ['auth', 'login'],
|
|
97
|
+
loginNote: 'Opens your browser to sign in with your Claude account.',
|
|
98
|
+
browser: true,
|
|
99
|
+
},
|
|
100
|
+
gemini: {
|
|
101
|
+
install: ['npm install -g @google/gemini-cli'],
|
|
102
|
+
login: [],
|
|
103
|
+
loginNote: 'Gemini signs in from its own prompt: run `gemini`, type /auth, pick "Use Gemini API key" (get one at aistudio.google.com/app/apikey) or Google login.',
|
|
104
|
+
interactive: true,
|
|
105
|
+
},
|
|
106
|
+
opencode: {
|
|
107
|
+
install: ['brew install opencode', 'or: npm install -g opencode-ai'],
|
|
108
|
+
login: ['auth', 'login'],
|
|
109
|
+
loginNote: 'Pick a provider and paste its key or complete its OAuth flow.',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// How the room can (re)connect an agent. Codex and Claude sign in with a
|
|
114
|
+
// browser flow their own CLI drives, so the server can run them and stream the
|
|
115
|
+
// URL; Gemini and OpenCode need their interactive prompt, so the user gets the
|
|
116
|
+
// exact command instead.
|
|
117
|
+
export function loginPlanFor(agent) {
|
|
118
|
+
const setup = AGENT_SETUP[agent.id];
|
|
119
|
+
if (!setup) return null;
|
|
120
|
+
const headless = Boolean(setup.browser) && setup.login.length > 0;
|
|
121
|
+
return {
|
|
122
|
+
headless,
|
|
123
|
+
command: agent.path ?? agent.id,
|
|
124
|
+
args: headless ? setup.login : [],
|
|
125
|
+
display: headless ? `${agent.id} ${setup.login.join(' ')}` : agent.id === 'gemini' ? 'gemini # then type /auth' : `${agent.id} ${setup.login.join(' ')}`,
|
|
126
|
+
note: setup.loginNote,
|
|
127
|
+
install: setup.install,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function probeAgentAuth(agent) {
|
|
132
|
+
if (!agent.detected || !agent.path) return { state: 'not-installed', detail: 'not found on this computer' };
|
|
133
|
+
switch (agent.id) {
|
|
134
|
+
case 'codex': return parseCodexLoginStatus(await run(agent.path, ['login', 'status']));
|
|
135
|
+
case 'claude': return parseClaudeAuthStatus(await run(agent.path, ['auth', 'status', '--json']));
|
|
136
|
+
case 'opencode': return parseOpenCodeAuthList(await run(agent.path, ['auth', 'list']));
|
|
137
|
+
case 'gemini': return probeGemini();
|
|
138
|
+
default: return { state: 'unknown', detail: 'no probe for this agent' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function probeAll(agents) {
|
|
143
|
+
const results = await Promise.all(agents.map((agent) => probeAgentAuth(agent)));
|
|
144
|
+
return Object.fromEntries(agents.map((agent, index) => [agent.id, results[index]]));
|
|
145
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// What each CLI can do from MADRE, verified against the installed versions on
|
|
2
|
+
// 2026-09-16 (Codex 0.153, Claude Code 2.1, Gemini CLI 0.60, OpenCode 1.18).
|
|
3
|
+
// The room shows this so a human knows whom to ask for what, and an
|
|
4
|
+
// orchestrator can route steps to the agent that actually has the ability.
|
|
5
|
+
//
|
|
6
|
+
// read inspect the project (always on: consultation mode)
|
|
7
|
+
// imageIn accept an image the human attaches
|
|
8
|
+
// write create files, only inside a scoped output directory, only when
|
|
9
|
+
// the human grants a creation lease for the turn (phase 2)
|
|
10
|
+
// imageGen generate images (Codex: image_generation feature via ChatGPT)
|
|
11
|
+
// web fetch or search the web (denied by default in consultation mode)
|
|
12
|
+
|
|
13
|
+
export const CAPABILITIES = {
|
|
14
|
+
codex: {
|
|
15
|
+
read: true,
|
|
16
|
+
imageIn: { how: '-i <file>', note: 'Images attach to the prompt directly.' },
|
|
17
|
+
write: { how: '-C <outdir> --sandbox workspace-write', note: 'Reads the whole project, writes only inside the lease directory.' },
|
|
18
|
+
imageGen: { how: 'features.image_generation (stable, enabled)', note: 'Generated with the ChatGPT account; saved into the lease directory.' },
|
|
19
|
+
web: { how: '--search (live web_search tool)', note: 'Off until enabled in CONNECTIONS.' },
|
|
20
|
+
},
|
|
21
|
+
claude: {
|
|
22
|
+
read: true,
|
|
23
|
+
imageIn: { how: 'Read tool on the attachment path', note: 'The attachments folder is added with --add-dir.' },
|
|
24
|
+
write: { how: '--tools Write,Edit + permission rules', note: 'Write/Edit allowed only under the lease directory.' },
|
|
25
|
+
imageGen: false,
|
|
26
|
+
web: { how: '--tools WebFetch,WebSearch', note: 'Off until enabled in CONNECTIONS.' },
|
|
27
|
+
},
|
|
28
|
+
gemini: {
|
|
29
|
+
read: true,
|
|
30
|
+
imageIn: { how: 'read_file on the attachment path', note: 'The attachments folder is added with --include-directories.' },
|
|
31
|
+
write: { how: 'policy: allow write_file/edit with argsPattern', note: 'Paths outside the lease directory stay denied.' },
|
|
32
|
+
imageGen: false,
|
|
33
|
+
web: { how: 'policy: allow google_web_search, web_fetch', note: 'Off until enabled in CONNECTIONS.' },
|
|
34
|
+
},
|
|
35
|
+
opencode: {
|
|
36
|
+
read: true,
|
|
37
|
+
imageIn: { how: '-f <file>', note: 'Files attach to the message.' },
|
|
38
|
+
write: { how: 'permission.edit patterns', note: 'Edit allowed only under the lease directory.' },
|
|
39
|
+
imageGen: false,
|
|
40
|
+
web: { how: 'permission.webfetch / websearch = allow', note: 'Off until enabled in CONNECTIONS.' },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// MADRE modules can add abilities a CLI lacks. Image Studio (MADRE's own MCP
|
|
45
|
+
// image server on the Gemini API) gives Gemini CLI, Claude Code and OpenCode
|
|
46
|
+
// image generation; Codex keeps its native one.
|
|
47
|
+
let imageModule = { enabled: false };
|
|
48
|
+
export function setImageModule(state) {
|
|
49
|
+
imageModule = { enabled: Boolean(state?.enabled), model: state?.model ?? null };
|
|
50
|
+
}
|
|
51
|
+
export const imageModuleState = () => ({ ...imageModule });
|
|
52
|
+
|
|
53
|
+
export const capabilityOf = (agentId) => {
|
|
54
|
+
const base = CAPABILITIES[agentId] ?? { read: true, imageIn: false, write: false, imageGen: false, web: false };
|
|
55
|
+
if (!base.imageGen && imageModule.enabled && ['gemini', 'claude', 'opencode'].includes(agentId)) {
|
|
56
|
+
return { ...base, imageGen: { how: 'MADRE Image Studio (MCP tool generate_image, Gemini API)', note: `Attached only inside a creation lease with the image scope on${imageModule.model ? ` · model ${imageModule.model}` : ''}.`, module: 'image-studio' } };
|
|
57
|
+
}
|
|
58
|
+
return base;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function capabilitySummary(agentId) {
|
|
62
|
+
const caps = capabilityOf(agentId);
|
|
63
|
+
return {
|
|
64
|
+
read: Boolean(caps.read),
|
|
65
|
+
imageIn: Boolean(caps.imageIn),
|
|
66
|
+
write: Boolean(caps.write),
|
|
67
|
+
imageGen: Boolean(caps.imageGen),
|
|
68
|
+
web: Boolean(caps.web),
|
|
69
|
+
detail: Object.fromEntries(Object.entries(caps).filter(([, value]) => value && typeof value === 'object').map(([key, value]) => [key, value])),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Agents that have a capability, for routing hints in the delegation help.
|
|
74
|
+
export function agentsWith(capability, agentIds) {
|
|
75
|
+
return agentIds.filter((id) => Boolean(capabilityOf(id)[capability]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Permission modes, chosen per message in the composer and capped per agent
|
|
79
|
+
// in CONNECTIONS. The human's mode is the ceiling of any plan it starts.
|
|
80
|
+
// #0 GHOST off the record: nothing enters the log or anyone's context
|
|
81
|
+
// #1 EXCHANGE read the project, coordinate with the others (default)
|
|
82
|
+
// #2 CREATE create files and images inside the turn's .pulse/out/ lease
|
|
83
|
+
// #3 CONTROL read, create and modify the project itself (phase C)
|
|
84
|
+
export const MODES = {
|
|
85
|
+
0: { key: 'ghost', label: 'GHOST', hint: 'off the record · nothing is saved or remembered' },
|
|
86
|
+
1: { key: 'exchange', label: 'EXCHANGE', hint: 'read the project and coordinate · default' },
|
|
87
|
+
2: { key: 'create', label: 'CREATE', hint: 'create files and images inside .pulse/out/' },
|
|
88
|
+
3: { key: 'control', label: 'CONTROL', hint: 'modify the project itself · only this agent · needs the override' },
|
|
89
|
+
};
|
|
90
|
+
export const MODE_MAX = 3;
|
|
91
|
+
export const normalizeMode = (value, fallback = 1) => {
|
|
92
|
+
const mode = Number(value);
|
|
93
|
+
return Number.isInteger(mode) && mode >= 0 && mode <= MODE_MAX ? mode : fallback;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Scopes are the capabilities the human has switched on for CREATE. A scope
|
|
97
|
+
// can only be enabled where the CLI has the capability; by default file
|
|
98
|
+
// creation and image generation are on wherever possible, web stays off.
|
|
99
|
+
export const SCOPES = ['write', 'imageGen', 'web'];
|
|
100
|
+
export const SCOPE_LABELS = { write: 'create files', imageGen: 'generate images', web: 'web access' };
|
|
101
|
+
|
|
102
|
+
export function resolveScopes(agentId, configured = {}) {
|
|
103
|
+
const caps = capabilityOf(agentId);
|
|
104
|
+
const scopes = {};
|
|
105
|
+
for (const scope of SCOPES) {
|
|
106
|
+
const capable = Boolean(caps[scope]);
|
|
107
|
+
const wanted = configured[scope] ?? (scope !== 'web');
|
|
108
|
+
scopes[scope] = { capable, enabled: capable && wanted, wired: true };
|
|
109
|
+
}
|
|
110
|
+
// A standing lease: every turn of this agent may create files inside
|
|
111
|
+
// .pulse/out/ without the human arming CREATE each time. Opt-in per agent.
|
|
112
|
+
scopes.write.always = Boolean(configured.alwaysCreate) && scopes.write.enabled;
|
|
113
|
+
// The ceiling: how far the composer may take this agent. Writing needs the
|
|
114
|
+
// CLI to be able to write; CONTROL is capped at 2 until phase C wires it.
|
|
115
|
+
const wanted = normalizeMode(configured.maxMode, scopes.write.enabled ? 2 : 1);
|
|
116
|
+
scopes.maxMode = Math.min(wanted, scopes.write.capable ? 3 : 1);
|
|
117
|
+
scopes.defaultMode = scopes.write.always && scopes.maxMode >= 2 ? 2 : 1;
|
|
118
|
+
return scopes;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One line per agent for the orchestrator: who can do what right now.
|
|
122
|
+
export function abilityLine(agentId, scopes) {
|
|
123
|
+
const on = SCOPES.filter((scope) => scopes[scope]?.enabled && scopes[scope]?.wired).map((scope) => SCOPE_LABELS[scope]);
|
|
124
|
+
const cannot = SCOPES.filter((scope) => !scopes[scope]?.capable).map((scope) => SCOPE_LABELS[scope]);
|
|
125
|
+
return `@${agentId}: can ${on.length ? on.join(', ') : 'only read'}${cannot.length ? `; cannot ${cannot.join(', ')}` : ''}`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, mkdtemp, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve, sep } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
// CONTROL turns run between two photographs of the project. The photograph
|
|
10
|
+
// is a real git commit, kept under MADRE's own ref namespace so the user's
|
|
11
|
+
// branch, index and stash never notice; it is taken with a throwaway index
|
|
12
|
+
// so it includes untracked files (but never ignored ones). UNDO restores the
|
|
13
|
+
// working tree to that photograph and removes what appeared since.
|
|
14
|
+
|
|
15
|
+
export const CHECKPOINT_REF = 'refs/madre/checkpoints';
|
|
16
|
+
|
|
17
|
+
// Paths no agent touches even in CONTROL. Enforced after the turn: whatever
|
|
18
|
+
// changed here is restored from the checkpoint and reported.
|
|
19
|
+
export const FORBIDDEN = [
|
|
20
|
+
/^\.git(\/|$)/,
|
|
21
|
+
/^\.pulse(\/|$)/,
|
|
22
|
+
/(^|\/)\.env(\.|$)/,
|
|
23
|
+
/(^|\/)\.madre(\/|$)/,
|
|
24
|
+
/(^|\/)\.claude\/settings\.local\.json$/,
|
|
25
|
+
];
|
|
26
|
+
export const isForbidden = (path) => FORBIDDEN.some((pattern) => pattern.test(path));
|
|
27
|
+
|
|
28
|
+
async function git(root, args, { env = {} } = {}) {
|
|
29
|
+
const { stdout } = await execFileAsync('git', ['-c', 'core.quotepath=off', ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
|
|
30
|
+
return stdout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function isGitRepo(root) {
|
|
34
|
+
return access(join(root, '.git')).then(() => true, () => false);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A tree object of the working tree as it is right now: tracked and untracked
|
|
38
|
+
// files, ignored ones left out. Built in a temporary index.
|
|
39
|
+
export async function worktreeTree(root) {
|
|
40
|
+
const dir = await mkdtemp(join(tmpdir(), 'madre-index-'));
|
|
41
|
+
const index = join(dir, 'index');
|
|
42
|
+
try {
|
|
43
|
+
const env = { GIT_INDEX_FILE: index };
|
|
44
|
+
await git(root, ['read-tree', '--empty'], { env });
|
|
45
|
+
await git(root, ['add', '-A', '--', '.'], { env });
|
|
46
|
+
return (await git(root, ['write-tree'], { env })).trim();
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
|
|
53
|
+
const tree = await worktreeTree(root);
|
|
54
|
+
const head = (await git(root, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')).trim() || null;
|
|
55
|
+
const commit = (await git(root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `${label} ${id}`], { env: { GIT_AUTHOR_NAME: 'MADRE', GIT_AUTHOR_EMAIL: 'madre@localhost', GIT_COMMITTER_NAME: 'MADRE', GIT_COMMITTER_EMAIL: 'madre@localhost' } })).trim();
|
|
56
|
+
await git(root, ['update-ref', `${CHECKPOINT_REF}/${id}`, commit]);
|
|
57
|
+
return { id, commit, tree, head, createdAt: new Date().toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// What changed since the checkpoint: name-status per file and git's --stat.
|
|
61
|
+
export async function diffCheckpoint(root, checkpoint) {
|
|
62
|
+
const after = await worktreeTree(root);
|
|
63
|
+
if (after === checkpoint.tree) return { afterTree: after, files: [], stat: '', forbidden: [] };
|
|
64
|
+
const nameStatus = await git(root, ['diff', '--name-status', '-M', checkpoint.tree, after]);
|
|
65
|
+
const files = nameStatus.split('\n').filter(Boolean).map((line) => {
|
|
66
|
+
const [status, ...rest] = line.split('\t');
|
|
67
|
+
const path = rest.at(-1);
|
|
68
|
+
return { status: status[0], path, from: status[0] === 'R' ? rest[0] : undefined };
|
|
69
|
+
});
|
|
70
|
+
const stat = (await git(root, ['diff', '--stat=100', checkpoint.tree, after])).trim();
|
|
71
|
+
return { afterTree: after, files, stat, forbidden: files.filter((file) => isForbidden(file.path)).map((file) => file.path) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Put the working tree back to the checkpoint: files added since are removed,
|
|
75
|
+
// everything else is restored from the checkpoint commit. `paths` limits the
|
|
76
|
+
// restore to those files (used for forbidden zones right after a turn).
|
|
77
|
+
export async function restoreCheckpoint(root, checkpoint, { paths = null } = {}) {
|
|
78
|
+
const { files } = await diffCheckpoint(root, checkpoint);
|
|
79
|
+
const chosen = paths ? files.filter((file) => paths.includes(file.path)) : files;
|
|
80
|
+
const removed = [];
|
|
81
|
+
const restored = [];
|
|
82
|
+
const canonicalRoot = resolve(root);
|
|
83
|
+
for (const file of chosen) {
|
|
84
|
+
const absolute = resolve(root, file.path);
|
|
85
|
+
if (!absolute.startsWith(canonicalRoot + sep)) continue;
|
|
86
|
+
if (file.status === 'A') {
|
|
87
|
+
await unlink(absolute).catch(() => {});
|
|
88
|
+
removed.push(file.path);
|
|
89
|
+
} else {
|
|
90
|
+
if (file.status === 'R' && file.from) {
|
|
91
|
+
await unlink(absolute).catch(() => {});
|
|
92
|
+
await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.from]);
|
|
93
|
+
restored.push(file.from);
|
|
94
|
+
removed.push(file.path);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.path]);
|
|
98
|
+
restored.push(file.path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { removed, restored };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function dropCheckpoint(root, checkpoint) {
|
|
105
|
+
await git(root, ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`]).catch(() => {});
|
|
106
|
+
}
|
package/src/cli-args.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Tiny argv reader for the MADRE CLI. Accepts `--name value` and
|
|
2
|
+
// `--name=value`, and remembers which options the user set explicitly so the
|
|
3
|
+
// room can choose sensible defaults (like the next free port) only when they
|
|
4
|
+
// did not.
|
|
5
|
+
|
|
6
|
+
export function parseArgs(argv) {
|
|
7
|
+
const args = [...argv];
|
|
8
|
+
const helpFlags = new Set(['help', '--help', '-h']);
|
|
9
|
+
const command = helpFlags.has(args[0])
|
|
10
|
+
? 'help'
|
|
11
|
+
: args[0]?.startsWith('-') ? 'start' : (args.shift() ?? 'start');
|
|
12
|
+
|
|
13
|
+
const options = {};
|
|
14
|
+
const flags = new Set();
|
|
15
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
16
|
+
const arg = args[index];
|
|
17
|
+
if (!arg.startsWith('--')) continue;
|
|
18
|
+
const equals = arg.indexOf('=');
|
|
19
|
+
if (equals > 0) {
|
|
20
|
+
options[arg.slice(2, equals)] = arg.slice(equals + 1);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const name = arg.slice(2);
|
|
24
|
+
const next = args[index + 1];
|
|
25
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
26
|
+
options[name] = next;
|
|
27
|
+
index += 1;
|
|
28
|
+
} else {
|
|
29
|
+
flags.add(name);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
command,
|
|
34
|
+
option: (name, fallback) => options[name] ?? fallback,
|
|
35
|
+
has: (name) => flags.has(name) || name in options,
|
|
36
|
+
explicit: (name) => name in options,
|
|
37
|
+
};
|
|
38
|
+
}
|