@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,263 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { access, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// Optional room modules. Built-ins only change MADRE's local configuration;
|
|
9
|
+
// the external AHP+ installer can write into the selected project, so it
|
|
10
|
+
// requires explicit confirmation and records that action in the durable log.
|
|
11
|
+
|
|
12
|
+
async function readJson(path) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// AHP+ platform names for the agents MADRE knows about. Gemini has no AHP+
|
|
21
|
+
// adapter yet, so it is simply not requested.
|
|
22
|
+
const AHP_PLATFORMS = { codex: 'codex', claude: 'claude', opencode: 'opencode' };
|
|
23
|
+
|
|
24
|
+
// Where does git think this project lives? AHP+ pins itself to the git root,
|
|
25
|
+
// so a project inside a bigger repository (or a home directory that was
|
|
26
|
+
// accidentally `git init`ed) would receive .ahp/ somewhere else entirely.
|
|
27
|
+
export async function gitToplevel(projectRoot) {
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
|
|
30
|
+
return await realpath(stdout.trim()).catch(() => stdout.trim());
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function findOnPath(name, envPath = process.env.PATH ?? '') {
|
|
37
|
+
for (const directory of envPath.split(delimiter).filter(Boolean)) {
|
|
38
|
+
const candidate = join(directory, name);
|
|
39
|
+
if (await access(candidate).then(() => true, () => false)) return candidate;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const EXTENSIONS = [
|
|
45
|
+
{
|
|
46
|
+
id: 'ahp',
|
|
47
|
+
name: 'AHP+',
|
|
48
|
+
vendor: 'Agent Handoff Protocol Plus',
|
|
49
|
+
package: '@jossuealcala/ahp-plus',
|
|
50
|
+
version: '1.4.1',
|
|
51
|
+
summary: 'Verified project state, checkpoints and handoffs between AI sessions, stored in .ahp/ next to your code.',
|
|
52
|
+
creates: ['.ahp/ with manifest, sessions, handoffs and evidence', 'a project-local pin of @jossuealcala/ahp-plus', 'IDE adapter files for the detected agents'],
|
|
53
|
+
async detect(projectRoot) {
|
|
54
|
+
const manifest = await readJson(join(projectRoot, '.ahp', 'manifest.json'));
|
|
55
|
+
if (!manifest) return { installed: false };
|
|
56
|
+
const pinned = await readJson(join(projectRoot, 'node_modules', '@jossuealcala', 'ahp-plus', 'package.json'));
|
|
57
|
+
return {
|
|
58
|
+
installed: true,
|
|
59
|
+
version: pinned?.version ?? null,
|
|
60
|
+
protocolVersion: manifest.protocol_version ?? null,
|
|
61
|
+
projectId: manifest.project_id ?? null,
|
|
62
|
+
detail: [pinned?.version ? `cli ${pinned.version}` : null, manifest.protocol_version ? `protocol ${manifest.protocol_version}` : null].filter(Boolean).join(' · '),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
// Refuses before anything is written when the outcome would be wrong.
|
|
66
|
+
async preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
|
|
67
|
+
const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
68
|
+
const problems = [];
|
|
69
|
+
if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
|
|
70
|
+
const top = await toplevel(root);
|
|
71
|
+
if (!top) {
|
|
72
|
+
problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
|
|
73
|
+
} else if (top !== root) {
|
|
74
|
+
problems.push(`The project's git root is ${top}, not the project itself. AHP+ would install there. Run \`git init\` inside the project, or remove the stray repository at ${top}.`);
|
|
75
|
+
}
|
|
76
|
+
return { ok: problems.length === 0, problems, gitRoot: top };
|
|
77
|
+
},
|
|
78
|
+
installCommand({ agents = [] }) {
|
|
79
|
+
const platforms = agents
|
|
80
|
+
.filter((agent) => agent.detected && AHP_PLATFORMS[agent.id])
|
|
81
|
+
.map((agent) => AHP_PLATFORMS[agent.id]);
|
|
82
|
+
const args = ['--yes', `${this.package}@${this.version}`, 'setup', '.'];
|
|
83
|
+
if (platforms.length) args.push('--platforms', platforms.join(','));
|
|
84
|
+
return { command: 'npx', args, platforms, display: ['npx', ...args].join(' ') };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// Built-in module: no project write, no npm. "Installing" it enables MADRE's own
|
|
90
|
+
// MCP image server for the CLIs that cannot generate images natively.
|
|
91
|
+
export const IMAGE_STUDIO = {
|
|
92
|
+
id: 'image-studio',
|
|
93
|
+
kind: 'builtin',
|
|
94
|
+
name: 'Image Studio',
|
|
95
|
+
vendor: 'MADRE · Gemini API',
|
|
96
|
+
package: null,
|
|
97
|
+
version: '0.1.0',
|
|
98
|
+
summary: 'Gives Gemini CLI, Claude Code and OpenCode an image-generation tool through a MADRE-owned MCP server on the Gemini API image models, using your own Gemini key and credits. Attached only inside a creation lease with the image scope on.',
|
|
99
|
+
creates: ['nothing in the project: images land in the lease directory like any artifact', 'an "image-studio" entry in ~/.pulse/config.json', 'an MCP server process per turn, started and stopped by the CLI'],
|
|
100
|
+
requires: ['a Gemini API key with credits (the key the Gemini CLI stores, or GEMINI_API_KEY)'],
|
|
101
|
+
models: ['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'],
|
|
102
|
+
};
|
|
103
|
+
EXTENSIONS.push(IMAGE_STUDIO);
|
|
104
|
+
|
|
105
|
+
// Git Pulse: read-only repository facts in the room via /git. Nothing to
|
|
106
|
+
// install; it is on wherever the project is a git repository.
|
|
107
|
+
export const GIT_PULSE = {
|
|
108
|
+
id: 'git-pulse',
|
|
109
|
+
kind: 'builtin',
|
|
110
|
+
name: 'Git Pulse',
|
|
111
|
+
vendor: 'MADRE',
|
|
112
|
+
package: null,
|
|
113
|
+
version: '0.1.0',
|
|
114
|
+
summary: 'Type /git in the composer to bring the repository\'s branch, uncommitted changes, recent commits or diff stats into the room as a shared fact card, without spending an agent turn.',
|
|
115
|
+
creates: ['nothing: read-only git commands run inside the project'],
|
|
116
|
+
requires: ['the project is a git repository'],
|
|
117
|
+
models: [],
|
|
118
|
+
commands: ['/git status', '/git log [n]', '/git diff', '/git branches'],
|
|
119
|
+
};
|
|
120
|
+
EXTENSIONS.push(GIT_PULSE);
|
|
121
|
+
|
|
122
|
+
export const ASHCODE = {
|
|
123
|
+
id: 'ashcode',
|
|
124
|
+
kind: 'builtin',
|
|
125
|
+
name: 'AshCode',
|
|
126
|
+
vendor: 'MADRE · ORDER 937',
|
|
127
|
+
package: null,
|
|
128
|
+
version: '0.1.0-beta',
|
|
129
|
+
summary: 'Opt-in Spanish/English prompt and reply abbreviation. Keeps the original visible; may change meaning. Shorter characters do not guarantee fewer provider tokens.',
|
|
130
|
+
creates: ['nothing in the project', 'an ashCode switch in ~/.pulse/config.json', 'original and abbreviated text in the room event log when applied'],
|
|
131
|
+
requires: [],
|
|
132
|
+
models: [],
|
|
133
|
+
};
|
|
134
|
+
EXTENSIONS.push(ASHCODE);
|
|
135
|
+
|
|
136
|
+
// RIPLEY: the file viewer renders HTML, SVG and Markdown instead of showing
|
|
137
|
+
// their source, inside a sealed frame. A switch in config.json, nothing else.
|
|
138
|
+
export const RIPLEY = {
|
|
139
|
+
id: 'ripley',
|
|
140
|
+
kind: 'builtin',
|
|
141
|
+
name: 'RIPLEY',
|
|
142
|
+
vendor: 'MADRE · PREVIEW',
|
|
143
|
+
package: null,
|
|
144
|
+
version: '0.1.0',
|
|
145
|
+
summary: 'Renders HTML, SVG and Markdown from the project and from .pulse/out in the file viewer, inside a sealed frame: no scripts, no network, no storage. Nothing leaves the room.',
|
|
146
|
+
creates: ['nothing in the project', 'a ripley switch in ~/.pulse/config.json'],
|
|
147
|
+
requires: [],
|
|
148
|
+
models: [],
|
|
149
|
+
};
|
|
150
|
+
EXTENSIONS.push(RIPLEY);
|
|
151
|
+
|
|
152
|
+
export const extensionById = (id) => EXTENSIONS.find((extension) => extension.id === id) ?? null;
|
|
153
|
+
|
|
154
|
+
export async function listExtensions({ projectRoot, agents = [], config = {}, imageKey = async () => null }) {
|
|
155
|
+
return Promise.all(EXTENSIONS.map(async (extension) => {
|
|
156
|
+
if (extension.id === 'git-pulse') {
|
|
157
|
+
const isRepo = await gitToplevel(projectRoot);
|
|
158
|
+
return {
|
|
159
|
+
id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
|
|
160
|
+
summary: extension.summary, creates: extension.creates, requires: extension.requires, models: [], commands: extension.commands,
|
|
161
|
+
status: { installed: Boolean(isRepo), detail: isRepo ? 'on · project is a git repository' : 'not a git repository' },
|
|
162
|
+
preflight: isRepo ? { ok: true, problems: [] } : { ok: false, problems: ['Run `git init` in the project to use /git.'] },
|
|
163
|
+
install: { display: '/git in the composer', platforms: [] },
|
|
164
|
+
fixed: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (extension.id === 'ashcode') {
|
|
168
|
+
const enabled = Boolean(config.modules?.ashCode?.enabled);
|
|
169
|
+
return {
|
|
170
|
+
id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
|
|
171
|
+
summary: extension.summary, creates: extension.creates, requires: [], models: [],
|
|
172
|
+
status: { installed: enabled, detail: enabled ? 'on · beta · ORDER 937 available' : 'off · beta' },
|
|
173
|
+
preflight: { ok: true, problems: [] },
|
|
174
|
+
install: { display: enabled ? 'disable AshCode' : 'enable AshCode (config.json)', platforms: [] },
|
|
175
|
+
warning: 'Beta: abbreviation may alter meaning or introduce errors. Review the original. Character reduction is not verified token savings.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (extension.id === 'ripley') {
|
|
179
|
+
const enabled = Boolean(config.modules?.ripley?.enabled);
|
|
180
|
+
return {
|
|
181
|
+
id: extension.id, kind: 'builtin', name: extension.name, vendor: extension.vendor, package: null, version: extension.version,
|
|
182
|
+
summary: extension.summary, creates: extension.creates, requires: [], models: [],
|
|
183
|
+
status: { installed: enabled, detail: enabled ? 'on · PREVIEW in the file viewer' : 'off · files show as source' },
|
|
184
|
+
preflight: { ok: true, problems: [] },
|
|
185
|
+
install: { display: enabled ? 'disable RIPLEY' : 'enable RIPLEY (config.json)', platforms: [] },
|
|
186
|
+
fixed: false,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (extension.kind === 'builtin') {
|
|
190
|
+
const enabled = Boolean(config.modules?.imageStudio?.enabled);
|
|
191
|
+
const key = await imageKey();
|
|
192
|
+
return {
|
|
193
|
+
id: extension.id,
|
|
194
|
+
kind: 'builtin',
|
|
195
|
+
name: extension.name,
|
|
196
|
+
vendor: extension.vendor,
|
|
197
|
+
package: null,
|
|
198
|
+
version: extension.version,
|
|
199
|
+
summary: extension.summary,
|
|
200
|
+
creates: extension.creates,
|
|
201
|
+
requires: extension.requires,
|
|
202
|
+
models: extension.models,
|
|
203
|
+
model: config.modules?.imageStudio?.model ?? extension.models[0],
|
|
204
|
+
status: { installed: enabled, detail: enabled ? `on · ${config.modules?.imageStudio?.model ?? extension.models[0]}${key ? '' : ' · no Gemini key found'}` : key ? 'key found' : 'no Gemini key found' },
|
|
205
|
+
preflight: key ? { ok: true, problems: [] } : { ok: false, problems: ['No Gemini API key: sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY. Image models bill against that key\'s AI Studio credits.'] },
|
|
206
|
+
install: { display: enabled ? 'disable Image Studio' : 'enable Image Studio (config.json)', platforms: ['gemini', 'claude', 'opencode'] },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const status = await extension.detect(projectRoot);
|
|
210
|
+
const plan = extension.installCommand({ agents });
|
|
211
|
+
const preflight = extension.preflight ? await extension.preflight(projectRoot) : { ok: true, problems: [] };
|
|
212
|
+
return {
|
|
213
|
+
id: extension.id,
|
|
214
|
+
name: extension.name,
|
|
215
|
+
vendor: extension.vendor,
|
|
216
|
+
package: extension.package,
|
|
217
|
+
version: extension.version,
|
|
218
|
+
summary: extension.summary,
|
|
219
|
+
creates: extension.creates,
|
|
220
|
+
status,
|
|
221
|
+
preflight,
|
|
222
|
+
install: { display: plan.display, platforms: plan.platforms },
|
|
223
|
+
};
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Runs one installer inside the project, streaming output lines. `runner`
|
|
228
|
+
// lets tests substitute the real installer with a local script.
|
|
229
|
+
export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env }) {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
232
|
+
const started = Date.now();
|
|
233
|
+
let lastOutput = started;
|
|
234
|
+
// Installers go quiet for long stretches (npm install, git status); say so.
|
|
235
|
+
const heartbeat = setInterval(() => {
|
|
236
|
+
if (Date.now() - lastOutput >= heartbeatMs) {
|
|
237
|
+
onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s · installer is quiet (npm install or git status can take a while)`, 'heartbeat');
|
|
238
|
+
lastOutput = Date.now();
|
|
239
|
+
}
|
|
240
|
+
}, Math.min(heartbeatMs, 2000));
|
|
241
|
+
heartbeat.unref?.();
|
|
242
|
+
let buffer = '';
|
|
243
|
+
const feed = (chunk, stream) => {
|
|
244
|
+
buffer += chunk;
|
|
245
|
+
let index;
|
|
246
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
247
|
+
const line = buffer.slice(0, index).replace(/\r$/, '');
|
|
248
|
+
buffer = buffer.slice(index + 1);
|
|
249
|
+
if (line.trim()) { lastOutput = Date.now(); onLine?.(line, stream); }
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
|
|
253
|
+
child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
|
|
254
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs);
|
|
255
|
+
child.on('error', (error) => { clearTimeout(timer); clearInterval(heartbeat); resolve({ code: 1, error: error.code === 'ENOENT' ? `${error.message} (is the project folder present and ${command} on PATH?)` : error.message }); });
|
|
256
|
+
child.on('close', (code) => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
clearInterval(heartbeat);
|
|
259
|
+
if (buffer.trim()) onLine?.(buffer.trim(), 'stdout');
|
|
260
|
+
resolve({ code: code ?? 1 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Read-only file access for the room UI, and human attachments.
|
|
6
|
+
//
|
|
7
|
+
// Two roots are servable: the project (read-only, so agents' references to
|
|
8
|
+
// files and images render inline) and the room's own attachments folder under
|
|
9
|
+
// PULSE_HOME (where the human's uploads live, never inside the project).
|
|
10
|
+
|
|
11
|
+
export const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const TYPES = {
|
|
15
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif',
|
|
16
|
+
'.pdf': 'application/pdf',
|
|
17
|
+
'.md': 'text/markdown', '.txt': 'text/plain', '.json': 'application/json', '.jsonl': 'application/x-ndjson', '.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/plain',
|
|
18
|
+
'.js': 'text/javascript', '.mjs': 'text/javascript', '.cjs': 'text/javascript', '.ts': 'text/plain', '.tsx': 'text/plain', '.jsx': 'text/plain',
|
|
19
|
+
'.py': 'text/plain', '.rb': 'text/plain', '.go': 'text/plain', '.rs': 'text/plain', '.java': 'text/plain', '.kt': 'text/plain', '.swift': 'text/plain', '.c': 'text/plain', '.h': 'text/plain', '.cpp': 'text/plain',
|
|
20
|
+
'.sh': 'text/plain', '.zsh': 'text/plain', '.css': 'text/css', '.html': 'text/plain', '.xml': 'text/plain', '.csv': 'text/csv', '.sql': 'text/plain', '.env': 'text/plain',
|
|
21
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
22
|
+
};
|
|
23
|
+
export const contentTypeFor = (name) => TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
24
|
+
export const isImage = (name) => contentTypeFor(name).startsWith('image/');
|
|
25
|
+
export const isText = (name) => /^(text\/|application\/(json|x-ndjson))/.test(contentTypeFor(name));
|
|
26
|
+
|
|
27
|
+
// Resolves `relative` inside `root`, refusing anything that escapes it (../,
|
|
28
|
+
// absolute paths, symlinks pointing outside). Returns null when refused.
|
|
29
|
+
export async function resolveInside(root, relative) {
|
|
30
|
+
if (typeof relative !== 'string' || !relative || relative.includes('\0')) return null;
|
|
31
|
+
const canonicalRoot = await realpath(root).catch(() => null);
|
|
32
|
+
if (!canonicalRoot) return null;
|
|
33
|
+
const candidate = resolve(canonicalRoot, relative);
|
|
34
|
+
if (candidate !== canonicalRoot && !candidate.startsWith(canonicalRoot + sep)) return null;
|
|
35
|
+
const canonical = await realpath(candidate).catch(() => null);
|
|
36
|
+
if (!canonical || (canonical !== canonicalRoot && !canonical.startsWith(canonicalRoot + sep))) return null;
|
|
37
|
+
return canonical;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// One level of the project tree, for the room's file panel. Directories
|
|
41
|
+
// first, then files, both alphabetical; .git is never listed and node_modules
|
|
42
|
+
// is shown but not walked. Read-only and fenced to the project root.
|
|
43
|
+
const SKIP = new Set(['.git', '.DS_Store']);
|
|
44
|
+
// Folders the search walk skips (the tree still opens them one level at a time).
|
|
45
|
+
const SHALLOW = new Set(['node_modules', '.venv', 'venv', 'dist', 'build', '.next', '.git']);
|
|
46
|
+
export async function listDirectory(root, relative = '.') {
|
|
47
|
+
const path = await resolveInside(root, relative || '.');
|
|
48
|
+
if (!path) return { status: 404, error: 'Not found.' };
|
|
49
|
+
let entries;
|
|
50
|
+
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { status: 404, error: 'Not a directory.' }; }
|
|
51
|
+
const items = [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (SKIP.has(entry.name)) continue;
|
|
54
|
+
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && (await stat(join(path, entry.name)).catch(() => null))?.isDirectory());
|
|
55
|
+
const item = { name: entry.name, kind: isDir ? 'dir' : 'file' };
|
|
56
|
+
if (isDir && SHALLOW.has(entry.name)) item.shallow = true;
|
|
57
|
+
if (!isDir) {
|
|
58
|
+
const info = await stat(join(path, entry.name)).catch(() => null);
|
|
59
|
+
if (info) item.size = info.size;
|
|
60
|
+
item.contentType = contentTypeFor(entry.name);
|
|
61
|
+
}
|
|
62
|
+
items.push(item);
|
|
63
|
+
}
|
|
64
|
+
items.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) : a.kind === 'dir' ? -1 : 1));
|
|
65
|
+
return { status: 200, entries: items.slice(0, 500), truncated: items.length > 500 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fuzzy-ish file search for the "!" menu in the composer: every path under
|
|
69
|
+
// the project (same fences as the tree, shallow folders skipped) scored by
|
|
70
|
+
// substring and initials match. The walk is cached for a short while.
|
|
71
|
+
const walkCache = new Map(); // root -> { at, files }
|
|
72
|
+
export async function walkProject(root, { maxFiles = 20000, ttlMs = 15000 } = {}) {
|
|
73
|
+
const cached = walkCache.get(root);
|
|
74
|
+
if (cached && Date.now() - cached.at < ttlMs) return cached.files;
|
|
75
|
+
const files = [];
|
|
76
|
+
async function walk(dir, prefix, depth) {
|
|
77
|
+
if (files.length >= maxFiles || depth > 14) return;
|
|
78
|
+
let entries = [];
|
|
79
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (SKIP.has(entry.name)) continue;
|
|
82
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
83
|
+
if (entry.isDirectory()) { if (!SHALLOW.has(entry.name)) await walk(join(dir, entry.name), rel, depth + 1); continue; }
|
|
84
|
+
if (entry.isFile()) files.push(rel);
|
|
85
|
+
if (files.length >= maxFiles) return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await walk(root, '', 0);
|
|
89
|
+
walkCache.set(root, { at: Date.now(), files });
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function scoreFile(path, query) {
|
|
94
|
+
const q = query.toLowerCase();
|
|
95
|
+
if (!q) return 1;
|
|
96
|
+
const lower = path.toLowerCase();
|
|
97
|
+
const name = lower.split('/').pop();
|
|
98
|
+
if (name === q) return 100;
|
|
99
|
+
if (name.startsWith(q)) return 80 - Math.min(name.length - q.length, 30);
|
|
100
|
+
if (name.includes(q)) return 60 - Math.min(name.indexOf(q), 30);
|
|
101
|
+
if (lower.includes(q)) return 40 - Math.min(lower.length / 10, 20);
|
|
102
|
+
// subsequence match ("rmjs" -> room.mjs)
|
|
103
|
+
let index = 0;
|
|
104
|
+
for (const char of q) { index = lower.indexOf(char, index); if (index < 0) return 0; index += 1; }
|
|
105
|
+
return 10 - Math.min(lower.length / 20, 9);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function searchFiles(root, query, { limit = 20 } = {}) {
|
|
109
|
+
const files = await walkProject(root);
|
|
110
|
+
return files
|
|
111
|
+
.map((path) => ({ path, score: scoreFile(path, String(query ?? '').trim()) }))
|
|
112
|
+
.filter((item) => item.score > 0)
|
|
113
|
+
.sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
|
|
114
|
+
.slice(0, limit)
|
|
115
|
+
.map((item) => ({ path: item.path, name: item.path.split('/').pop(), contentType: contentTypeFor(item.path) }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// "!src/room.mjs:12-20" in a human message points an agent at a project file
|
|
119
|
+
// (optionally a line range). Only files that exist inside the project count.
|
|
120
|
+
export const REFERENCE = /(^|[\s(,;:])!((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?::(\d+)(?:-(\d+))?)?(?![\w/])/g;
|
|
121
|
+
export async function resolveReferences(root, text, { maxLines = 120 } = {}) {
|
|
122
|
+
const found = [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const match of String(text ?? '').matchAll(REFERENCE)) {
|
|
125
|
+
const rel = match[2];
|
|
126
|
+
const key = `${rel}:${match[3] ?? ''}-${match[4] ?? ''}`;
|
|
127
|
+
if (seen.has(key)) continue;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
const path = await resolveInside(root, rel);
|
|
130
|
+
if (!path) continue;
|
|
131
|
+
const info = await stat(path).catch(() => null);
|
|
132
|
+
if (!info || !info.isFile()) continue;
|
|
133
|
+
const reference = { path: rel, size: info.size, contentType: contentTypeFor(rel) };
|
|
134
|
+
if (match[3]) {
|
|
135
|
+
const from = Number(match[3]);
|
|
136
|
+
const to = match[4] ? Math.max(from, Number(match[4])) : from;
|
|
137
|
+
reference.lines = { from, to };
|
|
138
|
+
if (isText(rel) && info.size <= 2 * 1024 * 1024) {
|
|
139
|
+
const all = (await readFile(path, 'utf8')).split('\n');
|
|
140
|
+
reference.excerpt = all.slice(from - 1, Math.min(to, from - 1 + maxLines)).map((line, index) => `${String(from + index).padStart(4)} | ${line}`).join('\n');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
found.push(reference);
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function readServable(root, relative, { maxBytes = MAX_FILE_BYTES } = {}) {
|
|
149
|
+
const path = await resolveInside(root, relative);
|
|
150
|
+
if (!path) return { status: 404, error: 'Not found.' };
|
|
151
|
+
const info = await stat(path).catch(() => null);
|
|
152
|
+
if (!info || !info.isFile()) return { status: 404, error: 'Not found.' };
|
|
153
|
+
if (info.size > maxBytes) return { status: 413, error: `File is larger than ${Math.round(maxBytes / 1024 / 1024)} MB.` };
|
|
154
|
+
const body = await readFile(path);
|
|
155
|
+
return { status: 200, body, contentType: contentTypeFor(path), size: info.size, path };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const safeName = (name) => String(name ?? 'file').replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
|
|
159
|
+
|
|
160
|
+
export async function storeAttachment(attachmentsRoot, { name, bytes }) {
|
|
161
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw new Error('Attachment is empty.');
|
|
162
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES) throw new Error(`Attachment is larger than ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)} MB.`);
|
|
163
|
+
await mkdir(attachmentsRoot, { recursive: true });
|
|
164
|
+
const id = randomUUID();
|
|
165
|
+
const fileName = `${id.slice(0, 8)}-${safeName(name)}`;
|
|
166
|
+
const path = join(attachmentsRoot, fileName);
|
|
167
|
+
await writeFile(path, bytes, { mode: 0o600 });
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
name: safeName(name),
|
|
171
|
+
fileName,
|
|
172
|
+
path,
|
|
173
|
+
size: bytes.length,
|
|
174
|
+
contentType: contentTypeFor(fileName),
|
|
175
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { resolveGeminiKey } from './mcp/image-server.mjs';
|
|
3
|
+
|
|
4
|
+
// How MADRE attaches its image MCP server to a CLI during a lease.
|
|
5
|
+
export const IMAGE_SERVER_PATH = fileURLToPath(new URL('./mcp/image-server.mjs', import.meta.url));
|
|
6
|
+
export const IMAGE_SERVER_NAME = 'pulse-image';
|
|
7
|
+
export const IMAGE_TOOL = 'generate_image';
|
|
8
|
+
|
|
9
|
+
export function imageStudioFor({ enabled, model, outDir, env = process.env }) {
|
|
10
|
+
if (!enabled || !outDir) return null;
|
|
11
|
+
return {
|
|
12
|
+
name: IMAGE_SERVER_NAME,
|
|
13
|
+
tool: IMAGE_TOOL,
|
|
14
|
+
command: process.execPath,
|
|
15
|
+
args: [IMAGE_SERVER_PATH],
|
|
16
|
+
env: {
|
|
17
|
+
PULSE_IMAGE_OUT_DIR: outDir,
|
|
18
|
+
PULSE_IMAGE_MODEL: model ?? 'gemini-2.5-flash-image',
|
|
19
|
+
...(env.GEMINI_API_KEY ? { GEMINI_API_KEY: env.GEMINI_API_KEY } : {}),
|
|
20
|
+
...(env.PULSE_IMAGE_FAKE ? { PULSE_IMAGE_FAKE: env.PULSE_IMAGE_FAKE } : {}),
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { resolveGeminiKey };
|
package/src/lease.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { contentTypeFor } from './files.mjs';
|
|
4
|
+
|
|
5
|
+
// A creation lease is the human saying, for one message (and the plan it may
|
|
6
|
+
// start): "these agents may create files, here". MADRE makes a fresh
|
|
7
|
+
// directory under <project>/.pulse/out/, points every adapter's write scope at
|
|
8
|
+
// it, and afterwards reports exactly what appeared there as artifacts.
|
|
9
|
+
//
|
|
10
|
+
// Nothing else in the project becomes writable. The lease directory is
|
|
11
|
+
// MADRE's own; add `.pulse/` to the project's ignore file if you do not want
|
|
12
|
+
// artifacts committed.
|
|
13
|
+
|
|
14
|
+
export const LEASE_ROOT = join('.pulse', 'out');
|
|
15
|
+
|
|
16
|
+
const stamp = () => {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
19
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function createLease({ projectRoot, leaseId }) {
|
|
23
|
+
const name = `${stamp()}-${leaseId.slice(0, 8)}`;
|
|
24
|
+
const relativeDir = join(LEASE_ROOT, name);
|
|
25
|
+
const outDir = join(projectRoot, relativeDir);
|
|
26
|
+
await mkdir(outDir, { recursive: true, mode: 0o755 });
|
|
27
|
+
return { leaseId, outDir, relativeDir };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// name → { size, mtimeMs } for every file under `dir`, recursively.
|
|
31
|
+
export async function snapshot(dir) {
|
|
32
|
+
const files = new Map();
|
|
33
|
+
async function walk(current) {
|
|
34
|
+
let entries = [];
|
|
35
|
+
try { entries = await readdir(current, { withFileTypes: true }); } catch { return; }
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const path = join(current, entry.name);
|
|
38
|
+
if (entry.isDirectory()) { await walk(path); continue; }
|
|
39
|
+
if (!entry.isFile()) continue;
|
|
40
|
+
const info = await stat(path).catch(() => null);
|
|
41
|
+
if (info) files.set(relative(dir, path).split(sep).join('/'), { size: info.size, mtimeMs: info.mtimeMs });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await walk(dir);
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Files that are new or changed between two snapshots, as artifacts the room
|
|
49
|
+
// can show: paths are relative to the project so /api/files can serve them.
|
|
50
|
+
export function diffSnapshots(before, after, { relativeDir }) {
|
|
51
|
+
const artifacts = [];
|
|
52
|
+
for (const [name, info] of after) {
|
|
53
|
+
const previous = before.get(name);
|
|
54
|
+
if (previous && previous.size === info.size && previous.mtimeMs === info.mtimeMs) continue;
|
|
55
|
+
artifacts.push({
|
|
56
|
+
name: name.split('/').pop(),
|
|
57
|
+
path: `${relativeDir.split(sep).join('/')}/${name}`,
|
|
58
|
+
size: info.size,
|
|
59
|
+
contentType: contentTypeFor(name),
|
|
60
|
+
status: previous ? 'changed' : 'created',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return artifacts.sort((a, b) => a.path.localeCompare(b.path));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function leaseInstructions({ outDir, agentId, scopes = { write: true, imageGen: agentId === 'codex' }, capable = null, imageStudio = null, control = false }) {
|
|
67
|
+
const canImage = Boolean(scopes.imageGen);
|
|
68
|
+
const couldImage = capable ? Boolean(capable.imageGen?.capable) : canImage;
|
|
69
|
+
return [
|
|
70
|
+
control
|
|
71
|
+
? `CONTROL (#3): the human put you in command of this project at ${outDir}. You may read, create and modify its files without asking, one change at a time, minimal and reversible. MADRE took a checkpoint before this turn; everything you change is listed to the human afterwards and can be undone in one click.`
|
|
72
|
+
: `CREATION LEASE: the human allows you to create files for this request, only inside ${outDir}.`,
|
|
73
|
+
control
|
|
74
|
+
? 'Never touch .git, .pulse, .env files or credentials: writes there are denied and reverted. Do not run destructive commands. Do not delegate this power: other agents you involve work read-only.'
|
|
75
|
+
: 'Write every file you produce there (images, code, documents); paths elsewhere are denied.',
|
|
76
|
+
control ? 'End with a short list of the files you changed and why.' : 'Reading the project stays allowed. Do not modify project files.',
|
|
77
|
+
imageStudio ? `You can generate images with the MCP tool ${imageStudio.tool} (server ${imageStudio.name}): pass a detailed prompt and a file_name; it saves the PNG into the lease directory and returns the path.`
|
|
78
|
+
: canImage ? 'You can generate images; save them into the lease directory with a descriptive file name.'
|
|
79
|
+
: couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
|
|
80
|
+
: 'You cannot generate images from this CLI; if asked for one, say so plainly instead of attempting it.',
|
|
81
|
+
'List the files you created (or "none") before any plan block; nothing may follow a plan block.',
|
|
82
|
+
].filter(Boolean).join('\n');
|
|
83
|
+
}
|