@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,84 @@
|
|
|
1
|
+
import { runReadonlyProcess } from './process.mjs';
|
|
2
|
+
|
|
3
|
+
// With a lease, the working root is the lease directory (the only writable
|
|
4
|
+
// place) and the sandbox allows workspace writes; the project stays readable.
|
|
5
|
+
// A TOML value for `-c key=value`: strings quoted, arrays and tables inline.
|
|
6
|
+
export function tomlValue(value) {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(tomlValue).join(', ')}]`;
|
|
8
|
+
if (value && typeof value === 'object') return `{ ${Object.entries(value).map(([key, item]) => `${/^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key)} = ${tomlValue(item)}`).join(', ')} }`;
|
|
9
|
+
return JSON.stringify(String(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The room's memory as an MCP server for this run only: Codex reads
|
|
13
|
+
// mcp_servers.<name> from its config, and -c overrides it on the command line
|
|
14
|
+
// without touching the user's config.toml.
|
|
15
|
+
export function codexMcpOverrides(memoryServer) {
|
|
16
|
+
if (!memoryServer) return [];
|
|
17
|
+
const key = `mcp_servers.${memoryServer.name}`;
|
|
18
|
+
return [
|
|
19
|
+
'-c', `${key}.command=${tomlValue(memoryServer.command)}`,
|
|
20
|
+
'-c', `${key}.args=${tomlValue(memoryServer.args)}`,
|
|
21
|
+
'-c', `${key}.env=${tomlValue(memoryServer.env ?? {})}`,
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildCodexArgs({ projectRoot, prompt, model = null, attachments = [], lease = null, scopes = null, memoryServer = null }) {
|
|
26
|
+
const images = attachments.filter((file) => /^image\//.test(file.contentType ?? ''));
|
|
27
|
+
return [
|
|
28
|
+
'--sandbox', lease ? 'workspace-write' : 'read-only',
|
|
29
|
+
'--ask-for-approval', 'never',
|
|
30
|
+
// Live web search is a global Codex flag; the human's web scope decides.
|
|
31
|
+
...(scopes?.web ? ['--search'] : []),
|
|
32
|
+
'-C', lease ? lease.outDir : projectRoot,
|
|
33
|
+
...codexMcpOverrides(memoryServer),
|
|
34
|
+
// Image generation is a Codex feature; the human's scope decides per turn.
|
|
35
|
+
...(lease && (scopes?.imageGen === false || lease.scopes?.imageGen === false) ? ['-c', 'features.image_generation=false'] : []),
|
|
36
|
+
'exec',
|
|
37
|
+
...(lease ? ['--skip-git-repo-check'] : []),
|
|
38
|
+
...(model ? ['--model', model] : []),
|
|
39
|
+
...images.flatMap((file) => ['--image', file.path]),
|
|
40
|
+
'--ephemeral',
|
|
41
|
+
'--color', 'never',
|
|
42
|
+
'--json',
|
|
43
|
+
prompt,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseCodexOutput(output) {
|
|
48
|
+
let text = '';
|
|
49
|
+
let usage = null;
|
|
50
|
+
for (const line of output.split('\n').filter(Boolean)) {
|
|
51
|
+
try {
|
|
52
|
+
const event = JSON.parse(line);
|
|
53
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message') {
|
|
54
|
+
text += event.item.text ?? '';
|
|
55
|
+
}
|
|
56
|
+
if (event.type === 'turn.completed' && event.usage) {
|
|
57
|
+
usage = {
|
|
58
|
+
inputTokens: event.usage.input_tokens ?? 0,
|
|
59
|
+
cachedInputTokens: event.usage.cached_input_tokens ?? 0,
|
|
60
|
+
outputTokens: event.usage.output_tokens ?? 0,
|
|
61
|
+
reasoningTokens: event.usage.reasoning_output_tokens ?? 0,
|
|
62
|
+
totalTokens: (event.usage.input_tokens ?? 0) + (event.usage.output_tokens ?? 0),
|
|
63
|
+
source: 'codex-json',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore CLI diagnostics that are not JSON events.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { text: text.trim(), usage };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null }) {
|
|
74
|
+
return runReadonlyProcess({
|
|
75
|
+
executable,
|
|
76
|
+
args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer }),
|
|
77
|
+
cwd: lease ? lease.outDir : projectRoot,
|
|
78
|
+
env: process.env,
|
|
79
|
+
timeoutMs,
|
|
80
|
+
signal,
|
|
81
|
+
label: 'Codex',
|
|
82
|
+
parse: parseCodexOutput,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir, tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { runReadonlyProcess } from './process.mjs';
|
|
5
|
+
|
|
6
|
+
export const geminiReadonlyPolicy = `[[rule]]
|
|
7
|
+
toolName = "*"
|
|
8
|
+
decision = "deny"
|
|
9
|
+
priority = 998
|
|
10
|
+
interactive = false
|
|
11
|
+
denyMessage = "MADRE consultation mode only permits local project reads."
|
|
12
|
+
|
|
13
|
+
[[rule]]
|
|
14
|
+
toolName = ["read_file", "list_directory", "glob", "grep_search"]
|
|
15
|
+
decision = "allow"
|
|
16
|
+
priority = 999
|
|
17
|
+
interactive = false
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
// Only credentials are carried into the isolated home: OAuth tokens, the
|
|
21
|
+
// account marker, the installation id and ~/.gemini/.env (where users may keep
|
|
22
|
+
// GEMINI_API_KEY). Keys stored in the OS keychain need no copy. Hooks,
|
|
23
|
+
// extensions, MCP servers, memory files and history stay behind.
|
|
24
|
+
export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json', 'installation_id', '.env'];
|
|
25
|
+
|
|
26
|
+
// The read-only policy plus, under a lease, allow rules for the write tools
|
|
27
|
+
// whose file_path argument starts with the lease directory. Plan mode would
|
|
28
|
+
// block every write regardless of policy, so a lease uses approval "default":
|
|
29
|
+
// headless Gemini cannot prompt, so anything the policy does not allow fails.
|
|
30
|
+
export function geminiLeasePolicy(outDir, { control = false } = {}) {
|
|
31
|
+
const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
const forbidden = control ? `
|
|
33
|
+
[[rule]]
|
|
34
|
+
toolName = ["write_file", "replace", "edit", "run_shell_command"]
|
|
35
|
+
argsPattern = '${escaped}/(\\.git|\\.pulse|\\.env)'
|
|
36
|
+
decision = "deny"
|
|
37
|
+
priority = 1100
|
|
38
|
+
interactive = false
|
|
39
|
+
` : '';
|
|
40
|
+
return `${geminiReadonlyPolicy}
|
|
41
|
+
[[rule]]
|
|
42
|
+
toolName = ["write_file", "replace", "edit"]
|
|
43
|
+
argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
|
|
44
|
+
decision = "allow"
|
|
45
|
+
priority = 1000
|
|
46
|
+
interactive = false
|
|
47
|
+
${forbidden}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const geminiWebPolicy = `
|
|
51
|
+
[[rule]]
|
|
52
|
+
toolName = ["google_web_search", "web_fetch"]
|
|
53
|
+
decision = "allow"
|
|
54
|
+
priority = 999
|
|
55
|
+
interactive = false
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
|
|
59
|
+
return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function geminiMemoryPolicy(memoryServer) {
|
|
63
|
+
const names = memoryServer.tools.flatMap((tool) => [tool, `${memoryServer.name}__${tool}`]);
|
|
64
|
+
return `
|
|
65
|
+
[[rule]]
|
|
66
|
+
toolName = [${names.map((name) => `"${name}"`).join(', ')}]
|
|
67
|
+
decision = "allow"
|
|
68
|
+
priority = 1000
|
|
69
|
+
interactive = false
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Gemini CLI reads "@something" in a prompt as a file to include, even in
|
|
74
|
+
// headless mode, and fuzzily resolves "@claude" to CLAUDE.md. Its parser skips
|
|
75
|
+
// an @ preceded by a backslash, so every handle goes in as \@name; the model
|
|
76
|
+
// is told what that means and its reply is unescaped on the way out.
|
|
77
|
+
export const GEMINI_MENTION_NOTE = 'Note: in this prompt every @handle is written as \\@handle (your CLI would otherwise read @name as a file to include). Read \\@name as @name and write handles plainly as @name in your reply.';
|
|
78
|
+
export function escapeGeminiMentions(text) {
|
|
79
|
+
return String(text ?? '').replace(/(^|[^\\])@(?=[\w./~-])/g, '$1\\@');
|
|
80
|
+
}
|
|
81
|
+
export const unescapeGeminiMentions = (text) => String(text ?? '').replace(/\\@/g, '@');
|
|
82
|
+
|
|
83
|
+
export function buildGeminiArgs({ projectRoot, prompt, policyPath, model = null, attachmentsDir = null, lease = null }) {
|
|
84
|
+
const includes = [projectRoot, attachmentsDir, lease?.outDir].filter(Boolean).join(',');
|
|
85
|
+
const escaped = escapeGeminiMentions(prompt);
|
|
86
|
+
const safePrompt = escaped !== prompt ? `${GEMINI_MENTION_NOTE}\n\n${escaped}` : prompt;
|
|
87
|
+
return [
|
|
88
|
+
...(model && model !== 'auto' ? ['--model', model] : []),
|
|
89
|
+
'--approval-mode', lease ? 'default' : 'plan',
|
|
90
|
+
// stream-json emits init / tool_use / message deltas / result as JSONL, so
|
|
91
|
+
// MADRE can tell a thinking Gemini from a hung one.
|
|
92
|
+
'--output-format', 'stream-json',
|
|
93
|
+
'--skip-trust',
|
|
94
|
+
'--include-directories', includes,
|
|
95
|
+
'--policy', policyPath,
|
|
96
|
+
'--prompt', safePrompt,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildGeminiEnvironment({ runtimeRoot, environment = process.env }) {
|
|
101
|
+
return {
|
|
102
|
+
...environment,
|
|
103
|
+
// Storage resolves ~/.gemini from this root, so settings, hooks and
|
|
104
|
+
// extensions are read from the temporary home instead of the real one.
|
|
105
|
+
GEMINI_CLI_HOME: runtimeRoot,
|
|
106
|
+
// The launcher otherwise re-spawns itself in a second process that owns
|
|
107
|
+
// the request; a SIGTERM to the launcher would not stop the model call.
|
|
108
|
+
GEMINI_CLI_NO_RELAUNCH: 'true',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isolateGeminiSettings(settings, { imageStudio = null, memoryServer = null } = {}) {
|
|
113
|
+
const auth = settings?.security?.auth;
|
|
114
|
+
const isolated = auth ? { security: { auth } } : {};
|
|
115
|
+
for (const server of [memoryServer, imageStudio]) {
|
|
116
|
+
if (!server) continue;
|
|
117
|
+
isolated.mcpServers ??= {};
|
|
118
|
+
isolated.mcpServers[server.name] = { command: server.command, args: server.args, env: server.env, trust: true };
|
|
119
|
+
}
|
|
120
|
+
return isolated;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function geminiImagePolicy(imageStudio) {
|
|
124
|
+
return `
|
|
125
|
+
[[rule]]
|
|
126
|
+
toolName = ["${imageStudio.tool}", "${imageStudio.name}__${imageStudio.tool}"]
|
|
127
|
+
decision = "allow"
|
|
128
|
+
priority = 1000
|
|
129
|
+
interactive = false
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir(), '.gemini'), imageStudio = null, memoryServer = null }) {
|
|
134
|
+
const geminiDir = join(runtimeRoot, '.gemini');
|
|
135
|
+
await mkdir(geminiDir, { recursive: true, mode: 0o700 });
|
|
136
|
+
for (const name of geminiCredentialFiles) {
|
|
137
|
+
await copyFile(join(sourceHome, name), join(geminiDir, name)).catch((error) => {
|
|
138
|
+
if (error.code !== 'ENOENT') throw error;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
let settings = {};
|
|
142
|
+
try {
|
|
143
|
+
settings = JSON.parse(await readFile(join(sourceHome, 'settings.json'), 'utf8'));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
146
|
+
}
|
|
147
|
+
await writeFile(join(geminiDir, 'settings.json'), JSON.stringify(isolateGeminiSettings(settings, { imageStudio, memoryServer })), { mode: 0o600 });
|
|
148
|
+
return geminiDir;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function usageFromStats(stats) {
|
|
152
|
+
const models = Object.values(stats?.models ?? {});
|
|
153
|
+
if (!models.length) return null;
|
|
154
|
+
const totals = models.reduce((usage, model) => {
|
|
155
|
+
// Two shapes exist: the older { tokens: { prompt, candidates, total, cached, thoughts } }
|
|
156
|
+
// and stream-json's flat { input_tokens, output_tokens, total_tokens, cached }.
|
|
157
|
+
const tokens = model?.tokens ?? model ?? {};
|
|
158
|
+
const input = tokens.prompt ?? tokens.input_tokens ?? tokens.input ?? 0;
|
|
159
|
+
const output = tokens.candidates ?? tokens.output_tokens ?? 0;
|
|
160
|
+
usage.inputTokens += input;
|
|
161
|
+
usage.cachedInputTokens += tokens.cached ?? 0;
|
|
162
|
+
usage.outputTokens += output;
|
|
163
|
+
usage.reasoningTokens += tokens.thoughts ?? 0;
|
|
164
|
+
usage.totalTokens += tokens.total ?? tokens.total_tokens ?? (input + output);
|
|
165
|
+
return usage;
|
|
166
|
+
}, { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0, source: 'gemini-json' });
|
|
167
|
+
return totals;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Accepts stream-json (JSONL events) and, for compatibility, the single-object
|
|
171
|
+
// output of `--output-format json`.
|
|
172
|
+
export function parseGeminiOutput(output) {
|
|
173
|
+
const trimmed = String(output ?? '').trim();
|
|
174
|
+
if (!trimmed) return { text: '', usage: null };
|
|
175
|
+
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
176
|
+
const events = [];
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
try { events.push(JSON.parse(line)); } catch { /* diagnostics between events */ }
|
|
179
|
+
}
|
|
180
|
+
if (events.length === 1 && !events[0].type && (events[0].response !== undefined || events[0].error || events[0].stats)) {
|
|
181
|
+
const result = events[0];
|
|
182
|
+
if (result.error) return { text: '', usage: null, error: result.error.message ?? 'Gemini returned an error.' };
|
|
183
|
+
return { text: typeof result.response === 'string' ? unescapeGeminiMentions(result.response.trim()) : '', usage: usageFromStats(result.stats) };
|
|
184
|
+
}
|
|
185
|
+
let text = '';
|
|
186
|
+
let usage = null;
|
|
187
|
+
let error = null;
|
|
188
|
+
let toolCalls = 0;
|
|
189
|
+
for (const event of events) {
|
|
190
|
+
if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string') text += event.content;
|
|
191
|
+
if (event.type === 'tool_use') toolCalls += 1;
|
|
192
|
+
if (event.type === 'error' || (event.type === 'result' && event.status && event.status !== 'success')) {
|
|
193
|
+
error = event.error?.message ?? event.message ?? `Gemini finished with status ${event.status ?? 'error'}.`;
|
|
194
|
+
}
|
|
195
|
+
if (event.type === 'result') usage = usageFromStats(event.stats);
|
|
196
|
+
}
|
|
197
|
+
return { text: unescapeGeminiMentions(text.trim()), usage, toolCalls, ...(error ? { error } : {}) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Best-effort removal of the temporary home. A killed Gemini may still be
|
|
201
|
+
// flushing files for a moment, which makes a single recursive rm fail with
|
|
202
|
+
// ENOTEMPTY; cleanup must never replace the real outcome of the turn.
|
|
203
|
+
export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs = 250 } = {}) {
|
|
204
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
205
|
+
try {
|
|
206
|
+
await rm(runtimeRoot, { recursive: true, force: true });
|
|
207
|
+
return true;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (attempt === attempts) {
|
|
210
|
+
console.error(`MADRE could not remove Gemini's temporary home ${runtimeRoot}: ${error.message}`);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Gemini CLI retries HTTP 429 (quota / rate limit) with exponential backoff
|
|
220
|
+
// and prints only stack traces to stderr while it waits. From the room that
|
|
221
|
+
// looked like a hang; naming it lets the human act on it.
|
|
222
|
+
export function diagnoseGeminiStderr(stderr) {
|
|
223
|
+
const text = String(stderr ?? '');
|
|
224
|
+
if (/prepayment credits are depleted/i.test(text)) {
|
|
225
|
+
return { code: 'CREDITS_DEPLETED', message: 'Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.', hint: 'Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.' };
|
|
226
|
+
}
|
|
227
|
+
if (/status:\s*429|\b429\b|RESOURCE_EXHAUSTED|rate ?limit|quota exceeded/i.test(text)) {
|
|
228
|
+
const router = /ClassifierStrategy|\.route\b/.test(text);
|
|
229
|
+
return {
|
|
230
|
+
code: 'RATE_LIMITED',
|
|
231
|
+
message: `Google is rate-limiting this Gemini key (HTTP 429)${router ? ' while its "auto" router picked a model' : ''}; the CLI kept retrying with backoff.`,
|
|
232
|
+
hint: 'Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
|
|
236
|
+
return { code: 'UNAVAILABLE', message: 'Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.', hint: 'Try again shortly or choose another model.' };
|
|
237
|
+
}
|
|
238
|
+
if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
|
|
239
|
+
return { code: 'AUTH', message: 'Google rejected the Gemini credentials.', hint: 'Run `gemini` and use /auth, or check GEMINI_API_KEY.' };
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Gemini is bimodal in practice: it answers in 10–60 s or never at all,
|
|
245
|
+
// printing nothing after startup. With stream-json every tool call and text
|
|
246
|
+
// delta is activity, so a long silence means a hang; one retry usually lands.
|
|
247
|
+
export async function invokeGemini({
|
|
248
|
+
executable,
|
|
249
|
+
projectRoot,
|
|
250
|
+
prompt,
|
|
251
|
+
timeoutMs = 120000,
|
|
252
|
+
signal,
|
|
253
|
+
model = null,
|
|
254
|
+
attachments = [],
|
|
255
|
+
lease = null,
|
|
256
|
+
scopes = null,
|
|
257
|
+
imageStudio = null,
|
|
258
|
+
memoryServer = null,
|
|
259
|
+
idleTimeoutMs = Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
|
|
260
|
+
retries = Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
|
|
261
|
+
fallbackModel = process.env.PULSE_GEMINI_FALLBACK_MODEL ?? 'gemini-2.5-flash',
|
|
262
|
+
failFastMs = 15000,
|
|
263
|
+
run = runReadonlyProcess,
|
|
264
|
+
}) {
|
|
265
|
+
const runtimeRoot = await mkdtemp(join(tmpdir(), 'pulse-gemini-'));
|
|
266
|
+
const policyPath = join(runtimeRoot, 'readonly.toml');
|
|
267
|
+
try {
|
|
268
|
+
await writeFile(policyPath, geminiPolicy({ lease, scopes, imageStudio: lease ? imageStudio : null, memoryServer }), { mode: 0o600 });
|
|
269
|
+
await prepareGeminiHome({ runtimeRoot, imageStudio: lease ? imageStudio : null, memoryServer });
|
|
270
|
+
let attempt = 0;
|
|
271
|
+
let currentModel = model;
|
|
272
|
+
let switched = false;
|
|
273
|
+
for (;;) {
|
|
274
|
+
attempt += 1;
|
|
275
|
+
// The CLI retries a 503 or 429 with backoff for minutes while stderr
|
|
276
|
+
// fills with stack traces. Read stderr as it comes and stop as soon as
|
|
277
|
+
// the condition is clear: credits/auth at once, capacity after a grace.
|
|
278
|
+
let firstSeenAt = null;
|
|
279
|
+
const watchStderr = (stderr) => {
|
|
280
|
+
const diagnosis = diagnoseGeminiStderr(stderr);
|
|
281
|
+
if (!diagnosis) return null;
|
|
282
|
+
firstSeenAt ??= Date.now();
|
|
283
|
+
const terminal = diagnosis.code === 'CREDITS_DEPLETED' || diagnosis.code === 'AUTH';
|
|
284
|
+
if (!terminal && Date.now() - firstSeenAt < failFastMs) return null;
|
|
285
|
+
return Object.assign(new Error(`${diagnosis.message} ${diagnosis.hint}`), { code: diagnosis.code, diagnosis });
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
return await run({
|
|
289
|
+
executable,
|
|
290
|
+
args: buildGeminiArgs({ projectRoot, prompt, policyPath, model: currentModel, attachmentsDir: attachments[0]?.dir ?? null, lease }),
|
|
291
|
+
cwd: runtimeRoot,
|
|
292
|
+
env: buildGeminiEnvironment({ runtimeRoot }),
|
|
293
|
+
timeoutMs,
|
|
294
|
+
idleTimeoutMs,
|
|
295
|
+
watchStderr,
|
|
296
|
+
signal,
|
|
297
|
+
label: 'Gemini',
|
|
298
|
+
parse: parseGeminiOutput,
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const produced = parseGeminiOutput(error.partialOutput ?? '').text;
|
|
302
|
+
const diagnosis = error.diagnosis ?? diagnoseGeminiStderr(error.partialStderr ?? error.stderr ?? '');
|
|
303
|
+
if (diagnosis?.code === 'AUTH' || diagnosis?.code === 'CREDITS_DEPLETED') {
|
|
304
|
+
error.message = `${diagnosis.message} ${diagnosis.hint}`;
|
|
305
|
+
error.code = diagnosis.code;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
// Capacity trouble (503) or a rate limit on the chosen model: one more
|
|
309
|
+
// try on a lighter, explicit model before giving up.
|
|
310
|
+
if ((diagnosis?.code === 'UNAVAILABLE' || diagnosis?.code === 'RATE_LIMITED') && !produced && !signal?.aborted && fallbackModel && !switched && currentModel !== fallbackModel) {
|
|
311
|
+
switched = true;
|
|
312
|
+
currentModel = fallbackModel;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (diagnosis?.code === 'UNAVAILABLE' || diagnosis?.code === 'RATE_LIMITED') {
|
|
316
|
+
error.message = `${diagnosis.message} ${diagnosis.hint}${switched ? ` (also tried ${fallbackModel})` : ''}`;
|
|
317
|
+
error.code = diagnosis.code;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
if (error.code === 'IDLE' && !produced && attempt <= retries && !signal?.aborted) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (diagnosis) error.message = `${error.message} ${diagnosis.message} ${diagnosis.hint}`;
|
|
324
|
+
else if ((error.code === 'TIMEOUT' || error.code === 'IDLE') && error.partialStderr?.trim()) {
|
|
325
|
+
const lastStderr = error.partialStderr.trim().split('\n').filter(Boolean).at(-1);
|
|
326
|
+
if (lastStderr) error.message = `${error.message} stderr: ${lastStderr.slice(0, 200)}`;
|
|
327
|
+
}
|
|
328
|
+
if (attempt > 1) error.message = `${error.message} (retried ${attempt - 1}×)`;
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
void cleanupRuntimeRoot(runtimeRoot);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { runReadonlyProcess } from './process.mjs';
|
|
2
|
+
|
|
3
|
+
const readonlyConfig = {
|
|
4
|
+
share: 'disabled',
|
|
5
|
+
agent: {
|
|
6
|
+
'pulse-readonly': {
|
|
7
|
+
description: 'MADRE project consultation without mutations',
|
|
8
|
+
mode: 'primary',
|
|
9
|
+
prompt: 'Answer the user directly. Inspect project files only when necessary. Never modify files, run commands, browse the web, or launch subagents.',
|
|
10
|
+
permission: {
|
|
11
|
+
'*': 'deny',
|
|
12
|
+
read: 'allow',
|
|
13
|
+
glob: 'allow',
|
|
14
|
+
grep: 'allow',
|
|
15
|
+
list: 'allow',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Optional provider/model override (e.g. "openai/gpt-5.6-sol"). Without it,
|
|
22
|
+
// OpenCode picks its own default provider, which may not be the one the user
|
|
23
|
+
// actually signed in to.
|
|
24
|
+
export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PULSE_OPENCODE_MODEL, attachments = [] }) {
|
|
25
|
+
return [
|
|
26
|
+
'--pure',
|
|
27
|
+
'run',
|
|
28
|
+
'--format', 'json',
|
|
29
|
+
'--agent', 'pulse-readonly',
|
|
30
|
+
...(model ? ['--model', model] : []),
|
|
31
|
+
...attachments.flatMap((file) => ['--file', file.path]),
|
|
32
|
+
'--dir', projectRoot,
|
|
33
|
+
prompt,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function leaseConfig(outDir, { control = false } = {}) {
|
|
38
|
+
const forbidden = control ? Object.fromEntries(['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].map((glob) => [`${outDir}/${glob}`, 'deny'])) : {};
|
|
39
|
+
return {
|
|
40
|
+
...readonlyConfig,
|
|
41
|
+
agent: {
|
|
42
|
+
'pulse-readonly': {
|
|
43
|
+
...readonlyConfig.agent['pulse-readonly'],
|
|
44
|
+
prompt: control
|
|
45
|
+
? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse and .env files. Do not run commands, browse the web, or launch subagents.'
|
|
46
|
+
: 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
|
|
47
|
+
permission: {
|
|
48
|
+
...readonlyConfig.agent['pulse-readonly'].permission,
|
|
49
|
+
edit: { '*': 'deny', [`${outDir}/**`]: 'allow', ...forbidden },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
|
|
57
|
+
let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control) }) : readonlyConfig;
|
|
58
|
+
if (scopes?.web) {
|
|
59
|
+
config = {
|
|
60
|
+
...config,
|
|
61
|
+
agent: {
|
|
62
|
+
'pulse-readonly': {
|
|
63
|
+
...config.agent['pulse-readonly'],
|
|
64
|
+
prompt: config.agent['pulse-readonly'].prompt.replace('browse the web, ', ''),
|
|
65
|
+
permission: { ...config.agent['pulse-readonly'].permission, webfetch: 'allow', websearch: 'allow' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (imageStudio && lease) {
|
|
71
|
+
config = {
|
|
72
|
+
...config,
|
|
73
|
+
mcp: { ...(config.mcp ?? {}), [imageStudio.name]: { type: 'local', command: [imageStudio.command, ...imageStudio.args], environment: imageStudio.env, enabled: true } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (memoryServer) {
|
|
77
|
+
config = {
|
|
78
|
+
...config,
|
|
79
|
+
mcp: { ...(config.mcp ?? {}), [memoryServer.name]: { type: 'local', command: [memoryServer.command, ...memoryServer.args], environment: memoryServer.env, enabled: true } },
|
|
80
|
+
agent: {
|
|
81
|
+
'pulse-readonly': {
|
|
82
|
+
...config.agent['pulse-readonly'],
|
|
83
|
+
permission: {
|
|
84
|
+
...config.agent['pulse-readonly'].permission,
|
|
85
|
+
[`${memoryServer.name}*`]: 'allow',
|
|
86
|
+
...Object.fromEntries(memoryServer.tools.map((tool) => [`${memoryServer.name}_${tool}`, 'allow'])),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function openCodeEnvironment(environment = process.env, { lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
|
|
96
|
+
return {
|
|
97
|
+
...environment,
|
|
98
|
+
OPENCODE_AUTO_SHARE: 'false',
|
|
99
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfig({ lease, scopes, imageStudio, memoryServer })),
|
|
100
|
+
OPENCODE_DISABLE_AUTOUPDATE: 'true',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseOpenCodeOutput(output) {
|
|
105
|
+
const text = [];
|
|
106
|
+
let usage = null;
|
|
107
|
+
let error = null;
|
|
108
|
+
for (const line of output.split('\n').filter(Boolean)) {
|
|
109
|
+
try {
|
|
110
|
+
const event = JSON.parse(line);
|
|
111
|
+
if (event.type === 'error' && event.error) {
|
|
112
|
+
const detail = event.error.data?.message ?? event.error.message ?? '';
|
|
113
|
+
error = [event.error.name, detail].filter(Boolean).join(': ') || 'OpenCode returned an error.';
|
|
114
|
+
}
|
|
115
|
+
if (event.type === 'text' && typeof event.part?.text === 'string') {
|
|
116
|
+
text.push(event.part.text);
|
|
117
|
+
}
|
|
118
|
+
if (event.type === 'step_finish' && event.part?.tokens) {
|
|
119
|
+
const tokens = event.part.tokens;
|
|
120
|
+
usage = {
|
|
121
|
+
inputTokens: tokens.input ?? 0,
|
|
122
|
+
cachedInputTokens: tokens.cache?.read ?? 0,
|
|
123
|
+
outputTokens: tokens.output ?? 0,
|
|
124
|
+
reasoningTokens: tokens.reasoning ?? 0,
|
|
125
|
+
totalTokens: tokens.total ?? ((tokens.input ?? 0) + (tokens.output ?? 0)),
|
|
126
|
+
costUsd: event.part.cost ?? null,
|
|
127
|
+
source: 'opencode-json',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Ignore non-event diagnostic lines; stderr is reported when the process fails.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
|
|
138
|
+
return runReadonlyProcess({
|
|
139
|
+
executable,
|
|
140
|
+
args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
|
|
141
|
+
cwd: projectRoot,
|
|
142
|
+
env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer }),
|
|
143
|
+
timeoutMs,
|
|
144
|
+
signal,
|
|
145
|
+
label: 'OpenCode',
|
|
146
|
+
parse: parseOpenCodeOutput,
|
|
147
|
+
});
|
|
148
|
+
}
|