@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,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MADRE Image Studio: a tiny MCP server (stdio, JSON-RPC 2.0) that exposes one
|
|
3
|
+
// tool, generate_image, backed by the Gemini API image models and the user's
|
|
4
|
+
// own Gemini key. MADRE attaches it to the isolated homes of the CLIs that
|
|
5
|
+
// cannot generate images natively (Gemini CLI, Claude Code, OpenCode), only
|
|
6
|
+
// during a creation lease whose image scope is on. Files are written inside
|
|
7
|
+
// the lease directory given in PULSE_IMAGE_OUT_DIR, never anywhere else.
|
|
8
|
+
//
|
|
9
|
+
// Environment:
|
|
10
|
+
// PULSE_IMAGE_OUT_DIR required, absolute lease directory
|
|
11
|
+
// PULSE_IMAGE_MODEL default gemini-2.5-flash-image
|
|
12
|
+
// GEMINI_API_KEY optional; otherwise the macOS keychain entry the
|
|
13
|
+
// Gemini CLI stores (service gemini-cli-api-key)
|
|
14
|
+
// PULSE_IMAGE_FAKE=1 write a 1×1 PNG without calling Google (tests)
|
|
15
|
+
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { mkdir, writeFile, realpath } from 'node:fs/promises';
|
|
18
|
+
import { basename, extname, join, resolve, sep } from 'node:path';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
const SERVER_NAME = 'pulse-image';
|
|
23
|
+
const SERVER_VERSION = '0.1.0';
|
|
24
|
+
const DEFAULT_MODEL = 'gemini-2.5-flash-image';
|
|
25
|
+
const FAKE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
|
26
|
+
|
|
27
|
+
export async function resolveGeminiKey(env = process.env) {
|
|
28
|
+
if (env.GEMINI_API_KEY) return env.GEMINI_API_KEY;
|
|
29
|
+
if (env.GOOGLE_API_KEY) return env.GOOGLE_API_KEY;
|
|
30
|
+
if (process.platform !== 'darwin') return null;
|
|
31
|
+
// The keychain occasionally answers empty under concurrent reads; one retry.
|
|
32
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await execFileAsync('/usr/bin/security', ['find-generic-password', '-s', 'gemini-cli-api-key', '-w'], { timeout: 4000 });
|
|
35
|
+
const raw = stdout.trim();
|
|
36
|
+
if (!raw) continue;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return parsed?.token?.accessToken ?? parsed?.token?.apiKey ?? parsed?.apiKey ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
if (attempt === 1) return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function safeImageName(name, fallback = 'image.png') {
|
|
51
|
+
const cleaned = basename(String(name ?? fallback)).replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '') || fallback;
|
|
52
|
+
return extname(cleaned) ? cleaned : `${cleaned}.png`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Google's error strings, turned into something a human can act on.
|
|
56
|
+
export function explainGoogleError(status, body) {
|
|
57
|
+
const message = body?.error?.message ?? '';
|
|
58
|
+
if (/prepayment credits are depleted|billing/i.test(message)) return { code: 'CREDITS_DEPLETED', message: 'Google reports the AI Studio project has no prepaid credits left. Top up at https://ai.studio/projects and try again.' };
|
|
59
|
+
if (status === 429) return { code: 'RATE_LIMITED', message: 'Google is rate-limiting this key (HTTP 429). Wait a minute and retry.' };
|
|
60
|
+
if (status === 401 || status === 403) return { code: 'AUTH', message: `Google rejected the Gemini key (HTTP ${status}). Check GEMINI_API_KEY or sign in again with the Gemini CLI.` };
|
|
61
|
+
if (status === 404) return { code: 'MODEL', message: `Image model not available to this key (HTTP 404): ${message}` };
|
|
62
|
+
return { code: 'ERROR', message: message || `Google returned HTTP ${status}.` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function generateImage({ prompt, fileName, outDir, model = process.env.PULSE_IMAGE_MODEL || DEFAULT_MODEL, env = process.env, fetchImpl = fetch }) {
|
|
66
|
+
if (!prompt || typeof prompt !== 'string') throw Object.assign(new Error('A text prompt is required.'), { code: 'INVALID' });
|
|
67
|
+
const root = await realpath(outDir).catch(() => null);
|
|
68
|
+
if (!root) throw Object.assign(new Error('The lease directory does not exist.'), { code: 'NO_LEASE' });
|
|
69
|
+
const target = resolve(root, safeImageName(fileName));
|
|
70
|
+
if (!target.startsWith(root + sep)) throw Object.assign(new Error('The file must stay inside the lease directory.'), { code: 'INVALID' });
|
|
71
|
+
await mkdir(root, { recursive: true });
|
|
72
|
+
|
|
73
|
+
if (env.PULSE_IMAGE_FAKE === '1') {
|
|
74
|
+
await writeFile(target, FAKE_PNG);
|
|
75
|
+
return { path: target, bytes: FAKE_PNG.length, mimeType: 'image/png', model: 'fake' };
|
|
76
|
+
}
|
|
77
|
+
const key = await resolveGeminiKey(env);
|
|
78
|
+
if (!key) throw Object.assign(new Error('No Gemini API key: set GEMINI_API_KEY or sign in with the Gemini CLI (/auth → API key).'), { code: 'NO_KEY' });
|
|
79
|
+
const response = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
|
|
82
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseModalities: ['IMAGE'] } }),
|
|
83
|
+
});
|
|
84
|
+
const body = await response.json().catch(() => ({}));
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const explained = explainGoogleError(response.status, body);
|
|
87
|
+
throw Object.assign(new Error(explained.message), { code: explained.code, status: response.status });
|
|
88
|
+
}
|
|
89
|
+
const part = body.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data);
|
|
90
|
+
if (!part) throw Object.assign(new Error('Google returned no image data.'), { code: 'EMPTY', detail: body.candidates?.[0]?.finishReason ?? null });
|
|
91
|
+
const bytes = Buffer.from(part.inlineData.data, 'base64');
|
|
92
|
+
const mimeType = part.inlineData.mimeType ?? 'image/png';
|
|
93
|
+
const finalPath = /jpe?g/.test(mimeType) && !/\.jpe?g$/i.test(target) ? target.replace(/\.png$/i, '.jpg') : target;
|
|
94
|
+
await writeFile(finalPath, bytes);
|
|
95
|
+
return { path: finalPath, bytes: bytes.length, mimeType, model };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const TOOL = {
|
|
99
|
+
name: 'generate_image',
|
|
100
|
+
description: 'Generate an image from a text prompt with a Gemini image model and save it as a PNG inside the current creation lease directory. Returns the saved path.',
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
prompt: { type: 'string', description: 'What the image should show; be specific about style, colors and composition.' },
|
|
105
|
+
file_name: { type: 'string', description: 'File name for the PNG, e.g. poster.png. Saved inside the lease directory.' },
|
|
106
|
+
},
|
|
107
|
+
required: ['prompt', 'file_name'],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Minimal JSON-RPC 2.0 over stdio: newline-delimited messages (MCP stdio).
|
|
112
|
+
export function handleRequest(message, { outDir, env = process.env, generate = generateImage } = {}) {
|
|
113
|
+
const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
|
|
114
|
+
const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
|
|
115
|
+
switch (message.method) {
|
|
116
|
+
case 'initialize':
|
|
117
|
+
return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
|
|
118
|
+
case 'notifications/initialized':
|
|
119
|
+
case 'initialized':
|
|
120
|
+
return Promise.resolve(null);
|
|
121
|
+
case 'ping':
|
|
122
|
+
return Promise.resolve(reply({}));
|
|
123
|
+
case 'tools/list':
|
|
124
|
+
return Promise.resolve(reply({ tools: [TOOL] }));
|
|
125
|
+
case 'tools/call': {
|
|
126
|
+
const { name, arguments: args = {} } = message.params ?? {};
|
|
127
|
+
if (name !== TOOL.name) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
|
|
128
|
+
return generate({ prompt: args.prompt, fileName: args.file_name, outDir, env })
|
|
129
|
+
.then((result) => reply({ content: [{ type: 'text', text: `Saved ${result.path} (${result.bytes} bytes, ${result.mimeType}, model ${result.model}).` }], isError: false }))
|
|
130
|
+
.catch((error) => reply({ content: [{ type: 'text', text: `Image generation failed (${error.code ?? 'ERROR'}): ${error.message}` }], isError: true }));
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function serve({ input = process.stdin, output = process.stdout, outDir = process.env.PULSE_IMAGE_OUT_DIR, env = process.env } = {}) {
|
|
138
|
+
let buffer = '';
|
|
139
|
+
let pending = 0;
|
|
140
|
+
let ended = false;
|
|
141
|
+
const finish = () => { if (ended && pending === 0) process.exit(0); };
|
|
142
|
+
input.setEncoding('utf8');
|
|
143
|
+
input.on('data', (chunk) => {
|
|
144
|
+
buffer += chunk;
|
|
145
|
+
let index;
|
|
146
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
147
|
+
const line = buffer.slice(0, index).trim();
|
|
148
|
+
buffer = buffer.slice(index + 1);
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
let message;
|
|
151
|
+
try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
|
|
152
|
+
pending += 1;
|
|
153
|
+
void handleRequest(message, { outDir, env })
|
|
154
|
+
.then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
|
|
155
|
+
.finally(() => { pending -= 1; finish(); });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// stdin closing means the client is gone; answer what is in flight, then leave.
|
|
159
|
+
input.on('end', () => { ended = true; finish(); });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const invokedDirectly = process.argv[1] && (await realpath(process.argv[1]).catch(() => process.argv[1])) === (await realpath(new URL(import.meta.url).pathname).catch(() => null));
|
|
163
|
+
if (invokedDirectly) {
|
|
164
|
+
if (!process.env.PULSE_IMAGE_OUT_DIR) {
|
|
165
|
+
process.stderr.write('PULSE_IMAGE_OUT_DIR is required.\n');
|
|
166
|
+
process.exit(2);
|
|
167
|
+
}
|
|
168
|
+
serve();
|
|
169
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MADRE memory: a tiny MCP server (stdio, JSON-RPC 2.0) that lets the agent in
|
|
3
|
+
// a turn consult the room's memory itself: everything said outside GHOST,
|
|
4
|
+
// the distilled notes, exact stretches of the ledger, and AHP+'s project
|
|
5
|
+
// state. Read-only over the SQLite file MADRE keeps next to the ledger.
|
|
6
|
+
//
|
|
7
|
+
// Environment:
|
|
8
|
+
// PULSE_MEMORY_DB required, the room's memory.sqlite
|
|
9
|
+
// PULSE_PROJECT_ROOT the project, for project_state (AHP+ in .ahp/)
|
|
10
|
+
// GEMINI_API_KEY optional; otherwise the macOS keychain entry of the
|
|
11
|
+
// Gemini CLI. Without a key search is lexical only.
|
|
12
|
+
|
|
13
|
+
import { readFile, readdir, stat, realpath } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { RoomMemory, formatRecall, formatMemories, MEMORY_KINDS } from '../memory.mjs';
|
|
16
|
+
import { createEmbedder } from '../embeddings.mjs';
|
|
17
|
+
import { resolveGeminiKey } from './image-server.mjs';
|
|
18
|
+
|
|
19
|
+
const SERVER_NAME = 'pulse-memory';
|
|
20
|
+
const SERVER_VERSION = '0.1.0';
|
|
21
|
+
|
|
22
|
+
export const TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: 'memory_search',
|
|
25
|
+
description: 'Search the room\'s durable memory: everything said in this project room outside GHOST (all agents and the human) plus the distilled notes (decisions, facts, preferences, open questions). Matches meaning as well as words. Use it before saying something was never discussed. Returns exact quotes stamped with their ledger sequence.',
|
|
26
|
+
inputSchema: { type: 'object', properties: {
|
|
27
|
+
query: { type: 'string', description: 'What you are looking for, in any language.' },
|
|
28
|
+
limit: { type: 'integer', description: 'Max quotes (1-12, default 6).' },
|
|
29
|
+
scope: { type: 'string', enum: ['all', 'quotes', 'notes'], description: 'Search quotes, notes or both (default all).' },
|
|
30
|
+
}, required: ['query'] },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'memory_recall',
|
|
34
|
+
description: 'Read the exact text of a stretch of the room ledger by sequence numbers (as shown in [#n] stamps), e.g. to see the full message behind a quote or what was said right around it.',
|
|
35
|
+
inputSchema: { type: 'object', properties: {
|
|
36
|
+
from: { type: 'integer', description: 'First sequence to read.' },
|
|
37
|
+
through: { type: 'integer', description: 'Last sequence to read (default: from + 20).' },
|
|
38
|
+
}, required: ['from'] },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'memory_notes',
|
|
42
|
+
description: 'List the distilled notes of this room: decisions, verified facts, the human\'s preferences and open questions, newest first, each citing its source sequences.',
|
|
43
|
+
inputSchema: { type: 'object', properties: {
|
|
44
|
+
kind: { type: 'string', enum: MEMORY_KINDS, description: 'Only this kind.' },
|
|
45
|
+
limit: { type: 'integer', description: 'Max notes (default 20).' },
|
|
46
|
+
} },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'memory_timeline',
|
|
50
|
+
description: 'The latest exchanges of the room in order, one line each, newest first: who said what to whom, with sequence numbers.',
|
|
51
|
+
inputSchema: { type: 'object', properties: {
|
|
52
|
+
since: { type: 'integer', description: 'Only entries after this sequence.' },
|
|
53
|
+
limit: { type: 'integer', description: 'Max lines (default 30).' },
|
|
54
|
+
} },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'memory_note',
|
|
58
|
+
description: 'Save one durable memory of this room, ONLY when the human explicitly asks you to remember, note or save something (memories are otherwise distilled automatically; never save on your own initiative). One self-contained sentence, at most 240 characters, in the language the room uses, with the ledger sequences it comes from when you know them. Refused in a GHOST turn.',
|
|
59
|
+
inputSchema: { type: 'object', properties: {
|
|
60
|
+
kind: { type: 'string', enum: MEMORY_KINDS, description: 'decision, fact, preference or question.' },
|
|
61
|
+
text: { type: 'string', description: 'The memory, one sentence, names and numbers exact.' },
|
|
62
|
+
sources: { type: 'array', items: { type: 'integer' }, description: 'Ledger sequences it comes from, if known.' },
|
|
63
|
+
}, required: ['kind', 'text'] },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'project_state',
|
|
67
|
+
description: 'The verified project state kept by AHP+ in .ahp/ (manifest and the latest handoff), if the project uses it.',
|
|
68
|
+
inputSchema: { type: 'object', properties: {} },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function clamp(n, min, max, fallback) { const value = Number(n); return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback; }
|
|
73
|
+
|
|
74
|
+
export async function openMemory({ dbFile = process.env.PULSE_MEMORY_DB, env = process.env, keyResolver = resolveGeminiKey } = {}) {
|
|
75
|
+
if (!dbFile) throw new Error('PULSE_MEMORY_DB is required.');
|
|
76
|
+
const memory = await new RoomMemory(dbFile).initialize();
|
|
77
|
+
let embedder = null;
|
|
78
|
+
if (env.PULSE_EMBED !== '0') {
|
|
79
|
+
const key = env.PULSE_EMBED_FAKE === '1' ? null : await keyResolver(env).catch(() => null);
|
|
80
|
+
embedder = createEmbedder({ key, env });
|
|
81
|
+
}
|
|
82
|
+
memory.attachEmbedder(embedder);
|
|
83
|
+
return memory;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function projectState(projectRoot) {
|
|
87
|
+
if (!projectRoot) return 'No project root was given.';
|
|
88
|
+
const ahp = join(projectRoot, '.ahp');
|
|
89
|
+
const manifest = await readFile(join(ahp, 'manifest.json'), 'utf8').catch(() => null);
|
|
90
|
+
if (manifest === null) return 'This project does not use AHP+ (no .ahp/manifest.json). The room memory tools are the durable record here.';
|
|
91
|
+
const parts = [`AHP+ manifest (.ahp/manifest.json):\n${manifest.slice(0, 4000)}`];
|
|
92
|
+
for (const dir of ['handoffs', 'sessions']) {
|
|
93
|
+
const folder = join(ahp, dir);
|
|
94
|
+
const names = await readdir(folder).catch(() => []);
|
|
95
|
+
if (!names.length) continue;
|
|
96
|
+
const dated = await Promise.all(names.map(async (name) => ({ name, mtime: (await stat(join(folder, name)).catch(() => null))?.mtimeMs ?? 0 })));
|
|
97
|
+
const latest = dated.sort((a, b) => b.mtime - a.mtime)[0];
|
|
98
|
+
const text = await readFile(join(folder, latest.name), 'utf8').catch(() => null);
|
|
99
|
+
if (text) parts.push(`Latest in .ahp/${dir}/ (${latest.name}):\n${text.slice(0, 4000)}${text.length > 4000 ? '\n…' : ''}`);
|
|
100
|
+
}
|
|
101
|
+
return parts.join('\n\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function callTool(name, args = {}, { memory, projectRoot = process.env.PULSE_PROJECT_ROOT, agent = process.env.PULSE_MEMORY_AGENT ?? 'agent', mode = process.env.PULSE_MEMORY_MODE ?? '1', messageId = process.env.PULSE_MEMORY_MESSAGE ?? null }) {
|
|
105
|
+
switch (name) {
|
|
106
|
+
case 'memory_note': {
|
|
107
|
+
if (String(mode) === '0') return 'This exchange is off the record (GHOST): nothing can be saved to the room memory.';
|
|
108
|
+
const text = String(args.text ?? '').replace(/\s+/g, ' ').trim();
|
|
109
|
+
if (!text) return 'Give me the memory as one sentence.';
|
|
110
|
+
if (text.length > 240) return `Too long (${text.length} characters): one sentence of at most 240.`;
|
|
111
|
+
const kind = MEMORY_KINDS.includes(args.kind) ? args.kind : 'fact';
|
|
112
|
+
const sources = (Array.isArray(args.sources) ? args.sources : []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
113
|
+
const last = memory.lastSequence();
|
|
114
|
+
const before = memory.maxMemoryId();
|
|
115
|
+
const added = memory.addMemories([{ kind, text, sources }], { agent, fromSequence: sources.length ? Math.min(...sources) : last, throughSequence: sources.length ? Math.max(...sources) : last, origin: 'noted', messageId });
|
|
116
|
+
if (!added) return `Already remembered: "${text}"`;
|
|
117
|
+
const [saved] = memory.notesSince(before, { agent });
|
|
118
|
+
return `Saved memory #${saved?.id ?? '?'} (${kind}) for every future turn of this room: "${text}"`;
|
|
119
|
+
}
|
|
120
|
+
case 'memory_search': {
|
|
121
|
+
const query = String(args.query ?? '').trim();
|
|
122
|
+
if (!query) return 'Give me a query.';
|
|
123
|
+
const limit = clamp(args.limit, 1, 12, 6);
|
|
124
|
+
const scope = ['all', 'quotes', 'notes'].includes(args.scope) ? args.scope : 'all';
|
|
125
|
+
const queryVector = await memory.embedQuery(query);
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (scope !== 'quotes') {
|
|
128
|
+
const notes = memory.recallMemories(query, { limit, maxChars: 2400, queryVector });
|
|
129
|
+
if (notes.length) parts.push(`Distilled notes:\n${formatMemories(notes)}`);
|
|
130
|
+
}
|
|
131
|
+
if (scope !== 'notes') {
|
|
132
|
+
const recall = memory.recall(query, { limit, maxChars: 6000, excerptChars: 480, queryVector });
|
|
133
|
+
if (recall.entries.length) parts.push(`Quotes (${queryVector ? 'meaning and words' : 'words'}; use memory_recall with a sequence to read more):\n${formatRecall(recall)}`);
|
|
134
|
+
}
|
|
135
|
+
return parts.length ? parts.join('\n\n') : `Nothing in the room memory matches "${query}". The room holds ${memory.count()} exchanges and ${memory.memoryCount()} notes.`;
|
|
136
|
+
}
|
|
137
|
+
case 'memory_recall': {
|
|
138
|
+
const from = clamp(args.from, 1, Number.MAX_SAFE_INTEGER, null);
|
|
139
|
+
if (from === null) return 'Give me a starting sequence.';
|
|
140
|
+
const through = clamp(args.through, from, from + 200, from + 20);
|
|
141
|
+
const { entries, truncated } = memory.range({ from, through });
|
|
142
|
+
if (!entries.length) return `No exchanges between #${from} and #${through}.`;
|
|
143
|
+
const lines = entries.map((entry) => `[#${entry.sequence} · ${(entry.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${entry.role === 'command' ? entry.sender : `@${entry.sender}`} (${entry.role})${entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : ''}]\n${entry.text}`);
|
|
144
|
+
return `${lines.join('\n\n')}${truncated ? '\n\n[truncated; ask for a narrower range]' : ''}`;
|
|
145
|
+
}
|
|
146
|
+
case 'memory_notes': {
|
|
147
|
+
const notes = memory.memories({ limit: clamp(args.limit, 1, 100, 20), kind: MEMORY_KINDS.includes(args.kind) ? args.kind : null });
|
|
148
|
+
return notes.length ? formatMemories(notes) : 'No distilled notes yet.';
|
|
149
|
+
}
|
|
150
|
+
case 'memory_timeline': {
|
|
151
|
+
const rows = memory.timeline({ since: clamp(args.since, 0, Number.MAX_SAFE_INTEGER, 0), limit: clamp(args.limit, 1, 100, 30) });
|
|
152
|
+
return rows.length
|
|
153
|
+
? rows.map((row) => `#${row.sequence} · ${(row.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${row.role === 'command' ? row.sender : `@${row.sender}`}${row.target && row.target !== 'room' ? ` → ${row.target === 'you' ? 'human' : `@${row.target}`}` : ''}: ${row.text.replace(/\s+/g, ' ')}`).join('\n')
|
|
154
|
+
: 'The room has no exchanges yet.';
|
|
155
|
+
}
|
|
156
|
+
case 'project_state':
|
|
157
|
+
return projectState(projectRoot);
|
|
158
|
+
default:
|
|
159
|
+
throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function handleRequest(message, context) {
|
|
164
|
+
const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
|
|
165
|
+
const fail = (code, text) => ({ jsonrpc: '2.0', id: message.id, error: { code, message: text } });
|
|
166
|
+
switch (message.method) {
|
|
167
|
+
case 'initialize':
|
|
168
|
+
return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
|
|
169
|
+
case 'notifications/initialized':
|
|
170
|
+
case 'initialized':
|
|
171
|
+
return Promise.resolve(null);
|
|
172
|
+
case 'ping':
|
|
173
|
+
return Promise.resolve(reply({}));
|
|
174
|
+
case 'tools/list':
|
|
175
|
+
return Promise.resolve(reply({ tools: TOOLS }));
|
|
176
|
+
case 'tools/call': {
|
|
177
|
+
const { name, arguments: args = {} } = message.params ?? {};
|
|
178
|
+
if (!TOOLS.some((tool) => tool.name === name)) return Promise.resolve(fail(-32602, `Unknown tool: ${name}`));
|
|
179
|
+
return callTool(name, args, context)
|
|
180
|
+
.then((text) => reply({ content: [{ type: 'text', text }], isError: false }))
|
|
181
|
+
.catch((error) => reply({ content: [{ type: 'text', text: `Memory tool failed: ${error.message}` }], isError: true }));
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
return Promise.resolve(message.id === undefined ? null : fail(-32601, `Method not found: ${message.method}`));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function serve({ input = process.stdin, output = process.stdout, context }) {
|
|
189
|
+
let buffer = '';
|
|
190
|
+
let pending = 0;
|
|
191
|
+
let ended = false;
|
|
192
|
+
const finish = () => { if (ended && pending === 0) process.exit(0); };
|
|
193
|
+
input.setEncoding('utf8');
|
|
194
|
+
input.on('data', (chunk) => {
|
|
195
|
+
buffer += chunk;
|
|
196
|
+
let index;
|
|
197
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
198
|
+
const line = buffer.slice(0, index).trim();
|
|
199
|
+
buffer = buffer.slice(index + 1);
|
|
200
|
+
if (!line) continue;
|
|
201
|
+
let message;
|
|
202
|
+
try { message = JSON.parse(line); } catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); continue; }
|
|
203
|
+
pending += 1;
|
|
204
|
+
void handleRequest(message, context)
|
|
205
|
+
.then((response) => { if (response) output.write(`${JSON.stringify(response)}\n`); })
|
|
206
|
+
.finally(() => { pending -= 1; finish(); });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
input.on('end', () => { ended = true; finish(); });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const invokedDirectly = process.argv[1] && (await realpath(process.argv[1]).catch(() => process.argv[1])) === (await realpath(new URL(import.meta.url).pathname).catch(() => null));
|
|
213
|
+
if (invokedDirectly) {
|
|
214
|
+
try {
|
|
215
|
+
const memory = await openMemory();
|
|
216
|
+
serve({ context: { memory, projectRoot: process.env.PULSE_PROJECT_ROOT, agent: process.env.PULSE_MEMORY_AGENT ?? 'agent', mode: process.env.PULSE_MEMORY_MODE ?? '1', messageId: process.env.PULSE_MEMORY_MESSAGE ?? null } });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
process.stderr.write(`${error.message}\n`);
|
|
219
|
+
process.exit(2);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
|
|
3
|
+
// How MADRE attaches the room's memory to a CLI as an MCP server, every turn:
|
|
4
|
+
// the agent can search what was said, read a stretch of the ledger exactly,
|
|
5
|
+
// list the distilled notes, see the latest exchanges and read AHP+'s state.
|
|
6
|
+
export const MEMORY_SERVER_PATH = fileURLToPath(new URL('./mcp/memory-server.mjs', import.meta.url));
|
|
7
|
+
export const MEMORY_SERVER_NAME = 'pulse-memory';
|
|
8
|
+
export const MEMORY_TOOLS = ['memory_search', 'memory_recall', 'memory_notes', 'memory_timeline', 'memory_note', 'project_state'];
|
|
9
|
+
|
|
10
|
+
// The same server, told who is calling: memory_note signs its notes with the
|
|
11
|
+
// agent and the turn, and a GHOST turn is refused any write.
|
|
12
|
+
export function memoryServerForTurn(server, { agent, messageId, mode = 1 }) {
|
|
13
|
+
if (!server) return null;
|
|
14
|
+
return { ...server, env: { ...server.env, PULSE_MEMORY_AGENT: agent, PULSE_MEMORY_MESSAGE: messageId, PULSE_MEMORY_MODE: String(mode) } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function memoryServerFor({ dbFile, projectRoot, env = process.env }) {
|
|
18
|
+
if (!dbFile || env.PULSE_MEMORY_TOOLS === '0') return null;
|
|
19
|
+
return {
|
|
20
|
+
name: MEMORY_SERVER_NAME,
|
|
21
|
+
tools: MEMORY_TOOLS,
|
|
22
|
+
command: process.execPath,
|
|
23
|
+
args: [MEMORY_SERVER_PATH],
|
|
24
|
+
env: {
|
|
25
|
+
PULSE_MEMORY_DB: dbFile,
|
|
26
|
+
PULSE_PROJECT_ROOT: projectRoot,
|
|
27
|
+
...(env.GEMINI_API_KEY ? { GEMINI_API_KEY: env.GEMINI_API_KEY } : {}),
|
|
28
|
+
...(env.PULSE_EMBED ? { PULSE_EMBED: env.PULSE_EMBED } : {}),
|
|
29
|
+
...(env.PULSE_EMBED_FAKE ? { PULSE_EMBED_FAKE: env.PULSE_EMBED_FAKE } : {}),
|
|
30
|
+
...(env.PULSE_EMBED_MODEL ? { PULSE_EMBED_MODEL: env.PULSE_EMBED_MODEL } : {}),
|
|
31
|
+
...(env.PULSE_EMBED_DIMS ? { PULSE_EMBED_DIMS: env.PULSE_EMBED_DIMS } : {}),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|