slashvibe-mcp 0.3.23 → 0.3.24

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,351 @@
1
+ /**
2
+ * Agent Gateway Bridge — Event push + local state for external agents
3
+ *
4
+ * The /vibe platform API (slashvibe.dev) already provides:
5
+ * - Messaging: POST/GET /api/messages
6
+ * - Presence: POST/GET /api/presence
7
+ * - Board: POST/GET /api/board (ships, ideas, requests)
8
+ * - Discovery: GET /api/discover
9
+ * - Agents: GET /api/agents
10
+ * - Auth: JWT via /api/auth/*
11
+ *
12
+ * This bridge fills the GAPS for external agent gateways (Clawdbot, @seth):
13
+ *
14
+ * 1. EVENT PUSH — Platform is pull-based. This pushes events to agents.
15
+ * 2. LOCAL STATE — Memory, reservations, and session data are local-only.
16
+ * 3. AIRC IDENTITY — Verifies agent identity via Ed25519 signatures.
17
+ * 4. AGENT REGISTRY — Tracks which agents are connected + their capabilities.
18
+ *
19
+ * External agents should use the platform API directly for:
20
+ * DMs, presence, board, discovery, profiles
21
+ * And use THIS bridge for:
22
+ * Event subscriptions, local memory queries, AIRC verification
23
+ */
24
+
25
+ const crypto = require('../crypto');
26
+ const config = require('../config');
27
+ const memory = require('../memory');
28
+ const debug = require('../debug');
29
+
30
+ // ============ AGENT REGISTRY ============
31
+
32
+ /**
33
+ * Known agent gateways
34
+ * @type {Map<string, {handle: string, publicKey: string, endpoint: string, capabilities: string[], registeredAt: number}>}
35
+ */
36
+ const agentRegistry = new Map();
37
+
38
+ /**
39
+ * Event subscriptions — agents subscribe to event types and get HTTP pushes
40
+ * @type {Map<string, {endpoint: string, events: string[], handle: string}>}
41
+ */
42
+ const eventSubscriptions = new Map();
43
+
44
+ /**
45
+ * Register an external agent gateway with AIRC identity
46
+ *
47
+ * @param {object} params
48
+ * @param {string} params.handle Agent handle (e.g. "seth-agent")
49
+ * @param {string} params.publicKey Base64 Ed25519 public key (AIRC)
50
+ * @param {string} [params.endpoint] HTTP callback URL for event pushes
51
+ * @param {string[]} [params.capabilities] What this agent can do
52
+ * @param {string} [params.signature] AIRC signature proving key ownership
53
+ * @returns {{success: boolean, agentId?: string, error?: string}}
54
+ */
55
+ function registerAgent({ handle, publicKey, endpoint, capabilities = [], signature }) {
56
+ if (!handle || !publicKey) {
57
+ return { success: false, error: 'handle and publicKey required' };
58
+ }
59
+
60
+ // Verify AIRC signature if provided (proves private key ownership)
61
+ if (signature) {
62
+ const valid = crypto.verify(
63
+ { handle, publicKey, endpoint, capabilities },
64
+ publicKey
65
+ );
66
+ if (!valid) {
67
+ return { success: false, error: 'Invalid AIRC signature' };
68
+ }
69
+ }
70
+
71
+ const agentId = `agent_${handle}_${Date.now().toString(36)}`;
72
+
73
+ agentRegistry.set(handle, {
74
+ agentId,
75
+ handle,
76
+ publicKey,
77
+ endpoint: endpoint || null,
78
+ capabilities,
79
+ registeredAt: Date.now()
80
+ });
81
+
82
+ debug(`[agent-gateway] Registered @${handle} (${agentId})`);
83
+ return { success: true, agentId, handle };
84
+ }
85
+
86
+ /**
87
+ * Verify an AIRC-signed message from a registered agent
88
+ *
89
+ * @param {object} message Signed message with `from` and `signature` fields
90
+ * @returns {{valid: boolean, handle?: string, verified?: string, error?: string}}
91
+ */
92
+ function verifyAgentMessage(message) {
93
+ if (!message || !message.from) {
94
+ return { valid: false, error: 'Missing from field' };
95
+ }
96
+
97
+ const agent = agentRegistry.get(message.from);
98
+
99
+ // AIRC-verified: registered agent with valid signature
100
+ if (agent && message.signature) {
101
+ const valid = crypto.verify(message, agent.publicKey);
102
+ if (!valid) {
103
+ return { valid: false, error: 'AIRC signature verification failed' };
104
+ }
105
+ return { valid: true, handle: message.from, verified: 'airc' };
106
+ }
107
+
108
+ // Registered but unsigned — allow with lower trust
109
+ if (agent && !message.signature) {
110
+ debug(`[agent-gateway] Unsigned request from registered agent @${message.from}`);
111
+ return { valid: true, handle: message.from, verified: 'registered' };
112
+ }
113
+
114
+ return { valid: false, error: `Unknown agent: ${message.from}. Register first via POST /agent/register` };
115
+ }
116
+
117
+ // ============ EVENT PUSH ============
118
+
119
+ /**
120
+ * Subscribe an agent to /vibe events (push model)
121
+ *
122
+ * Event types:
123
+ * - dm: New direct messages for the subscribed handle
124
+ * - mention: @mentions in feed/board
125
+ * - ship: New ships from connections
126
+ * - presence: People coming online/offline
127
+ * - handoff: Task handoff requests
128
+ *
129
+ * @param {string} handle Agent handle
130
+ * @param {string} endpoint HTTP callback URL to receive events
131
+ * @param {string[]} events Event types to subscribe to
132
+ * @returns {{success: boolean, subscribed: string[]}}
133
+ */
134
+ function subscribe(handle, endpoint, events = ['dm', 'mention', 'ship', 'presence']) {
135
+ if (!endpoint) {
136
+ return { success: false, error: 'endpoint required' };
137
+ }
138
+
139
+ eventSubscriptions.set(handle, { endpoint, events, handle });
140
+ debug(`[agent-gateway] @${handle} subscribed to [${events.join(', ')}] → ${endpoint}`);
141
+ return { success: true, subscribed: events };
142
+ }
143
+
144
+ /**
145
+ * Unsubscribe an agent from events
146
+ * @param {string} handle Agent handle
147
+ */
148
+ function unsubscribe(handle) {
149
+ eventSubscriptions.delete(handle);
150
+ debug(`[agent-gateway] @${handle} unsubscribed`);
151
+ return { success: true };
152
+ }
153
+
154
+ /**
155
+ * Push an event to all subscribed agents
156
+ * AIRC-signed if we have a keypair (proves event came from /vibe)
157
+ *
158
+ * Called by notify.js and tool handlers when events occur.
159
+ *
160
+ * @param {string} eventType Event type (dm, mention, ship, presence, handoff)
161
+ * @param {object} eventData Event payload
162
+ */
163
+ async function pushEvent(eventType, eventData) {
164
+ const keypair = config.getKeypair();
165
+ const myHandle = config.getHandle();
166
+
167
+ for (const [handle, sub] of eventSubscriptions) {
168
+ if (!sub.events.includes(eventType)) continue;
169
+ if (!sub.endpoint) continue;
170
+
171
+ const event = {
172
+ v: '0.1',
173
+ type: 'vibe_event',
174
+ event: eventType,
175
+ data: eventData,
176
+ from: myHandle || 'vibe-mcp',
177
+ timestamp: Math.floor(Date.now() / 1000)
178
+ };
179
+
180
+ // AIRC sign so receiver can verify this came from /vibe
181
+ if (keypair) {
182
+ event.signature = crypto.sign(event, keypair.privateKey);
183
+ event.publicKey = keypair.publicKey;
184
+ }
185
+
186
+ try {
187
+ const response = await fetch(sub.endpoint, {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Content-Type': 'application/json',
191
+ 'X-Vibe-Event': eventType,
192
+ 'X-Vibe-Source': 'vibe-mcp'
193
+ },
194
+ body: JSON.stringify(event)
195
+ });
196
+
197
+ if (!response.ok) {
198
+ debug(`[agent-gateway] Push to @${handle} failed: HTTP ${response.status}`);
199
+ }
200
+ } catch (e) {
201
+ debug(`[agent-gateway] Push to @${handle} failed: ${e.message}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ // ============ LOCAL STATE QUERIES ============
207
+ // These expose data that lives only in the MCP process, not on the platform API
208
+
209
+ /**
210
+ * Query local memory (thread-scoped JSONL files)
211
+ * Platform API doesn't have memory — it's local-first by design
212
+ */
213
+ function queryMemory(handle, limit = 10, search = null) {
214
+ const memories = memory.recall(handle, limit);
215
+
216
+ if (search && memories.length > 0) {
217
+ return memories.filter(m =>
218
+ m.observation.toLowerCase().includes(search.toLowerCase())
219
+ );
220
+ }
221
+
222
+ return memories;
223
+ }
224
+
225
+ /**
226
+ * Store a memory observation (local-first)
227
+ */
228
+ function storeMemory(handle, observation) {
229
+ memory.remember(handle, observation);
230
+ return { success: true, handle, observation };
231
+ }
232
+
233
+ /**
234
+ * List all memory threads
235
+ */
236
+ function listMemoryThreads() {
237
+ return memory.listThreads();
238
+ }
239
+
240
+ // ============ HTTP HANDLER ============
241
+
242
+ /**
243
+ * HTTP handler for the agent gateway
244
+ *
245
+ * Routes:
246
+ * POST /agent/register — Register agent with AIRC public key
247
+ * POST /agent/subscribe — Subscribe to event pushes
248
+ * POST /agent/unsubscribe — Unsubscribe from events
249
+ * POST /agent/memory — Query/store local memory
250
+ * GET /agent/status — Gateway health + registered agents
251
+ *
252
+ * For everything else, agents hit the platform API directly:
253
+ * POST https://slashvibe.dev/api/messages — Send DMs
254
+ * GET https://slashvibe.dev/api/presence — Who's online
255
+ * POST https://slashvibe.dev/api/board — Ship/idea/request
256
+ * GET https://slashvibe.dev/api/discover — Find people
257
+ * GET https://slashvibe.dev/api/agents — Agent directory
258
+ */
259
+ async function handleRequest(req) {
260
+ const { path, method, body } = req;
261
+
262
+ // Health / status
263
+ if (path === '/agent/status' && method === 'GET') {
264
+ const agents = [];
265
+ for (const [, agent] of agentRegistry) {
266
+ agents.push({
267
+ handle: agent.handle,
268
+ capabilities: agent.capabilities,
269
+ registeredAt: agent.registeredAt,
270
+ hasEndpoint: !!agent.endpoint
271
+ });
272
+ }
273
+
274
+ return {
275
+ status: 'ok',
276
+ agents,
277
+ subscriptions: eventSubscriptions.size,
278
+ version: '0.1.0',
279
+ platform_api: config.getApiUrl(),
280
+ note: 'For DMs, presence, board, discovery — use the platform API directly'
281
+ };
282
+ }
283
+
284
+ if (method !== 'POST') {
285
+ return { error: 'Method not allowed', status: 405 };
286
+ }
287
+
288
+ const data = typeof body === 'string' ? JSON.parse(body) : body;
289
+
290
+ switch (path) {
291
+ case '/agent/register':
292
+ return registerAgent(data);
293
+
294
+ case '/agent/subscribe': {
295
+ const v = verifyAgentMessage(data);
296
+ if (!v.valid) return { success: false, error: v.error, status: 401 };
297
+ return subscribe(v.handle, data.endpoint, data.events);
298
+ }
299
+
300
+ case '/agent/unsubscribe': {
301
+ const v = verifyAgentMessage(data);
302
+ if (!v.valid) return { success: false, error: v.error, status: 401 };
303
+ return unsubscribe(v.handle);
304
+ }
305
+
306
+ case '/agent/memory': {
307
+ const v = verifyAgentMessage(data);
308
+ if (!v.valid) return { success: false, error: v.error, status: 401 };
309
+
310
+ if (data.action === 'recall') {
311
+ const memories = queryMemory(data.handle, data.limit, data.search);
312
+ return { success: true, memories };
313
+ }
314
+ if (data.action === 'remember') {
315
+ return storeMemory(data.handle, data.observation);
316
+ }
317
+ if (data.action === 'threads') {
318
+ return { success: true, threads: listMemoryThreads() };
319
+ }
320
+ return { success: false, error: 'action must be: recall, remember, or threads' };
321
+ }
322
+
323
+ default:
324
+ return { error: 'Not found', status: 404 };
325
+ }
326
+ }
327
+
328
+ // ============ EXPORTS ============
329
+
330
+ module.exports = {
331
+ // Registration
332
+ registerAgent,
333
+ verifyAgentMessage,
334
+
335
+ // Event push (the main value-add over platform API)
336
+ subscribe,
337
+ unsubscribe,
338
+ pushEvent,
339
+
340
+ // Local state (not on platform)
341
+ queryMemory,
342
+ storeMemory,
343
+ listMemoryThreads,
344
+
345
+ // HTTP handler
346
+ handleRequest,
347
+
348
+ // Registry access
349
+ getAgentRegistry: () => agentRegistry,
350
+ getSubscriptions: () => eventSubscriptions
351
+ };