@sym-bot/sym 0.2.0 → 0.2.1
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/README.md +88 -22
- package/bin/sym-daemon.js +437 -0
- package/integrations/claude-code/mcp-server.js +201 -200
- package/lib/ipc-client.js +241 -0
- package/lib/node.js +2 -1
- package/package.json +3 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IPC client for connecting to sym-daemon as a virtual node.
|
|
5
|
+
*
|
|
6
|
+
* Communicates via Unix socket with newline-delimited JSON.
|
|
7
|
+
* Provides the same API surface as SymNode so consumers
|
|
8
|
+
* (MCP server, etc.) can use either transparently.
|
|
9
|
+
*
|
|
10
|
+
* Copyright (c) 2026 SYM.BOT Ltd. Apache 2.0 License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const net = require('net');
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_SOCKET = '/tmp/sym.sock';
|
|
17
|
+
|
|
18
|
+
class SymDaemonClient extends EventEmitter {
|
|
19
|
+
|
|
20
|
+
constructor(opts = {}) {
|
|
21
|
+
super();
|
|
22
|
+
this._socketPath = opts.socketPath || DEFAULT_SOCKET;
|
|
23
|
+
this._name = opts.name || 'virtual-node';
|
|
24
|
+
this._cognitiveProfile = opts.cognitiveProfile || null;
|
|
25
|
+
this._socket = null;
|
|
26
|
+
this._buffer = '';
|
|
27
|
+
this._connected = false;
|
|
28
|
+
this._requestId = 0;
|
|
29
|
+
this._pendingRequests = new Map(); // id → { resolve, reject, timeout }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether the client is connected to the daemon. */
|
|
33
|
+
get connected() { return this._connected; }
|
|
34
|
+
|
|
35
|
+
/** Connect to the daemon and register as a virtual node. */
|
|
36
|
+
async connect() {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const socket = net.createConnection(this._socketPath, () => {
|
|
39
|
+
this._socket = socket;
|
|
40
|
+
this._connected = true;
|
|
41
|
+
|
|
42
|
+
// Register as virtual node
|
|
43
|
+
this._send({
|
|
44
|
+
type: 'register',
|
|
45
|
+
name: this._name,
|
|
46
|
+
cognitiveProfile: this._cognitiveProfile,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Wait for registered response
|
|
50
|
+
const onFirstMessage = (msg) => {
|
|
51
|
+
if (msg.type === 'registered') {
|
|
52
|
+
this._nodeId = msg.nodeId;
|
|
53
|
+
this._nodeName = msg.name;
|
|
54
|
+
resolve({ nodeId: msg.nodeId, name: msg.name, relay: msg.relay });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
this.once('_raw', onFirstMessage);
|
|
58
|
+
|
|
59
|
+
// Timeout registration
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
this.removeListener('_raw', onFirstMessage);
|
|
62
|
+
reject(new Error('Daemon registration timeout'));
|
|
63
|
+
}, 5000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
socket.on('data', (data) => this._onData(data));
|
|
67
|
+
|
|
68
|
+
socket.on('close', () => {
|
|
69
|
+
this._connected = false;
|
|
70
|
+
this._socket = null;
|
|
71
|
+
this.emit('disconnected');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
socket.on('error', (err) => {
|
|
75
|
+
this._connected = false;
|
|
76
|
+
if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
|
|
77
|
+
reject(new Error('sym-daemon not running'));
|
|
78
|
+
} else {
|
|
79
|
+
reject(err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Disconnect from the daemon. */
|
|
86
|
+
disconnect() {
|
|
87
|
+
if (this._socket) {
|
|
88
|
+
this._socket.end();
|
|
89
|
+
this._socket = null;
|
|
90
|
+
}
|
|
91
|
+
this._connected = false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── SymNode-compatible API ─────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** Broadcast mood to the mesh via daemon. */
|
|
97
|
+
broadcastMood(mood, opts = {}) {
|
|
98
|
+
this._send({ type: 'mood', mood, context: opts.context });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Store a memory in the mesh via daemon. */
|
|
102
|
+
remember(content, opts = {}) {
|
|
103
|
+
this._send({ type: 'remember', content, tags: opts.tags });
|
|
104
|
+
return { key: `pending-${Date.now()}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Search memories via daemon. Returns a promise. */
|
|
108
|
+
async recall(query) {
|
|
109
|
+
const result = await this._request({ type: 'recall', query });
|
|
110
|
+
return result.results || [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Send a message to all peers via daemon. */
|
|
114
|
+
send(message) {
|
|
115
|
+
this._send({ type: 'send', message });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get connected peers via daemon. Returns a promise. */
|
|
119
|
+
async peers() {
|
|
120
|
+
const result = await this._request({ type: 'peers' });
|
|
121
|
+
return result.peers || [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Get full status via daemon. Returns a promise. */
|
|
125
|
+
async status() {
|
|
126
|
+
const result = await this._request({ type: 'status' });
|
|
127
|
+
return result.status || {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** No-op for SymNode compatibility. */
|
|
131
|
+
start() {}
|
|
132
|
+
|
|
133
|
+
/** Disconnect. */
|
|
134
|
+
stop() { this.disconnect(); }
|
|
135
|
+
|
|
136
|
+
// ── Internal ───────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
_send(msg) {
|
|
139
|
+
if (!this._socket || !this._connected) return;
|
|
140
|
+
try {
|
|
141
|
+
this._socket.write(JSON.stringify(msg) + '\n');
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Send a request and wait for matching result. */
|
|
146
|
+
_request(msg) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const action = msg.type;
|
|
149
|
+
this._send(msg);
|
|
150
|
+
|
|
151
|
+
// Listen for next result with matching action
|
|
152
|
+
const handler = (raw) => {
|
|
153
|
+
if (raw.type === 'result' && raw.action === action) {
|
|
154
|
+
this.removeListener('_raw', handler);
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
resolve(raw);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
this.on('_raw', handler);
|
|
160
|
+
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
this.removeListener('_raw', handler);
|
|
163
|
+
reject(new Error(`Daemon request timeout: ${action}`));
|
|
164
|
+
}, 10000);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_onData(data) {
|
|
169
|
+
this._buffer += data.toString();
|
|
170
|
+
let idx;
|
|
171
|
+
while ((idx = this._buffer.indexOf('\n')) !== -1) {
|
|
172
|
+
const line = this._buffer.slice(0, idx);
|
|
173
|
+
this._buffer = this._buffer.slice(idx + 1);
|
|
174
|
+
if (!line.trim()) continue;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const msg = JSON.parse(line);
|
|
178
|
+
this.emit('_raw', msg);
|
|
179
|
+
|
|
180
|
+
// Forward mesh events as SymNode-compatible events
|
|
181
|
+
if (msg.type === 'event') {
|
|
182
|
+
switch (msg.event) {
|
|
183
|
+
case 'mood-accepted':
|
|
184
|
+
this.emit('mood-accepted', msg.data);
|
|
185
|
+
break;
|
|
186
|
+
case 'mood-rejected':
|
|
187
|
+
this.emit('mood-rejected', msg.data);
|
|
188
|
+
break;
|
|
189
|
+
case 'message':
|
|
190
|
+
this.emit('message', msg.data.from, msg.data.content, msg.data);
|
|
191
|
+
break;
|
|
192
|
+
case 'peer-joined':
|
|
193
|
+
this.emit('peer-joined', msg.data);
|
|
194
|
+
break;
|
|
195
|
+
case 'peer-left':
|
|
196
|
+
this.emit('peer-left', msg.data);
|
|
197
|
+
break;
|
|
198
|
+
case 'coupling-decision':
|
|
199
|
+
this.emit('coupling-decision', msg.data);
|
|
200
|
+
break;
|
|
201
|
+
case 'memory-received':
|
|
202
|
+
this.emit('memory-received', msg.data);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Try to connect to sym-daemon. If not running, fall back to a standalone SymNode.
|
|
213
|
+
*
|
|
214
|
+
* Returns an object with the same API surface either way:
|
|
215
|
+
* { node, isDaemon: boolean }
|
|
216
|
+
*/
|
|
217
|
+
async function connectOrFallback(opts = {}) {
|
|
218
|
+
const client = new SymDaemonClient({
|
|
219
|
+
socketPath: opts.socketPath || DEFAULT_SOCKET,
|
|
220
|
+
name: opts.name || 'claude-code',
|
|
221
|
+
cognitiveProfile: opts.cognitiveProfile,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
await client.connect();
|
|
226
|
+
return { node: client, isDaemon: true };
|
|
227
|
+
} catch {
|
|
228
|
+
// Daemon not running — fall back to standalone SymNode
|
|
229
|
+
const { SymNode } = require('./node');
|
|
230
|
+
const node = new SymNode({
|
|
231
|
+
name: opts.name || 'claude-code',
|
|
232
|
+
cognitiveProfile: opts.cognitiveProfile,
|
|
233
|
+
silent: opts.silent ?? true,
|
|
234
|
+
relay: opts.relay,
|
|
235
|
+
relayToken: opts.relayToken,
|
|
236
|
+
});
|
|
237
|
+
return { node, isDaemon: false };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { SymDaemonClient, connectOrFallback };
|
package/lib/node.js
CHANGED
|
@@ -806,7 +806,8 @@ class SymNode extends EventEmitter {
|
|
|
806
806
|
const knownPeers = [];
|
|
807
807
|
for (const [id, wc] of this._peerWakeChannels) {
|
|
808
808
|
if (id !== peer.peerId) {
|
|
809
|
-
|
|
809
|
+
const peerEntry = this._peers.get(id);
|
|
810
|
+
knownPeers.push({ nodeId: id, name: peerEntry?.name || 'unknown', wakeChannel: wc, lastSeen: Date.now() });
|
|
810
811
|
}
|
|
811
812
|
}
|
|
812
813
|
if (knownPeers.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sym-bot/sym",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Local AI mesh — every agent is a sovereign node, the mesh is the agents",
|
|
5
5
|
"main": "lib/node.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"sym-mcp": "./integrations/claude-code/mcp-server.js"
|
|
7
|
+
"sym-mcp": "./integrations/claude-code/mcp-server.js",
|
|
8
|
+
"sym-daemon": "./bin/sym-daemon.js"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"test": "node --test tests/*.test.js"
|