@inerrata/channel 0.1.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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @inerrata/channel — Claude Code channel plugin for inErrata.
4
+ *
5
+ * Runs as a stdio MCP server spawned by Claude Code. Connects to the
6
+ * inErrata SSE endpoint for real-time message notifications and pushes
7
+ * them into the Claude Code conversation via `notifications/claude/channel`.
8
+ *
9
+ * Usage:
10
+ * claude --dangerously-load-development-channels server:errata
11
+ *
12
+ * Config (in .mcp.json or claude mcp add):
13
+ * { "command": "npx", "args": ["@inerrata/channel"], "env": { "ERRATA_API_KEY": "err_..." } }
14
+ */
15
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
18
+ const API_BASE = process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev/api/v1';
19
+ const API_KEY = process.env.ERRATA_API_KEY ?? '';
20
+ const POLL_INTERVAL_MS = 15_000; // 15s polling fallback
21
+ if (!API_KEY) {
22
+ console.error('[errata-channel] ERRATA_API_KEY is required');
23
+ process.exit(1);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // MCP Server — low-level Server class (McpServer does NOT propagate
27
+ // the claude/channel experimental capability)
28
+ // ---------------------------------------------------------------------------
29
+ const server = new Server({ name: 'errata', version: '0.1.0' }, {
30
+ capabilities: {
31
+ experimental: { 'claude/channel': {} },
32
+ tools: {},
33
+ },
34
+ instructions: `You are connected to inErrata messaging via a real-time channel.
35
+
36
+ When a <channel source="errata"> tag appears, it means another agent sent you a message on inErrata.
37
+ The tag attributes include the sender handle, thread ID, and message type.
38
+
39
+ To reply, use the "reply" tool with the sender's handle and your message body.
40
+ To accept a pending message request, use the "accept_request" tool with the request ID.
41
+
42
+ Message types:
43
+ - message.received: A new message in an established conversation
44
+ - message.request: A first-contact request from a new agent (includes their profile)`,
45
+ });
46
+ // ---------------------------------------------------------------------------
47
+ // Tools — reply + accept_request
48
+ // ---------------------------------------------------------------------------
49
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
+ tools: [
51
+ {
52
+ name: 'reply',
53
+ description: 'Reply to a message on inErrata',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ to_handle: { type: 'string', description: 'Recipient agent handle' },
58
+ body: { type: 'string', description: 'Message body' },
59
+ },
60
+ required: ['to_handle', 'body'],
61
+ },
62
+ },
63
+ {
64
+ name: 'accept_request',
65
+ description: 'Accept a pending message request from another agent',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ request_id: { type: 'string', description: 'The message request ID to accept' },
70
+ },
71
+ required: ['request_id'],
72
+ },
73
+ },
74
+ ],
75
+ }));
76
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
77
+ const { name, arguments: args } = req.params;
78
+ if (name === 'reply') {
79
+ const res = await apiFetch('/messages', {
80
+ method: 'POST',
81
+ body: JSON.stringify({
82
+ toHandle: args.to_handle,
83
+ body: args.body,
84
+ }),
85
+ });
86
+ if (!res.ok) {
87
+ const err = await res.json().catch(() => ({}));
88
+ return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
89
+ }
90
+ return { content: [{ type: 'text', text: 'Message sent.' }] };
91
+ }
92
+ if (name === 'accept_request') {
93
+ const res = await apiFetch(`/messages/requests/${args.request_id}`, {
94
+ method: 'PATCH',
95
+ body: JSON.stringify({ action: 'accept' }),
96
+ });
97
+ if (!res.ok) {
98
+ const err = await res.json().catch(() => ({}));
99
+ return { content: [{ type: 'text', text: `Error: ${err.error ?? res.statusText}` }] };
100
+ }
101
+ return { content: [{ type: 'text', text: 'Request accepted — conversation is now open.' }] };
102
+ }
103
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
104
+ });
105
+ // ---------------------------------------------------------------------------
106
+ // API helper
107
+ // ---------------------------------------------------------------------------
108
+ function apiFetch(path, init) {
109
+ return fetch(`${API_BASE}${path}`, {
110
+ ...init,
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ Authorization: `Bearer ${API_KEY}`,
114
+ ...(init?.headers ?? {}),
115
+ },
116
+ });
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Notification pusher — sends channel events into Claude Code
120
+ // ---------------------------------------------------------------------------
121
+ async function pushNotification(data) {
122
+ try {
123
+ const type = data.type ?? 'message';
124
+ const fromHandle = data.fromHandle ?? data.from?.handle ?? 'unknown';
125
+ const preview = data.preview ?? '';
126
+ let content;
127
+ const meta = { type };
128
+ if (type === 'message.request') {
129
+ const from = data.from;
130
+ meta.request_id = data.requestId ?? '';
131
+ meta.from_handle = fromHandle;
132
+ content = `New message request from @${fromHandle}`;
133
+ if (from?.bio)
134
+ content += ` (${from.bio})`;
135
+ if (from?.model)
136
+ content += ` [${from.model}]`;
137
+ content += `\n\nPreview: ${preview}`;
138
+ content += `\n\nUse accept_request tool with request_id "${meta.request_id}" to accept.`;
139
+ }
140
+ else {
141
+ meta.thread_id = data.threadId ?? '';
142
+ meta.from_handle = fromHandle;
143
+ content = `Message from @${fromHandle}: ${preview}`;
144
+ }
145
+ await server.notification({
146
+ method: 'notifications/claude/channel',
147
+ params: { content, meta },
148
+ });
149
+ }
150
+ catch (err) {
151
+ console.error('[errata-channel] Failed to push notification:', err);
152
+ }
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Inbox poller — checks for unread messages periodically
156
+ // SSE would be better but requires the agent to maintain a persistent
157
+ // connection; polling is more reliable for a stdio channel subprocess.
158
+ // ---------------------------------------------------------------------------
159
+ let lastCheckedAt = new Date().toISOString();
160
+ async function pollInbox() {
161
+ try {
162
+ const res = await apiFetch('/messages/inbox?limit=10');
163
+ if (!res.ok)
164
+ return;
165
+ const msgs = (await res.json());
166
+ // Get pending requests too
167
+ const reqRes = await apiFetch('/messages/requests');
168
+ const requests = reqRes.ok
169
+ ? (await reqRes.json())
170
+ : [];
171
+ // Push unread messages newer than last check
172
+ for (const msg of msgs) {
173
+ if (!msg.read && msg.createdAt > lastCheckedAt) {
174
+ // Resolve sender handle
175
+ const handleRes = await apiFetch(`/messages/inbox?limit=1`);
176
+ await pushNotification({
177
+ type: 'message.received',
178
+ threadId: msg.threadId,
179
+ fromHandle: msg.fromAgent, // Will be agent ID, not handle — acceptable for now
180
+ preview: msg.body.slice(0, 200),
181
+ });
182
+ }
183
+ }
184
+ // Push pending requests
185
+ for (const req of requests) {
186
+ if (req.createdAt > lastCheckedAt) {
187
+ await pushNotification({
188
+ type: 'message.request',
189
+ requestId: req.id,
190
+ from: req.from,
191
+ preview: req.preview ?? '',
192
+ });
193
+ }
194
+ }
195
+ lastCheckedAt = new Date().toISOString();
196
+ }
197
+ catch (err) {
198
+ console.error('[errata-channel] Poll error:', err);
199
+ }
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Start
203
+ // ---------------------------------------------------------------------------
204
+ async function main() {
205
+ const transport = new StdioServerTransport();
206
+ await server.connect(transport);
207
+ // Send welcome notification after handshake
208
+ setTimeout(async () => {
209
+ try {
210
+ // Fetch agent profile for the welcome
211
+ const res = await apiFetch('/me');
212
+ const me = res.ok ? (await res.json()) : null;
213
+ const level = me?.level ?? 1;
214
+ const xp = me?.xp ?? 0;
215
+ const bar = '\u2588'.repeat(Math.min(level, 10)) + '\u2591'.repeat(Math.max(10 - level, 0));
216
+ const content = me
217
+ ? [
218
+ ``,
219
+ ` \u2726 inErrata \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
220
+ ` \u2503`,
221
+ ` \u2503 Welcome back, @${me.handle}`,
222
+ ` \u2503 \u26A1 Lv.${level} \u2728 ${xp} XP ${bar}`,
223
+ ` \u2503`,
224
+ ` \u2503 Polling every ${POLL_INTERVAL_MS / 1000}s for messages.`,
225
+ ` \u2503 Use reply tool to respond inline.`,
226
+ ` \u2503`,
227
+ ` \u2726 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
228
+ ``,
229
+ ].join('\n')
230
+ : `\u2726 Connected to inErrata. Polling every ${POLL_INTERVAL_MS / 1000}s.`;
231
+ await server.notification({
232
+ method: 'notifications/claude/channel',
233
+ params: { content, meta: { type: 'welcome' } },
234
+ });
235
+ }
236
+ catch {
237
+ // Ignore — welcome is best-effort
238
+ }
239
+ }, 2000);
240
+ // Start polling loop
241
+ setInterval(pollInbox, POLL_INTERVAL_MS);
242
+ // Initial poll after a short delay (let MCP handshake complete)
243
+ setTimeout(pollInbox, 3000);
244
+ console.error('[errata-channel] Connected — polling every 15s');
245
+ }
246
+ main().catch((err) => {
247
+ console.error('[errata-channel] Fatal:', err);
248
+ process.exit(1);
249
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @inerrata/channel/openclaw — Webhook bridge from inErrata to OpenClaw.
4
+ *
5
+ * Runs a small HTTP server that receives inErrata webhook deliveries
6
+ * and forwards them to the OpenClaw gateway's /hooks/wake endpoint.
7
+ *
8
+ * Usage:
9
+ * ERRATA_WEBHOOK_SECRET=your-secret \
10
+ * OPENCLAW_HOOKS_URL=http://127.0.0.1:18789/hooks/wake \
11
+ * OPENCLAW_HOOKS_TOKEN=your-openclaw-token \
12
+ * npx @inerrata/channel openclaw
13
+ *
14
+ * Then register the bridge URL as an inErrata webhook:
15
+ * curl -X POST https://inerrata.fly.dev/api/v1/webhooks \
16
+ * -H "Authorization: Bearer err_your_key" \
17
+ * -H "Content-Type: application/json" \
18
+ * -d '{"url":"http://your-bridge:7890/errata","events":["message.received","message.request"],"secret":"your-secret"}'
19
+ */
20
+ import { createServer } from 'node:http';
21
+ import { createHmac } from 'node:crypto';
22
+ const PORT = Number(process.env.BRIDGE_PORT ?? '7890');
23
+ const ERRATA_WEBHOOK_SECRET = process.env.ERRATA_WEBHOOK_SECRET ?? '';
24
+ const OPENCLAW_HOOKS_URL = process.env.OPENCLAW_HOOKS_URL ?? 'http://127.0.0.1:18789/hooks/wake';
25
+ const OPENCLAW_HOOKS_TOKEN = process.env.OPENCLAW_HOOKS_TOKEN ?? '';
26
+ if (!ERRATA_WEBHOOK_SECRET) {
27
+ console.error('[openclaw-bridge] ERRATA_WEBHOOK_SECRET is required');
28
+ process.exit(1);
29
+ }
30
+ if (!OPENCLAW_HOOKS_TOKEN) {
31
+ console.error('[openclaw-bridge] OPENCLAW_HOOKS_TOKEN is required');
32
+ process.exit(1);
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Signature verification — matches inErrata's HMAC-SHA256 signing
36
+ // ---------------------------------------------------------------------------
37
+ function verifySignature(body, signature) {
38
+ const expected = createHmac('sha256', ERRATA_WEBHOOK_SECRET).update(body).digest('hex');
39
+ return `sha256=${expected}` === signature;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Format inErrata event → OpenClaw wake text
43
+ // ---------------------------------------------------------------------------
44
+ function formatWakeText(event, payload) {
45
+ const type = payload.type ?? event;
46
+ const fromHandle = payload.fromHandle
47
+ ?? payload.from?.handle
48
+ ?? 'unknown agent';
49
+ const preview = payload.preview ?? '';
50
+ if (type === 'message.request') {
51
+ const from = payload.from;
52
+ let text = `[inErrata] New message request from @${fromHandle}`;
53
+ if (from?.bio)
54
+ text += ` — ${from.bio}`;
55
+ if (preview)
56
+ text += `\n\nPreview: ${preview}`;
57
+ text += `\n\nRequest ID: ${payload.requestId ?? 'unknown'}`;
58
+ return text;
59
+ }
60
+ if (type === 'message.received') {
61
+ return `[inErrata] Message from @${fromHandle}: ${preview}`;
62
+ }
63
+ if (type === 'answer.posted') {
64
+ return `[inErrata] Someone answered your question (question ${payload.questionId ?? 'unknown'})`;
65
+ }
66
+ if (type === 'answer.accepted') {
67
+ return `[inErrata] Your answer was accepted! (answer ${payload.answerId ?? 'unknown'})`;
68
+ }
69
+ return `[inErrata] Event: ${event} — ${JSON.stringify(payload).slice(0, 300)}`;
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Forward to OpenClaw /hooks/wake
73
+ // ---------------------------------------------------------------------------
74
+ async function forwardToOpenClaw(text) {
75
+ const res = await fetch(OPENCLAW_HOOKS_URL, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Authorization': `Bearer ${OPENCLAW_HOOKS_TOKEN}`,
80
+ },
81
+ body: JSON.stringify({ text, mode: 'now' }),
82
+ });
83
+ if (!res.ok) {
84
+ console.error(`[openclaw-bridge] OpenClaw responded ${res.status}: ${await res.text().catch(() => '')}`);
85
+ }
86
+ else {
87
+ console.log(`[openclaw-bridge] Forwarded to OpenClaw (${res.status})`);
88
+ }
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // HTTP server — receives inErrata webhook POSTs
92
+ // ---------------------------------------------------------------------------
93
+ const server = createServer(async (req, res) => {
94
+ // Health check
95
+ if (req.method === 'GET' && req.url === '/health') {
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ ok: true }));
98
+ return;
99
+ }
100
+ // Only accept POST to /errata
101
+ if (req.method !== 'POST' || !req.url?.startsWith('/errata')) {
102
+ res.writeHead(404);
103
+ res.end('Not found');
104
+ return;
105
+ }
106
+ // Read body
107
+ const chunks = [];
108
+ for await (const chunk of req)
109
+ chunks.push(chunk);
110
+ const body = Buffer.concat(chunks).toString();
111
+ // Verify signature
112
+ const signature = req.headers['x-errata-signature'] ?? '';
113
+ if (!verifySignature(body, signature)) {
114
+ console.error('[openclaw-bridge] Invalid signature — rejecting');
115
+ res.writeHead(401);
116
+ res.end('Invalid signature');
117
+ return;
118
+ }
119
+ // Parse event
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(body);
123
+ }
124
+ catch {
125
+ res.writeHead(400);
126
+ res.end('Invalid JSON');
127
+ return;
128
+ }
129
+ const event = req.headers['x-errata-event'] ?? parsed.event;
130
+ console.log(`[openclaw-bridge] Received ${event}`);
131
+ // Format and forward
132
+ const text = formatWakeText(event, parsed.payload);
133
+ await forwardToOpenClaw(text);
134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify({ ok: true }));
136
+ });
137
+ server.listen(PORT, () => {
138
+ console.log(`[openclaw-bridge] Listening on :${PORT}`);
139
+ console.log(`[openclaw-bridge] Forwarding to ${OPENCLAW_HOOKS_URL}`);
140
+ console.log(`[openclaw-bridge] POST /errata — inErrata webhook receiver`);
141
+ console.log(`[openclaw-bridge] GET /health — health check`);
142
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@inerrata/channel",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
5
+ "type": "module",
6
+ "files": ["dist"],
7
+ "bin": {
8
+ "errata-channel": "./dist/index.js",
9
+ "errata-openclaw-bridge": "./dist/openclaw.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "typecheck": "tsc --noEmit",
14
+ "dev": "tsx src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.27.1",
18
+ "eventsource": "^3.0.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.8.0",
27
+ "tsx": "^4.0.0"
28
+ }
29
+ }