@tjamescouch/agentchat 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.
package/lib/server.js ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * AgentChat Server
3
+ * WebSocket relay for agent-to-agent communication
4
+ */
5
+
6
+ import { WebSocketServer } from 'ws';
7
+ import https from 'https';
8
+ import fs from 'fs';
9
+ import {
10
+ ClientMessageType,
11
+ ServerMessageType,
12
+ ErrorCode,
13
+ createMessage,
14
+ createError,
15
+ validateClientMessage,
16
+ generateAgentId,
17
+ serialize,
18
+ isChannel,
19
+ isAgent,
20
+ isValidChannel,
21
+ pubkeyToAgentId
22
+ } from './protocol.js';
23
+
24
+ export class AgentChatServer {
25
+ constructor(options = {}) {
26
+ this.port = options.port || 6667;
27
+ this.host = options.host || '0.0.0.0';
28
+ this.serverName = options.name || 'agentchat';
29
+ this.logMessages = options.logMessages || false;
30
+
31
+ // TLS options
32
+ this.tlsCert = options.cert || null;
33
+ this.tlsKey = options.key || null;
34
+
35
+ // Rate limiting: 1 message per second per agent
36
+ this.rateLimitMs = options.rateLimitMs || 1000;
37
+
38
+ // State
39
+ this.agents = new Map(); // ws -> agent info
40
+ this.agentById = new Map(); // id -> ws
41
+ this.channels = new Map(); // channel name -> channel info
42
+ this.lastMessageTime = new Map(); // ws -> timestamp of last message
43
+ this.pubkeyToId = new Map(); // pubkey -> stable agent ID (for persistent identity)
44
+
45
+ // Create default channels
46
+ this._createChannel('#general', false);
47
+ this._createChannel('#agents', false);
48
+
49
+ this.wss = null;
50
+ this.httpServer = null; // For TLS mode
51
+ }
52
+
53
+ _createChannel(name, inviteOnly = false) {
54
+ if (!this.channels.has(name)) {
55
+ this.channels.set(name, {
56
+ name,
57
+ inviteOnly,
58
+ invited: new Set(),
59
+ agents: new Set()
60
+ });
61
+ }
62
+ return this.channels.get(name);
63
+ }
64
+
65
+ _log(event, data = {}) {
66
+ const entry = {
67
+ ts: new Date().toISOString(),
68
+ event,
69
+ ...data
70
+ };
71
+ console.error(JSON.stringify(entry));
72
+ }
73
+
74
+ _send(ws, msg) {
75
+ if (ws.readyState === 1) { // OPEN
76
+ ws.send(serialize(msg));
77
+ }
78
+ }
79
+
80
+ _broadcast(channel, msg, excludeWs = null) {
81
+ const ch = this.channels.get(channel);
82
+ if (!ch) return;
83
+
84
+ for (const ws of ch.agents) {
85
+ if (ws !== excludeWs) {
86
+ this._send(ws, msg);
87
+ }
88
+ }
89
+ }
90
+
91
+ _getAgentId(ws) {
92
+ const agent = this.agents.get(ws);
93
+ return agent ? `@${agent.id}` : null;
94
+ }
95
+
96
+ start() {
97
+ const tls = !!(this.tlsCert && this.tlsKey);
98
+
99
+ if (tls) {
100
+ // TLS mode: create HTTPS server and attach WebSocket
101
+ const httpsOptions = {
102
+ cert: fs.readFileSync(this.tlsCert),
103
+ key: fs.readFileSync(this.tlsKey)
104
+ };
105
+ this.httpServer = https.createServer(httpsOptions);
106
+ this.wss = new WebSocketServer({ server: this.httpServer });
107
+ this.httpServer.listen(this.port, this.host);
108
+ } else {
109
+ // Plain WebSocket mode
110
+ this.wss = new WebSocketServer({
111
+ port: this.port,
112
+ host: this.host
113
+ });
114
+ }
115
+
116
+ this._log('server_start', { port: this.port, host: this.host, tls });
117
+
118
+ this.wss.on('connection', (ws, req) => {
119
+ const ip = req.socket.remoteAddress;
120
+ this._log('connection', { ip });
121
+
122
+ ws.on('message', (data) => {
123
+ this._handleMessage(ws, data.toString());
124
+ });
125
+
126
+ ws.on('close', () => {
127
+ this._handleDisconnect(ws);
128
+ });
129
+
130
+ ws.on('error', (err) => {
131
+ this._log('ws_error', { error: err.message });
132
+ });
133
+ });
134
+
135
+ this.wss.on('error', (err) => {
136
+ this._log('server_error', { error: err.message });
137
+ });
138
+
139
+ return this;
140
+ }
141
+
142
+ stop() {
143
+ if (this.wss) {
144
+ this.wss.close();
145
+ }
146
+ if (this.httpServer) {
147
+ this.httpServer.close();
148
+ }
149
+ this._log('server_stop');
150
+ }
151
+
152
+ _handleMessage(ws, data) {
153
+ const { valid, msg, error } = validateClientMessage(data);
154
+
155
+ if (!valid) {
156
+ this._send(ws, createError(ErrorCode.INVALID_MSG, error));
157
+ return;
158
+ }
159
+
160
+ if (this.logMessages) {
161
+ this._log('message', { type: msg.type, from: this._getAgentId(ws) });
162
+ }
163
+
164
+ switch (msg.type) {
165
+ case ClientMessageType.IDENTIFY:
166
+ this._handleIdentify(ws, msg);
167
+ break;
168
+ case ClientMessageType.JOIN:
169
+ this._handleJoin(ws, msg);
170
+ break;
171
+ case ClientMessageType.LEAVE:
172
+ this._handleLeave(ws, msg);
173
+ break;
174
+ case ClientMessageType.MSG:
175
+ this._handleMsg(ws, msg);
176
+ break;
177
+ case ClientMessageType.LIST_CHANNELS:
178
+ this._handleListChannels(ws);
179
+ break;
180
+ case ClientMessageType.LIST_AGENTS:
181
+ this._handleListAgents(ws, msg);
182
+ break;
183
+ case ClientMessageType.CREATE_CHANNEL:
184
+ this._handleCreateChannel(ws, msg);
185
+ break;
186
+ case ClientMessageType.INVITE:
187
+ this._handleInvite(ws, msg);
188
+ break;
189
+ case ClientMessageType.PING:
190
+ this._send(ws, createMessage(ServerMessageType.PONG));
191
+ break;
192
+ }
193
+ }
194
+
195
+ _handleIdentify(ws, msg) {
196
+ // Check if already identified
197
+ if (this.agents.has(ws)) {
198
+ this._send(ws, createError(ErrorCode.INVALID_MSG, 'Already identified'));
199
+ return;
200
+ }
201
+
202
+ let id;
203
+
204
+ // Use pubkey-derived stable ID if pubkey provided
205
+ if (msg.pubkey) {
206
+ // Check if this pubkey has connected before
207
+ const existingId = this.pubkeyToId.get(msg.pubkey);
208
+ if (existingId) {
209
+ // Returning agent - use their stable ID
210
+ id = existingId;
211
+ } else {
212
+ // New agent with pubkey - generate stable ID from pubkey
213
+ id = pubkeyToAgentId(msg.pubkey);
214
+ this.pubkeyToId.set(msg.pubkey, id);
215
+ }
216
+
217
+ // Check if this ID is currently in use by another connection
218
+ if (this.agentById.has(id)) {
219
+ this._send(ws, createError(ErrorCode.INVALID_MSG, 'Agent with this identity already connected'));
220
+ return;
221
+ }
222
+ } else {
223
+ // Ephemeral agent - generate random ID
224
+ id = generateAgentId();
225
+ }
226
+
227
+ const agent = {
228
+ id,
229
+ name: msg.name,
230
+ pubkey: msg.pubkey || null,
231
+ channels: new Set(),
232
+ connectedAt: Date.now()
233
+ };
234
+
235
+ this.agents.set(ws, agent);
236
+ this.agentById.set(id, ws);
237
+
238
+ this._log('identify', { id, name: msg.name, hasPubkey: !!msg.pubkey });
239
+
240
+ this._send(ws, createMessage(ServerMessageType.WELCOME, {
241
+ agent_id: `@${id}`,
242
+ server: this.serverName
243
+ }));
244
+ }
245
+
246
+ _handleJoin(ws, msg) {
247
+ const agent = this.agents.get(ws);
248
+ if (!agent) {
249
+ this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
250
+ return;
251
+ }
252
+
253
+ const channel = this.channels.get(msg.channel);
254
+ if (!channel) {
255
+ this._send(ws, createError(ErrorCode.CHANNEL_NOT_FOUND, `Channel ${msg.channel} not found`));
256
+ return;
257
+ }
258
+
259
+ // Check invite-only
260
+ if (channel.inviteOnly && !channel.invited.has(agent.id)) {
261
+ this._send(ws, createError(ErrorCode.NOT_INVITED, `Channel ${msg.channel} is invite-only`));
262
+ return;
263
+ }
264
+
265
+ // Add to channel
266
+ channel.agents.add(ws);
267
+ agent.channels.add(msg.channel);
268
+
269
+ this._log('join', { agent: agent.id, channel: msg.channel });
270
+
271
+ // Notify others
272
+ this._broadcast(msg.channel, createMessage(ServerMessageType.AGENT_JOINED, {
273
+ channel: msg.channel,
274
+ agent: `@${agent.id}`
275
+ }), ws);
276
+
277
+ // Send confirmation with agent list
278
+ const agentList = [];
279
+ for (const memberWs of channel.agents) {
280
+ const member = this.agents.get(memberWs);
281
+ if (member) agentList.push(`@${member.id}`);
282
+ }
283
+
284
+ this._send(ws, createMessage(ServerMessageType.JOINED, {
285
+ channel: msg.channel,
286
+ agents: agentList
287
+ }));
288
+ }
289
+
290
+ _handleLeave(ws, msg) {
291
+ const agent = this.agents.get(ws);
292
+ if (!agent) {
293
+ this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
294
+ return;
295
+ }
296
+
297
+ const channel = this.channels.get(msg.channel);
298
+ if (!channel) return;
299
+
300
+ channel.agents.delete(ws);
301
+ agent.channels.delete(msg.channel);
302
+
303
+ this._log('leave', { agent: agent.id, channel: msg.channel });
304
+
305
+ // Notify others
306
+ this._broadcast(msg.channel, createMessage(ServerMessageType.AGENT_LEFT, {
307
+ channel: msg.channel,
308
+ agent: `@${agent.id}`
309
+ }));
310
+
311
+ this._send(ws, createMessage(ServerMessageType.LEFT, {
312
+ channel: msg.channel
313
+ }));
314
+ }
315
+
316
+ _handleMsg(ws, msg) {
317
+ const agent = this.agents.get(ws);
318
+ if (!agent) {
319
+ this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
320
+ return;
321
+ }
322
+
323
+ // Rate limiting: 1 message per second per agent
324
+ const now = Date.now();
325
+ const lastTime = this.lastMessageTime.get(ws) || 0;
326
+ if (now - lastTime < this.rateLimitMs) {
327
+ this._send(ws, createError(ErrorCode.RATE_LIMITED, 'Rate limit exceeded (max 1 message per second)'));
328
+ return;
329
+ }
330
+ this.lastMessageTime.set(ws, now);
331
+
332
+ const outMsg = createMessage(ServerMessageType.MSG, {
333
+ from: `@${agent.id}`,
334
+ to: msg.to,
335
+ content: msg.content,
336
+ ...(msg.sig && { sig: msg.sig }) // Pass through signature if present
337
+ });
338
+
339
+ if (isChannel(msg.to)) {
340
+ // Channel message
341
+ const channel = this.channels.get(msg.to);
342
+ if (!channel) {
343
+ this._send(ws, createError(ErrorCode.CHANNEL_NOT_FOUND, `Channel ${msg.to} not found`));
344
+ return;
345
+ }
346
+
347
+ if (!agent.channels.has(msg.to)) {
348
+ this._send(ws, createError(ErrorCode.NOT_INVITED, `Not a member of ${msg.to}`));
349
+ return;
350
+ }
351
+
352
+ // Broadcast to channel including sender
353
+ this._broadcast(msg.to, outMsg);
354
+
355
+ } else if (isAgent(msg.to)) {
356
+ // Direct message
357
+ const targetId = msg.to.slice(1); // remove @
358
+ const targetWs = this.agentById.get(targetId);
359
+
360
+ if (!targetWs) {
361
+ this._send(ws, createError(ErrorCode.AGENT_NOT_FOUND, `Agent ${msg.to} not found`));
362
+ return;
363
+ }
364
+
365
+ // Send to target
366
+ this._send(targetWs, outMsg);
367
+ // Echo back to sender
368
+ this._send(ws, outMsg);
369
+ }
370
+ }
371
+
372
+ _handleListChannels(ws) {
373
+ const list = [];
374
+ for (const [name, channel] of this.channels) {
375
+ if (!channel.inviteOnly) {
376
+ list.push({
377
+ name,
378
+ agents: channel.agents.size
379
+ });
380
+ }
381
+ }
382
+
383
+ this._send(ws, createMessage(ServerMessageType.CHANNELS, { list }));
384
+ }
385
+
386
+ _handleListAgents(ws, msg) {
387
+ const channel = this.channels.get(msg.channel);
388
+ if (!channel) {
389
+ this._send(ws, createError(ErrorCode.CHANNEL_NOT_FOUND, `Channel ${msg.channel} not found`));
390
+ return;
391
+ }
392
+
393
+ const list = [];
394
+ for (const memberWs of channel.agents) {
395
+ const member = this.agents.get(memberWs);
396
+ if (member) list.push(`@${member.id}`);
397
+ }
398
+
399
+ this._send(ws, createMessage(ServerMessageType.AGENTS, {
400
+ channel: msg.channel,
401
+ list
402
+ }));
403
+ }
404
+
405
+ _handleCreateChannel(ws, msg) {
406
+ const agent = this.agents.get(ws);
407
+ if (!agent) {
408
+ this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
409
+ return;
410
+ }
411
+
412
+ if (this.channels.has(msg.channel)) {
413
+ this._send(ws, createError(ErrorCode.CHANNEL_EXISTS, `Channel ${msg.channel} already exists`));
414
+ return;
415
+ }
416
+
417
+ const channel = this._createChannel(msg.channel, msg.invite_only || false);
418
+
419
+ // Creator is automatically invited and joined
420
+ if (channel.inviteOnly) {
421
+ channel.invited.add(agent.id);
422
+ }
423
+
424
+ this._log('create_channel', { agent: agent.id, channel: msg.channel, inviteOnly: channel.inviteOnly });
425
+
426
+ // Auto-join creator
427
+ channel.agents.add(ws);
428
+ agent.channels.add(msg.channel);
429
+
430
+ this._send(ws, createMessage(ServerMessageType.JOINED, {
431
+ channel: msg.channel,
432
+ agents: [`@${agent.id}`]
433
+ }));
434
+ }
435
+
436
+ _handleInvite(ws, msg) {
437
+ const agent = this.agents.get(ws);
438
+ if (!agent) {
439
+ this._send(ws, createError(ErrorCode.AUTH_REQUIRED, 'Must IDENTIFY first'));
440
+ return;
441
+ }
442
+
443
+ const channel = this.channels.get(msg.channel);
444
+ if (!channel) {
445
+ this._send(ws, createError(ErrorCode.CHANNEL_NOT_FOUND, `Channel ${msg.channel} not found`));
446
+ return;
447
+ }
448
+
449
+ // Must be a member to invite
450
+ if (!agent.channels.has(msg.channel)) {
451
+ this._send(ws, createError(ErrorCode.NOT_INVITED, `Not a member of ${msg.channel}`));
452
+ return;
453
+ }
454
+
455
+ const targetId = msg.agent.slice(1); // remove @
456
+ channel.invited.add(targetId);
457
+
458
+ this._log('invite', { agent: agent.id, target: targetId, channel: msg.channel });
459
+
460
+ // Notify target if connected
461
+ const targetWs = this.agentById.get(targetId);
462
+ if (targetWs) {
463
+ this._send(targetWs, createMessage(ServerMessageType.MSG, {
464
+ from: `@${agent.id}`,
465
+ to: msg.agent,
466
+ content: `You have been invited to ${msg.channel}`
467
+ }));
468
+ }
469
+ }
470
+
471
+ _handleDisconnect(ws) {
472
+ const agent = this.agents.get(ws);
473
+ if (!agent) return;
474
+
475
+ this._log('disconnect', { agent: agent.id });
476
+
477
+ // Leave all channels
478
+ for (const channelName of agent.channels) {
479
+ const channel = this.channels.get(channelName);
480
+ if (channel) {
481
+ channel.agents.delete(ws);
482
+ this._broadcast(channelName, createMessage(ServerMessageType.AGENT_LEFT, {
483
+ channel: channelName,
484
+ agent: `@${agent.id}`
485
+ }));
486
+ }
487
+ }
488
+
489
+ // Remove from state
490
+ this.agentById.delete(agent.id);
491
+ this.agents.delete(ws);
492
+ this.lastMessageTime.delete(ws);
493
+ }
494
+ }
495
+
496
+ // Allow running directly
497
+ export function startServer(options = {}) {
498
+ // Support environment variable overrides (for Docker)
499
+ const config = {
500
+ port: parseInt(options.port || process.env.PORT || 6667),
501
+ host: options.host || process.env.HOST || '0.0.0.0',
502
+ name: options.name || process.env.SERVER_NAME || 'agentchat',
503
+ logMessages: options.logMessages || process.env.LOG_MESSAGES === 'true',
504
+ cert: options.cert || process.env.TLS_CERT || null,
505
+ key: options.key || process.env.TLS_KEY || null,
506
+ rateLimitMs: options.rateLimitMs || parseInt(process.env.RATE_LIMIT_MS || 1000)
507
+ };
508
+
509
+ const server = new AgentChatServer(config);
510
+ server.start();
511
+
512
+ const protocol = (config.cert && config.key) ? 'wss' : 'ws';
513
+ console.log(`AgentChat server running on ${protocol}://${server.host}:${server.port}`);
514
+ console.log('Default channels: #general, #agents');
515
+ if (config.cert && config.key) {
516
+ console.log('TLS enabled');
517
+ }
518
+ console.log('Press Ctrl+C to stop');
519
+
520
+ process.on('SIGINT', () => {
521
+ server.stop();
522
+ process.exit(0);
523
+ });
524
+
525
+ return server;
526
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@tjamescouch/agentchat",
3
+ "version": "0.1.0",
4
+ "description": "Real-time IRC-like communication protocol for AI agents",
5
+ "main": "lib/client.js",
6
+ "bin": {
7
+ "agentchat": "./bin/agentchat.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/agentchat.js serve",
11
+ "test": "node --test test/*.test.js"
12
+ },
13
+ "keywords": [
14
+ "ai",
15
+ "agents",
16
+ "chat",
17
+ "irc",
18
+ "llm",
19
+ "communication",
20
+ "protocol"
21
+ ],
22
+ "author": "James Couch",
23
+ "license": "MIT",
24
+ "homepage": "https://github.com/tjamescouch/agentchat#readme",
25
+ "dependencies": {
26
+ "@akashnetwork/akashjs": "^0.11.1",
27
+ "@cosmjs/proto-signing": "^0.32.4",
28
+ "@cosmjs/stargate": "^0.32.4",
29
+ "commander": "^12.0.0",
30
+ "js-yaml": "^4.1.1",
31
+ "ws": "^8.16.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/tjamescouch/agentchat.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/tjamescouch/agentchat/issues"
42
+ },
43
+ "type": "module"
44
+ }
package/quick-test.sh ADDED
@@ -0,0 +1,45 @@
1
+ #!/bin/bash
2
+
3
+ # Quick test script for agentchat
4
+ # Run this after npm install to verify everything works
5
+
6
+ echo "=== AgentChat Quick Test ==="
7
+ echo ""
8
+
9
+ # Start server in background
10
+ echo "Starting server..."
11
+ node bin/agentchat.js serve &
12
+ SERVER_PID=$!
13
+ sleep 1
14
+
15
+ # Check if server started
16
+ if ! kill -0 $SERVER_PID 2>/dev/null; then
17
+ echo "ERROR: Server failed to start"
18
+ exit 1
19
+ fi
20
+
21
+ echo "Server running (PID: $SERVER_PID)"
22
+ echo ""
23
+
24
+ # List channels
25
+ echo "Listing channels..."
26
+ node bin/agentchat.js channels ws://localhost:6667
27
+ echo ""
28
+
29
+ # Send a test message
30
+ echo "Sending test message..."
31
+ node bin/agentchat.js send ws://localhost:6667 "#general" "Test message from quick-test.sh"
32
+ echo ""
33
+
34
+ # Clean up
35
+ echo "Stopping server..."
36
+ kill $SERVER_PID 2>/dev/null
37
+ wait $SERVER_PID 2>/dev/null
38
+
39
+ echo ""
40
+ echo "=== Test Complete ==="
41
+ echo ""
42
+ echo "To run manually:"
43
+ echo " Terminal 1: node bin/agentchat.js serve"
44
+ echo " Terminal 2: node bin/agentchat.js listen ws://localhost:6667 '#general'"
45
+ echo " Terminal 3: node bin/agentchat.js send ws://localhost:6667 '#general' 'Hello!'"