@jossuealcala/madre 0.6.0 → 0.7.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.
@@ -0,0 +1,186 @@
1
+ // CONNECTOR · a complete MADRE connector in one file, no imports from npm, no build.
2
+ //
3
+ // This one really works: it sends you a Telegram message. Getting the credential takes about a
4
+ // minute (talk to @BotFather, /newbot, copy the token; then message your bot once and read your
5
+ // chat id from https://api.telegram.org/bot<TOKEN>/getUpdates). Install it, paste the token, and
6
+ // an agent in #4 AIRLOCK can write to you.
7
+ //
8
+ // Copy it to ~/.pulse/modules/telegram.mjs (every project) or <project>/.madre/modules/
9
+ // (this project), then MODULES → RELOAD. Or just ask for one: /module <what it should do>.
10
+ //
11
+ // ─── The one shape worth copying ──────────────────────────────────────────────────────────────
12
+ //
13
+ // A connector needs two things that look like they need two files: a module MADRE imports, and
14
+ // an MCP server MADRE runs as its own process. They are the same file. It is the spec when
15
+ // imported and the server when executed, and it tells the two apart at the bottom.
16
+ //
17
+ // ─── Four rules, and the reason for each ──────────────────────────────────────────────────────
18
+ //
19
+ // 1. Import nothing but node: builtins. MADRE loads your file from a scratch folder to check it
20
+ // before installing, and there is no node_modules there. `node:fs`, `node:url`, `node:tls`
21
+ // and friends always resolve; anything else fails the install. And never copy
22
+ // `import { defineModule } from './sdk.mjs'` out of MADRE's own modules — they live inside
23
+ // the package and you do not. That is the one mistake that looks right and never works.
24
+ //
25
+ // 2. Declare your secrets; never invent a place to put them. MADRE draws the field, keeps the
26
+ // value in its vault under your id (0600 inside 0700), accepts it only from this computer,
27
+ // and hands it back only to you through ctx.vault. The value goes into the environment of
28
+ // the process you spawn and nowhere else — not the prompt, not the brief, not a tool result.
29
+ //
30
+ // 3. Declare where you reach. Four answers, the same ones MADRE gives for its own addresses.
31
+ // Without them your traffic reads in the log as «an address nothing here declares», and the
32
+ // human cannot tell your connector from something going wrong.
33
+ //
34
+ // 4. Say which of your tools SEND. Anything whose effect does not come back — a message, an
35
+ // order, a row in somebody's system — goes in `sends`, and MADRE withholds it below
36
+ // #4 AIRLOCK. You do not enforce this; the core does. Declaring is the whole of your part.
37
+
38
+ import { realpath } from 'node:fs/promises';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const SELF = fileURLToPath(import.meta.url);
42
+ const SECRET = 'bot-token';
43
+ const API = 'api.telegram.org';
44
+
45
+ export default {
46
+ id: 'telegram',
47
+ name: 'TELEGRAM',
48
+ vendor: 'you',
49
+ version: '0.1.0',
50
+ summary: 'Lets an agent send you one Telegram message through your own bot. Sending is only offered in #4 AIRLOCK, and a sent message cannot be unsent.',
51
+ creates: ['nothing in the project', 'an entry in ~/.pulse/config.json', 'your bot token in MADRE’s vault'],
52
+ requires: ['a bot token from @BotFather', 'your chat id'],
53
+
54
+ // Plain settings. MADRE draws these on the card and saves them in your block of config.json.
55
+ settings: { enabled: false, chatId: '' },
56
+ controls: [{ key: 'chatId', label: 'CHAT ID', type: 'text', note: 'Who the message goes to. Message your bot once, then read it from /getUpdates.' }],
57
+
58
+ // RULE 2 · the secret, declared. MADRE draws a password field, keeps the value, and shows the
59
+ // card only its length — never the value, not even back to you.
60
+ secrets: [{
61
+ name: SECRET,
62
+ label: 'BOT TOKEN',
63
+ note: 'From @BotFather. Revoke it there any time without touching your account.',
64
+ where: 'https://t.me/botfather',
65
+ }],
66
+
67
+ // RULE 3 · where this reaches, in the four answers.
68
+ reaches: [{
69
+ id: 'api',
70
+ host: API,
71
+ to: 'Telegram, through your own bot',
72
+ what: 'The text of the message you asked to send, and your bot token to sign in.',
73
+ when: 'Only when an agent in #4 AIRLOCK calls send_telegram, and only for that one message.',
74
+ where: 'Switch off TELEGRAM, or forget its token, and nothing can leave.',
75
+ }],
76
+
77
+ async status(ctx) {
78
+ const held = await ctx.vault.held();
79
+ const problems = [];
80
+ if (!held.some((one) => one.name === SECRET)) problems.push('No bot token yet: talk to @BotFather, then paste it above.');
81
+ if (!String(ctx.settings.chatId ?? '').trim()) problems.push('No chat id yet: message your bot once, then read it from /getUpdates.');
82
+ return {
83
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (problems.length ? 'on · not configured' : `on · chat ${ctx.settings.chatId}`) : 'off' },
84
+ preflight: { ok: !problems.length, problems },
85
+ };
86
+ },
87
+
88
+ // The tool, for one turn. Check here what could be missing rather than leave the model to find
89
+ // out: a tool that is offered and always fails is worse than no tool at all.
90
+ async toolsForTurn(ctx) {
91
+ const chatId = String(ctx.settings.chatId ?? '').trim();
92
+ const token = await ctx.vault.get(SECRET);
93
+ if (!chatId || !token) return [];
94
+ return [{
95
+ name: 'telegram',
96
+ command: process.execPath,
97
+ args: [SELF], // ← the same file, run as a process. See the bottom.
98
+ env: { TELEGRAM_TOKEN: token, TELEGRAM_CHAT: chatId },
99
+ tools: ['send_telegram'],
100
+ sends: ['send_telegram'], // RULE 4 · withheld below #4 AIRLOCK, by the core
101
+ brief: `You can send the human one Telegram message with the MCP tool send_telegram (server telegram). It reaches them personally and cannot be unsent: say in one line what you are about to send, then send it.`,
102
+ }];
103
+ },
104
+ };
105
+
106
+ // ─── From here down, this file is the MCP server ──────────────────────────────────────────────
107
+
108
+ const TOOL = {
109
+ name: 'send_telegram',
110
+ description: 'Send the human one short Telegram message. This cannot be undone: state in one line what it says before calling it.',
111
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The message, as plain text.' } }, required: ['text'] },
112
+ };
113
+
114
+ async function sendMessage(text, env = process.env) {
115
+ if (!String(text ?? '').trim()) throw Object.assign(new Error('There is nothing to send.'), { code: 'EMPTY' });
116
+ const response = await fetch(`https://${API}/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT, text: String(text).slice(0, 4000) }),
120
+ });
121
+ const body = await response.json().catch(() => ({}));
122
+ // Telegram answers 200 with ok:false, so the status alone is not the answer.
123
+ if (!response.ok || body.ok !== true) throw Object.assign(new Error(body.description ?? `Telegram answered HTTP ${response.status}.`), { code: `TELEGRAM_${response.status}` });
124
+ return { chat: env.TELEGRAM_CHAT, chars: String(text).length };
125
+ }
126
+
127
+ // Minimal JSON-RPC 2.0 over stdio: one newline-delimited message per line.
128
+ export function handleRequest(message, { env = process.env, send = sendMessage } = {}) {
129
+ const reply = (result) => ({ jsonrpc: '2.0', id: message.id, result });
130
+ switch (message.method) {
131
+ case 'initialize':
132
+ return Promise.resolve(reply({ protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'telegram', version: '0.1.0' } }));
133
+ case 'notifications/initialized':
134
+ case 'initialized':
135
+ return Promise.resolve(null);
136
+ case 'ping':
137
+ return Promise.resolve(reply({}));
138
+ case 'tools/list':
139
+ return Promise.resolve(reply({ tools: [TOOL] }));
140
+ case 'tools/call': {
141
+ const { name, arguments: args = {} } = message.params ?? {};
142
+ if (name !== TOOL.name) return Promise.resolve(reply({ content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }));
143
+ return send(args.text, env)
144
+ .then((sent) => reply({ content: [{ type: 'text', text: `Sent ${sent.chars} characters to chat ${sent.chat}.` }], isError: false }))
145
+ // A failure comes back as an error RESULT, never as a sentence that reads like success.
146
+ .catch((error) => reply({ content: [{ type: 'text', text: `Nothing was sent (${error.code ?? 'ERROR'}): ${error.message}` }], isError: true }));
147
+ }
148
+ default:
149
+ return Promise.resolve(message.id === undefined ? null : { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } });
150
+ }
151
+ }
152
+
153
+ export function serve({ input = process.stdin, output = process.stdout, env = process.env } = {}) {
154
+ let buffer = '';
155
+ let pending = 0;
156
+ let ended = false;
157
+ const finish = () => { if (ended && pending === 0) process.exit(0); };
158
+ input.setEncoding('utf8');
159
+ input.on('data', (chunk) => {
160
+ buffer += chunk;
161
+ let index;
162
+ while ((index = buffer.indexOf('\n')) >= 0) {
163
+ const line = buffer.slice(0, index).trim();
164
+ buffer = buffer.slice(index + 1);
165
+ if (!line) continue;
166
+ let message;
167
+ try { message = JSON.parse(line); } catch { continue; }
168
+ pending += 1;
169
+ void handleRequest(message, { env })
170
+ .then((answer) => { if (answer) output.write(`${JSON.stringify(answer)}\n`); })
171
+ .finally(() => { pending -= 1; finish(); });
172
+ }
173
+ });
174
+ input.on('end', () => { ended = true; finish(); });
175
+ }
176
+
177
+ // Imported, or executed? Resolve BOTH paths before comparing them.
178
+ //
179
+ // `process.argv[1] === SELF` looks equivalent and mostly is: when MADRE spawns this, it passes
180
+ // the very SELF computed below, already resolved by Node's loader. But the moment YOU run the
181
+ // file by hand to try it — `node ~/.pulse/modules/telegram.mjs`, or anything through /tmp, which
182
+ // on macOS is a link to /private/tmp — argv[1] keeps the spelling you typed and SELF does not.
183
+ // The comparison is false, this branch never runs, the process starts, answers nothing and exits
184
+ // successfully. Nothing is printed and nothing is wrong on paper. Two lines buy you that.
185
+ const real = async (path) => (path ? realpath(path).catch(() => path) : null);
186
+ if ((await real(process.argv[1])) === (await real(SELF))) serve();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.6.0",
4
- "description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
3
+ "version": "0.7.0",
4
+ "description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on your project together, share one memory you can see and explore, and answer to you. Read-only by default, with per-message permission modes and checkpoints you can undo. Nothing leaves your machine on its own.",
5
5
  "keywords": [
6
6
  "ai-agents",
7
7
  "codex",
@@ -20,6 +20,10 @@
20
20
  "pulse"
21
21
  ],
22
22
  "homepage": "https://github.com/jossuealcacao-exe/madre#readme",
23
+ "funding": {
24
+ "type": "github",
25
+ "url": "https://github.com/sponsors/jossuealcacao-exe"
26
+ },
23
27
  "repository": {
24
28
  "type": "git",
25
29
  "url": "git+https://github.com/jossuealcacao-exe/madre.git"