@sym-bot/sym 0.1.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/PRD.md +1 -1
- package/README.md +237 -70
- package/TECHNICAL-SPEC.md +250 -197
- package/bin/setup-claude.sh +31 -1
- package/bin/sym-daemon.js +437 -0
- package/docs/mesh-memory-protocol.md +563 -0
- package/docs/mmp-architecture-image-prompt.txt +12 -0
- package/docs/p2p-protocol-research.md +907 -0
- package/docs/protocol-wake.md +242 -0
- package/integrations/claude-code/mcp-server.js +264 -41
- package/integrations/telegram/bot.js +418 -0
- package/lib/ipc-client.js +241 -0
- package/lib/node.js +489 -39
- package/lib/transport.js +88 -0
- package/package.json +5 -3
- package/sym-relay/Dockerfile +7 -0
- package/sym-relay/lib/logger.js +28 -0
- package/sym-relay/lib/relay.js +388 -0
- package/sym-relay/package-lock.json +40 -0
- package/sym-relay/package.json +18 -0
- package/sym-relay/render.yaml +14 -0
- package/sym-relay/server.js +67 -0
- package/.mcp.json +0 -12
package/lib/node.js
CHANGED
|
@@ -9,6 +9,9 @@ const { nodeDir, loadOrCreateIdentity, ensureDir, log: logMsg } = require('./con
|
|
|
9
9
|
const { MemoryStore } = require('./memory-store');
|
|
10
10
|
const { FrameParser, sendFrame } = require('./frame-parser');
|
|
11
11
|
const { encode, DIM } = require('./context-encoder');
|
|
12
|
+
const { TcpTransport, RelayPeerTransport } = require('./transport');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { createSign } = require('crypto');
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* SymNode — a sovereign mesh node with cognitive coupling.
|
|
@@ -47,6 +50,24 @@ class SymNode extends EventEmitter {
|
|
|
47
50
|
this._port = 0;
|
|
48
51
|
this._running = false;
|
|
49
52
|
|
|
53
|
+
// Relay
|
|
54
|
+
this._relayUrl = opts.relay || null;
|
|
55
|
+
this._relayToken = opts.relayToken || null;
|
|
56
|
+
this._relayOnly = opts.relayOnly || false;
|
|
57
|
+
this._relayWs = null;
|
|
58
|
+
this._relayReconnectTimer = null;
|
|
59
|
+
this._relayReconnectDelay = 1000;
|
|
60
|
+
this._relayPeerTransports = new Map(); // nodeId → RelayPeerTransport
|
|
61
|
+
|
|
62
|
+
// Wake
|
|
63
|
+
this._wakeChannel = opts.wakeChannel || null; // { platform, token, environment }
|
|
64
|
+
this._peerWakeChannels = new Map(); // nodeId → { platform, token, environment }
|
|
65
|
+
this._wakeCooldownMs = opts.wakeCooldownMs || 5 * 60 * 1000; // 5 min default
|
|
66
|
+
this._peerLastWake = new Map(); // nodeId → timestamp
|
|
67
|
+
this._pendingFrames = new Map(); // nodeId → [frames] — queued for woken peers
|
|
68
|
+
this._wakeChannelsFile = path.join(this._dir, 'wake-channels.json');
|
|
69
|
+
this._loadWakeChannels();
|
|
70
|
+
|
|
50
71
|
// Timers
|
|
51
72
|
this._heartbeatInterval = opts.heartbeatInterval || 5000;
|
|
52
73
|
this._heartbeatTimeout = opts.heartbeatTimeout || 15000;
|
|
@@ -77,6 +98,38 @@ class SymNode extends EventEmitter {
|
|
|
77
98
|
return parts.join('\n');
|
|
78
99
|
}
|
|
79
100
|
|
|
101
|
+
// ── Wake Channel Persistence ───────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Load persisted peer wake channels from disk. */
|
|
104
|
+
_loadWakeChannels() {
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(this._wakeChannelsFile)) {
|
|
107
|
+
const data = JSON.parse(fs.readFileSync(this._wakeChannelsFile, 'utf8'));
|
|
108
|
+
for (const [id, ch] of Object.entries(data)) {
|
|
109
|
+
if (ch?.platform && ch?.token) {
|
|
110
|
+
this._peerWakeChannels.set(id, ch);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (this._peerWakeChannels.size > 0) {
|
|
114
|
+
this._log(`Loaded ${this._peerWakeChannels.size} wake channel(s) from disk`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this._log(`Failed to load wake channels: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Persist peer wake channels to disk. */
|
|
123
|
+
_saveWakeChannels() {
|
|
124
|
+
try {
|
|
125
|
+
ensureDir(path.dirname(this._wakeChannelsFile));
|
|
126
|
+
const data = Object.fromEntries(this._peerWakeChannels);
|
|
127
|
+
fs.writeFileSync(this._wakeChannelsFile, JSON.stringify(data, null, 2));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this._log(`Failed to save wake channels: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
80
133
|
_reencodeAndBroadcast() {
|
|
81
134
|
const context = this._buildContext();
|
|
82
135
|
if (context.length < 5) return;
|
|
@@ -122,7 +175,7 @@ class SymNode extends EventEmitter {
|
|
|
122
175
|
this._log(`Not sharing with ${peer.name} — rejected (drift: ${d.drift.toFixed(3)})`);
|
|
123
176
|
continue;
|
|
124
177
|
}
|
|
125
|
-
|
|
178
|
+
peer.transport.send({ type: 'memory-share', ...entry });
|
|
126
179
|
shared++;
|
|
127
180
|
if (d) {
|
|
128
181
|
this._log(`Shared with ${peer.name} — ${d.decision} (drift: ${d.drift.toFixed(3)})`);
|
|
@@ -139,13 +192,19 @@ class SymNode extends EventEmitter {
|
|
|
139
192
|
if (this._running) return;
|
|
140
193
|
this._running = true;
|
|
141
194
|
|
|
142
|
-
|
|
143
|
-
|
|
195
|
+
if (!this._relayOnly) {
|
|
196
|
+
await this._startServer();
|
|
197
|
+
this._startDiscovery();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this._relayUrl) {
|
|
201
|
+
this._connectRelay();
|
|
202
|
+
}
|
|
144
203
|
|
|
145
204
|
this._heartbeatTimer = setInterval(() => this._checkHeartbeats(), this._heartbeatInterval);
|
|
146
205
|
this._encodeTimer = setInterval(() => this._reencodeAndBroadcast(), this._encodeInterval);
|
|
147
206
|
|
|
148
|
-
this._log(`Started (port: ${this._port}, id: ${this._identity.nodeId.slice(0, 8)})`);
|
|
207
|
+
this._log(`Started (port: ${this._port}, id: ${this._identity.nodeId.slice(0, 8)}${this._relayUrl ? ', relay: ' + this._relayUrl : ''})`);
|
|
149
208
|
}
|
|
150
209
|
|
|
151
210
|
async stop() {
|
|
@@ -154,9 +213,21 @@ class SymNode extends EventEmitter {
|
|
|
154
213
|
|
|
155
214
|
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
156
215
|
if (this._encodeTimer) clearInterval(this._encodeTimer);
|
|
216
|
+
if (this._relayReconnectTimer) clearTimeout(this._relayReconnectTimer);
|
|
217
|
+
|
|
218
|
+
// Close relay transports
|
|
219
|
+
for (const [, transport] of this._relayPeerTransports) {
|
|
220
|
+
transport.destroy();
|
|
221
|
+
}
|
|
222
|
+
this._relayPeerTransports.clear();
|
|
223
|
+
|
|
224
|
+
if (this._relayWs) {
|
|
225
|
+
try { this._relayWs.close(); } catch {}
|
|
226
|
+
this._relayWs = null;
|
|
227
|
+
}
|
|
157
228
|
|
|
158
229
|
for (const [, peer] of this._peers) {
|
|
159
|
-
|
|
230
|
+
peer.transport.close();
|
|
160
231
|
}
|
|
161
232
|
this._peers.clear();
|
|
162
233
|
|
|
@@ -184,7 +255,6 @@ class SymNode extends EventEmitter {
|
|
|
184
255
|
this._meshNode.updateLocalState(h1, h2, 0.8);
|
|
185
256
|
|
|
186
257
|
// Evaluate coupling with each peer — only share with aligned/guarded
|
|
187
|
-
// Trigger coupling evaluation
|
|
188
258
|
this._meshNode.coupledState();
|
|
189
259
|
const decisions = this._meshNode.couplingDecisions;
|
|
190
260
|
|
|
@@ -197,7 +267,7 @@ class SymNode extends EventEmitter {
|
|
|
197
267
|
continue;
|
|
198
268
|
}
|
|
199
269
|
|
|
200
|
-
|
|
270
|
+
peer.transport.send({ type: 'memory-share', ...entry });
|
|
201
271
|
shared++;
|
|
202
272
|
|
|
203
273
|
if (d) {
|
|
@@ -231,6 +301,10 @@ class SymNode extends EventEmitter {
|
|
|
231
301
|
timestamp: Date.now(),
|
|
232
302
|
};
|
|
233
303
|
this._broadcastToPeers(frame);
|
|
304
|
+
|
|
305
|
+
// Wake sleeping peers and queue the frame for delivery on reconnect
|
|
306
|
+
this._wakeSleepingPeers('mood', frame);
|
|
307
|
+
|
|
234
308
|
this._log(`Mood broadcast: "${mood.slice(0, 50)}"`);
|
|
235
309
|
}
|
|
236
310
|
|
|
@@ -246,12 +320,195 @@ class SymNode extends EventEmitter {
|
|
|
246
320
|
};
|
|
247
321
|
if (opts.to) {
|
|
248
322
|
const peer = this._peers.get(opts.to);
|
|
249
|
-
if (peer)
|
|
323
|
+
if (peer) peer.transport.send(frame);
|
|
250
324
|
} else {
|
|
251
325
|
this._broadcastToPeers(frame);
|
|
252
326
|
}
|
|
253
327
|
}
|
|
254
328
|
|
|
329
|
+
// ── Wake ────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Wake a sleeping peer via push notification.
|
|
333
|
+
* Autonomous decision: checks transport, coupling, cooldown.
|
|
334
|
+
*/
|
|
335
|
+
async wakeIfNeeded(peerId, reason = 'message') {
|
|
336
|
+
// 1. Is peer transport active?
|
|
337
|
+
const peer = this._peers.get(peerId);
|
|
338
|
+
if (peer?.transport) return false; // peer is connected, no wake needed
|
|
339
|
+
|
|
340
|
+
// 2. Does peer have a wake channel?
|
|
341
|
+
const wakeChannel = this._peerWakeChannels.get(peerId);
|
|
342
|
+
if (!wakeChannel || wakeChannel.platform === 'none') return false;
|
|
343
|
+
|
|
344
|
+
// 3. Is peer coupled? (autonomous decision)
|
|
345
|
+
const d = this._meshNode.couplingDecisions.get(peerId);
|
|
346
|
+
if (d && d.decision === 'rejected') return false;
|
|
347
|
+
|
|
348
|
+
// 4. Cooldown check
|
|
349
|
+
const lastWake = this._peerLastWake.get(peerId) || 0;
|
|
350
|
+
if (Date.now() - lastWake < this._wakeCooldownMs) return false;
|
|
351
|
+
|
|
352
|
+
// 5. Send wake
|
|
353
|
+
try {
|
|
354
|
+
await this._sendWake(wakeChannel, reason);
|
|
355
|
+
this._peerLastWake.set(peerId, Date.now());
|
|
356
|
+
this._log(`Wake sent to ${peerId}: ${reason} via ${wakeChannel.platform}`);
|
|
357
|
+
return true;
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this._log(`Wake failed for ${peerId}: ${err.message}`);
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Wake all sleeping coupled peers.
|
|
366
|
+
*/
|
|
367
|
+
async wakeAllPeers(reason = 'message') {
|
|
368
|
+
const promises = [];
|
|
369
|
+
for (const [peerId] of this._peerWakeChannels) {
|
|
370
|
+
promises.push(this.wakeIfNeeded(peerId, reason));
|
|
371
|
+
}
|
|
372
|
+
const results = await Promise.allSettled(promises);
|
|
373
|
+
return results.filter(r => r.status === 'fulfilled' && r.value).length;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Attempt to wake all sleeping peers that have wake channels
|
|
378
|
+
* but no active transport. Queues the frame for delivery on reconnect.
|
|
379
|
+
*/
|
|
380
|
+
_wakeSleepingPeers(reason, pendingFrame) {
|
|
381
|
+
for (const [peerId] of this._peerWakeChannels) {
|
|
382
|
+
if (!this._peers.has(peerId)) {
|
|
383
|
+
// Queue frame for delivery when this peer reconnects
|
|
384
|
+
if (pendingFrame) {
|
|
385
|
+
if (!this._pendingFrames.has(peerId)) {
|
|
386
|
+
this._pendingFrames.set(peerId, []);
|
|
387
|
+
}
|
|
388
|
+
this._pendingFrames.get(peerId).push(pendingFrame);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.wakeIfNeeded(peerId, reason).catch(err => {
|
|
392
|
+
this._log(`Wake failed for ${peerId.slice(0, 8)}: ${err.message}`);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Route wake to the appropriate platform transport. */
|
|
399
|
+
async _sendWake(wakeChannel, reason) {
|
|
400
|
+
switch (wakeChannel.platform) {
|
|
401
|
+
case 'apns':
|
|
402
|
+
return this._sendAPNsWake(wakeChannel, reason);
|
|
403
|
+
default:
|
|
404
|
+
throw new Error(`Unsupported wake platform: ${wakeChannel.platform}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Send an APNs push notification to wake a sleeping iOS node.
|
|
410
|
+
*
|
|
411
|
+
* Uses JWT (ES256) authentication with a p8 key from ~/.sym/wake-keys/.
|
|
412
|
+
* Sends a visible alert (iOS throttles silent pushes making them unreliable).
|
|
413
|
+
* The content-available flag triggers background processing on delivery.
|
|
414
|
+
*/
|
|
415
|
+
async _sendAPNsWake(wakeChannel, reason) {
|
|
416
|
+
const http2 = require('http2');
|
|
417
|
+
const keysDir = path.join(require('os').homedir(), '.sym', 'wake-keys');
|
|
418
|
+
const configPath = path.join(keysDir, 'apns-config.json');
|
|
419
|
+
const keyPath = path.join(keysDir, 'apns-key.p8');
|
|
420
|
+
|
|
421
|
+
// Load and cache APNs credentials on first use
|
|
422
|
+
if (!this._apnsConfig) {
|
|
423
|
+
if (!fs.existsSync(configPath) || !fs.existsSync(keyPath)) {
|
|
424
|
+
throw new Error('APNs keys not found at ~/.sym/wake-keys/ (need apns-key.p8 + apns-config.json)');
|
|
425
|
+
}
|
|
426
|
+
this._apnsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
427
|
+
this._apnsKey = fs.readFileSync(keyPath, 'utf8');
|
|
428
|
+
|
|
429
|
+
if (!this._apnsConfig.teamId || !this._apnsConfig.keyId || !this._apnsConfig.bundleId) {
|
|
430
|
+
this._apnsConfig = null;
|
|
431
|
+
throw new Error('apns-config.json must have teamId, keyId, and bundleId');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { teamId, keyId, bundleId } = this._apnsConfig;
|
|
436
|
+
|
|
437
|
+
// Build JWT (ES256) — valid for up to 1 hour per Apple spec
|
|
438
|
+
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: keyId })).toString('base64url');
|
|
439
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
440
|
+
const claims = Buffer.from(JSON.stringify({ iss: teamId, iat })).toString('base64url');
|
|
441
|
+
const signer = createSign('SHA256');
|
|
442
|
+
signer.update(`${header}.${claims}`);
|
|
443
|
+
const signature = signer.sign({ key: this._apnsKey, dsaEncoding: 'ieee-p1363' }, 'base64url');
|
|
444
|
+
const jwt = `${header}.${claims}.${signature}`;
|
|
445
|
+
|
|
446
|
+
// APNs requires HTTP/2 — Node.js fetch does not support it
|
|
447
|
+
const host = wakeChannel.environment === 'sandbox'
|
|
448
|
+
? 'api.sandbox.push.apple.com'
|
|
449
|
+
: 'api.push.apple.com';
|
|
450
|
+
|
|
451
|
+
// Visible alert with content-available for background processing
|
|
452
|
+
const reasonText = {
|
|
453
|
+
mood: 'shared a mood signal',
|
|
454
|
+
message: 'sent a message',
|
|
455
|
+
memory: 'shared a memory',
|
|
456
|
+
}[reason] || 'wants to connect';
|
|
457
|
+
|
|
458
|
+
const payload = JSON.stringify({
|
|
459
|
+
aps: {
|
|
460
|
+
alert: { title: 'SYM Mesh', body: `${this.name}: ${reasonText}` },
|
|
461
|
+
'content-available': 1,
|
|
462
|
+
sound: 'default',
|
|
463
|
+
},
|
|
464
|
+
mmp: {
|
|
465
|
+
type: 'wake',
|
|
466
|
+
from: this._identity.nodeId,
|
|
467
|
+
fromName: this.name,
|
|
468
|
+
reason,
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
const client = http2.connect(`https://${host}`);
|
|
474
|
+
|
|
475
|
+
client.on('error', (err) => {
|
|
476
|
+
client.close();
|
|
477
|
+
reject(new Error(`APNs connection failed: ${err.message}`));
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const req = client.request({
|
|
481
|
+
':method': 'POST',
|
|
482
|
+
':path': `/3/device/${wakeChannel.token}`,
|
|
483
|
+
'authorization': `bearer ${jwt}`,
|
|
484
|
+
'apns-topic': bundleId,
|
|
485
|
+
'apns-push-type': 'alert',
|
|
486
|
+
'apns-priority': '10',
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
let status;
|
|
490
|
+
let body = '';
|
|
491
|
+
|
|
492
|
+
req.on('response', (headers) => { status = headers[':status']; });
|
|
493
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
494
|
+
req.on('end', () => {
|
|
495
|
+
client.close();
|
|
496
|
+
if (status === 200) {
|
|
497
|
+
resolve();
|
|
498
|
+
} else {
|
|
499
|
+
reject(new Error(`APNs responded ${status}: ${body}`));
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
req.on('error', (err) => {
|
|
503
|
+
client.close();
|
|
504
|
+
reject(new Error(`APNs request failed: ${err.message}`));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
req.write(payload);
|
|
508
|
+
req.end();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
255
512
|
// ── Monitoring ─────────────────────────────────────────────
|
|
256
513
|
|
|
257
514
|
peers() {
|
|
@@ -266,6 +523,7 @@ class SymNode extends EventEmitter {
|
|
|
266
523
|
lastSeen: peer.lastSeen,
|
|
267
524
|
coupling: d ? d.decision : 'pending',
|
|
268
525
|
drift: d ? parseFloat(d.drift.toFixed(3)) : null,
|
|
526
|
+
source: peer.source || 'bonjour',
|
|
269
527
|
});
|
|
270
528
|
}
|
|
271
529
|
return result;
|
|
@@ -285,6 +543,8 @@ class SymNode extends EventEmitter {
|
|
|
285
543
|
nodeId: this._identity.nodeId.slice(0, 8),
|
|
286
544
|
running: this._running,
|
|
287
545
|
port: this._port,
|
|
546
|
+
relay: this._relayUrl || null,
|
|
547
|
+
relayConnected: this._relayWs?.readyState === 1 || false,
|
|
288
548
|
peers: this.peers(),
|
|
289
549
|
peerCount: this._peers.size,
|
|
290
550
|
memoryCount: this.memories(),
|
|
@@ -311,24 +571,30 @@ class SymNode extends EventEmitter {
|
|
|
311
571
|
}
|
|
312
572
|
|
|
313
573
|
_handleInboundConnection(socket) {
|
|
314
|
-
const
|
|
574
|
+
const transport = new TcpTransport(socket);
|
|
315
575
|
let identified = false;
|
|
316
|
-
const timeout = setTimeout(() => { if (!identified)
|
|
576
|
+
const timeout = setTimeout(() => { if (!identified) transport.close(); }, 10000);
|
|
317
577
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
parser.on('message', (msg) => {
|
|
578
|
+
transport.on('message', (msg) => {
|
|
321
579
|
if (identified) return;
|
|
322
|
-
if (msg.type !== 'handshake') {
|
|
580
|
+
if (msg.type !== 'handshake') { transport.close(); return; }
|
|
323
581
|
identified = true;
|
|
324
582
|
clearTimeout(timeout);
|
|
325
|
-
if (this._peers.has(msg.nodeId)) {
|
|
326
|
-
|
|
327
|
-
|
|
583
|
+
if (this._peers.has(msg.nodeId)) { transport.close(); return; }
|
|
584
|
+
|
|
585
|
+
// Re-wire transport messages to peer handler
|
|
586
|
+
transport.removeAllListeners('message');
|
|
587
|
+
transport.on('message', (m) => {
|
|
588
|
+
const peer = this._peers.get(msg.nodeId);
|
|
589
|
+
if (peer) peer.lastSeen = Date.now();
|
|
590
|
+
this._handlePeerMessage(msg.nodeId, msg.name, m);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const peer = this._createPeer(transport, msg.nodeId, msg.name, false, 'bonjour');
|
|
328
594
|
this._addPeer(peer);
|
|
329
595
|
});
|
|
330
596
|
|
|
331
|
-
|
|
597
|
+
transport.on('error', () => clearTimeout(timeout));
|
|
332
598
|
}
|
|
333
599
|
|
|
334
600
|
// ── Bonjour Discovery ──────────────────────────────────────
|
|
@@ -362,46 +628,204 @@ class SymNode extends EventEmitter {
|
|
|
362
628
|
_connectToPeer(address, port, peerId, peerName) {
|
|
363
629
|
if (this._peers.has(peerId)) return;
|
|
364
630
|
const socket = net.createConnection({ host: address, port }, () => {
|
|
365
|
-
const
|
|
631
|
+
const transport = new TcpTransport(socket);
|
|
632
|
+
transport.on('message', (msg) => {
|
|
633
|
+
const peer = this._peers.get(peerId);
|
|
634
|
+
if (peer) peer.lastSeen = Date.now();
|
|
635
|
+
this._handlePeerMessage(peerId, peerName, msg);
|
|
636
|
+
});
|
|
637
|
+
transport.on('error', () => {});
|
|
638
|
+
const peer = this._createPeer(transport, peerId, peerName, true, 'bonjour');
|
|
366
639
|
this._addPeer(peer);
|
|
367
640
|
});
|
|
368
641
|
socket.on('error', (err) => this._log(`Connect failed to ${peerName}: ${err.message}`));
|
|
369
642
|
socket.setTimeout(10000, () => socket.destroy());
|
|
370
643
|
}
|
|
371
644
|
|
|
372
|
-
// ──
|
|
645
|
+
// ── WebSocket Relay ─────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
_connectRelay() {
|
|
648
|
+
if (!this._running || !this._relayUrl) return;
|
|
649
|
+
|
|
650
|
+
let WebSocket;
|
|
651
|
+
try {
|
|
652
|
+
WebSocket = require('ws');
|
|
653
|
+
} catch {
|
|
654
|
+
this._log('Relay requires the "ws" package — npm install ws');
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const ws = new WebSocket(this._relayUrl);
|
|
659
|
+
this._relayWs = ws;
|
|
660
|
+
|
|
661
|
+
ws.on('open', () => {
|
|
662
|
+
this._relayReconnectDelay = 1000;
|
|
663
|
+
this._log(`Relay connected: ${this._relayUrl}`);
|
|
664
|
+
|
|
665
|
+
// Authenticate with the relay
|
|
666
|
+
const auth = {
|
|
667
|
+
type: 'relay-auth',
|
|
668
|
+
nodeId: this._identity.nodeId,
|
|
669
|
+
name: this.name,
|
|
670
|
+
wakeChannel: this._wakeChannel || undefined,
|
|
671
|
+
};
|
|
672
|
+
if (this._relayToken) auth.token = this._relayToken;
|
|
673
|
+
ws.send(JSON.stringify(auth));
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
ws.on('message', (data) => {
|
|
677
|
+
let msg;
|
|
678
|
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
679
|
+
|
|
680
|
+
if (msg.type === 'relay-peer-joined') {
|
|
681
|
+
this._handleRelayPeerJoined(msg.nodeId, msg.name);
|
|
682
|
+
} else if (msg.type === 'relay-peer-left') {
|
|
683
|
+
this._handleRelayPeerLeft(msg.nodeId, msg.name);
|
|
684
|
+
} else if (msg.type === 'relay-peers') {
|
|
685
|
+
// Initial peer list from relay — includes gossip (wake channels)
|
|
686
|
+
for (const p of (msg.peers || [])) {
|
|
687
|
+
// Store gossiped wake channels
|
|
688
|
+
if (p.wakeChannel && p.wakeChannel.platform !== 'none') {
|
|
689
|
+
this._peerWakeChannels.set(p.nodeId, p.wakeChannel);
|
|
690
|
+
this._log(`Gossip: learned wake channel for ${p.name} (${p.wakeChannel.platform})`);
|
|
691
|
+
}
|
|
692
|
+
// Only connect to online peers
|
|
693
|
+
if (!p.offline) {
|
|
694
|
+
this._handleRelayPeerJoined(p.nodeId, p.name);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
this._saveWakeChannels();
|
|
698
|
+
} else if (msg.type === 'relay-ping') {
|
|
699
|
+
ws.send(JSON.stringify({ type: 'relay-pong' }));
|
|
700
|
+
} else if (msg.type === 'relay-error') {
|
|
701
|
+
this._log(`Relay error: ${msg.message}`);
|
|
702
|
+
} else if (msg.from && msg.payload) {
|
|
703
|
+
// Routed frame from a peer
|
|
704
|
+
const peer = this._peers.get(msg.from);
|
|
705
|
+
if (peer) peer.lastSeen = Date.now();
|
|
706
|
+
this._handlePeerMessage(msg.from, msg.fromName || 'unknown', msg.payload);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
ws.on('close', () => {
|
|
711
|
+
this._log('Relay disconnected');
|
|
712
|
+
this._relayWs = null;
|
|
713
|
+
|
|
714
|
+
// Clean up relay peers
|
|
715
|
+
for (const [peerId, peer] of this._peers) {
|
|
716
|
+
if (peer.source === 'relay') {
|
|
717
|
+
this._peers.delete(peerId);
|
|
718
|
+
this._meshNode.removePeer(peerId);
|
|
719
|
+
this.emit('peer-left', { id: peerId, name: peer.name });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
for (const [, transport] of this._relayPeerTransports) {
|
|
723
|
+
transport.destroy();
|
|
724
|
+
}
|
|
725
|
+
this._relayPeerTransports.clear();
|
|
373
726
|
|
|
374
|
-
|
|
375
|
-
const parser = new FrameParser();
|
|
376
|
-
socket.on('data', (chunk) => parser.feed(chunk));
|
|
377
|
-
parser.on('message', (msg) => {
|
|
378
|
-
const peer = this._peers.get(peerId);
|
|
379
|
-
if (peer) peer.lastSeen = Date.now();
|
|
380
|
-
this._handlePeerMessage(peerId, peerName, msg);
|
|
727
|
+
this._scheduleRelayReconnect();
|
|
381
728
|
});
|
|
382
|
-
|
|
383
|
-
|
|
729
|
+
|
|
730
|
+
ws.on('error', (err) => {
|
|
731
|
+
this._log(`Relay error: ${err.message}`);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
_handleRelayPeerJoined(peerId, peerName) {
|
|
736
|
+
if (!peerId || peerId === this._identity.nodeId) return;
|
|
737
|
+
if (this._peers.has(peerId)) return; // Already connected (via Bonjour or relay)
|
|
738
|
+
|
|
739
|
+
const transport = new RelayPeerTransport(this._relayWs, peerId);
|
|
740
|
+
this._relayPeerTransports.set(peerId, transport);
|
|
741
|
+
|
|
742
|
+
transport.on('close', () => {
|
|
743
|
+
this._relayPeerTransports.delete(peerId);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const peer = this._createPeer(transport, peerId, peerName, true, 'relay');
|
|
747
|
+
this._addPeer(peer);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
_handleRelayPeerLeft(peerId, peerName) {
|
|
751
|
+
const peer = this._peers.get(peerId);
|
|
752
|
+
if (!peer || peer.source !== 'relay') return;
|
|
753
|
+
|
|
754
|
+
const transport = this._relayPeerTransports.get(peerId);
|
|
755
|
+
if (transport) transport.destroy();
|
|
756
|
+
this._relayPeerTransports.delete(peerId);
|
|
757
|
+
|
|
758
|
+
this._peers.delete(peerId);
|
|
759
|
+
this._meshNode.removePeer(peerId);
|
|
760
|
+
this._log(`Relay peer left: ${peerName || peerId}`);
|
|
761
|
+
this.emit('peer-left', { id: peerId, name: peerName || peer.name });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
_scheduleRelayReconnect() {
|
|
765
|
+
if (!this._running || !this._relayUrl) return;
|
|
766
|
+
|
|
767
|
+
const jitter = this._relayReconnectDelay * 0.1 * Math.random();
|
|
768
|
+
const delay = this._relayReconnectDelay + jitter;
|
|
769
|
+
|
|
770
|
+
this._log(`Relay reconnecting in ${Math.round(delay / 1000)}s`);
|
|
771
|
+
this._relayReconnectTimer = setTimeout(() => this._connectRelay(), delay);
|
|
772
|
+
|
|
773
|
+
// Exponential backoff, capped at 30s
|
|
774
|
+
this._relayReconnectDelay = Math.min(this._relayReconnectDelay * 2, 30000);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── Peer Management ────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
_createPeer(transport, peerId, peerName, isOutbound, source) {
|
|
780
|
+
transport.on('close', () => {
|
|
384
781
|
this._peers.delete(peerId);
|
|
385
782
|
this._meshNode.removePeer(peerId);
|
|
386
783
|
this._log(`Peer disconnected: ${peerName}`);
|
|
387
784
|
this.emit('peer-left', { id: peerId, name: peerName });
|
|
388
785
|
});
|
|
389
|
-
|
|
390
|
-
return {
|
|
786
|
+
|
|
787
|
+
return { transport, peerId, name: peerName, isOutbound, source, lastSeen: Date.now() };
|
|
391
788
|
}
|
|
392
789
|
|
|
393
790
|
_addPeer(peer) {
|
|
394
791
|
this._peers.set(peer.peerId, peer);
|
|
395
792
|
|
|
396
793
|
// Handshake
|
|
397
|
-
|
|
794
|
+
peer.transport.send({ type: 'handshake', nodeId: this._identity.nodeId, name: this.name });
|
|
398
795
|
|
|
399
796
|
// Send cognitive state for coupling evaluation
|
|
400
797
|
const [h1, h2] = this._meshNode.coupledState();
|
|
401
|
-
|
|
798
|
+
peer.transport.send({ type: 'state-sync', h1, h2, confidence: 0.8 });
|
|
799
|
+
|
|
800
|
+
// Send wake channel if configured (legacy, for backward compat)
|
|
801
|
+
if (this._wakeChannel) {
|
|
802
|
+
peer.transport.send({ type: 'wake-channel', ...this._wakeChannel });
|
|
803
|
+
}
|
|
402
804
|
|
|
403
|
-
|
|
805
|
+
// Send peer-info gossip — share what we know about other peers
|
|
806
|
+
const knownPeers = [];
|
|
807
|
+
for (const [id, wc] of this._peerWakeChannels) {
|
|
808
|
+
if (id !== peer.peerId) {
|
|
809
|
+
const peerEntry = this._peers.get(id);
|
|
810
|
+
knownPeers.push({ nodeId: id, name: peerEntry?.name || 'unknown', wakeChannel: wc, lastSeen: Date.now() });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (knownPeers.length > 0) {
|
|
814
|
+
peer.transport.send({ type: 'peer-info', peers: knownPeers });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
this._log(`Peer connected: ${peer.name} (${peer.isOutbound ? 'outbound' : 'inbound'}, ${peer.source})`);
|
|
404
818
|
this.emit('peer-joined', { id: peer.peerId, name: peer.name });
|
|
819
|
+
|
|
820
|
+
// Deliver any frames queued while this peer was sleeping
|
|
821
|
+
const pending = this._pendingFrames.get(peer.peerId);
|
|
822
|
+
if (pending && pending.length > 0) {
|
|
823
|
+
this._log(`Delivering ${pending.length} pending frame(s) to ${peer.name}`);
|
|
824
|
+
for (const frame of pending) {
|
|
825
|
+
peer.transport.send(frame);
|
|
826
|
+
}
|
|
827
|
+
this._pendingFrames.delete(peer.peerId);
|
|
828
|
+
}
|
|
405
829
|
}
|
|
406
830
|
|
|
407
831
|
_handlePeerMessage(peerId, peerName, msg) {
|
|
@@ -464,15 +888,41 @@ class SymNode extends EventEmitter {
|
|
|
464
888
|
}
|
|
465
889
|
break;
|
|
466
890
|
|
|
891
|
+
case 'wake-channel':
|
|
892
|
+
if (msg.platform) {
|
|
893
|
+
this._peerWakeChannels.set(peerId, {
|
|
894
|
+
platform: msg.platform,
|
|
895
|
+
token: msg.token,
|
|
896
|
+
environment: msg.environment,
|
|
897
|
+
});
|
|
898
|
+
this._saveWakeChannels();
|
|
899
|
+
this._log(`Wake channel from ${peerName}: ${msg.platform}`);
|
|
900
|
+
}
|
|
901
|
+
break;
|
|
902
|
+
|
|
903
|
+
case 'peer-info':
|
|
904
|
+
// Gossip: peer is sharing what it knows about other peers
|
|
905
|
+
if (Array.isArray(msg.peers)) {
|
|
906
|
+
for (const p of msg.peers) {
|
|
907
|
+
if (p.nodeId && p.wakeChannel && p.nodeId !== this._identity.nodeId) {
|
|
908
|
+
this._peerWakeChannels.set(p.nodeId, p.wakeChannel);
|
|
909
|
+
this._log(`Gossip from ${peerName}: learned wake channel for ${p.name}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
this._saveWakeChannels();
|
|
913
|
+
}
|
|
914
|
+
break;
|
|
915
|
+
|
|
467
916
|
case 'message':
|
|
468
917
|
this._log(`Message from ${msg.fromName || peerName}: ${(msg.content || '').slice(0, 60)}`);
|
|
469
918
|
this.emit('message', msg.fromName || peerName, msg.content, msg);
|
|
470
919
|
break;
|
|
471
920
|
|
|
472
|
-
case 'ping':
|
|
921
|
+
case 'ping': {
|
|
473
922
|
const peer = this._peers.get(peerId);
|
|
474
|
-
if (peer)
|
|
923
|
+
if (peer) peer.transport.send({ type: 'pong' });
|
|
475
924
|
break;
|
|
925
|
+
}
|
|
476
926
|
|
|
477
927
|
case 'pong':
|
|
478
928
|
break;
|
|
@@ -481,7 +931,7 @@ class SymNode extends EventEmitter {
|
|
|
481
931
|
|
|
482
932
|
_broadcastToPeers(frame) {
|
|
483
933
|
for (const [, peer] of this._peers) {
|
|
484
|
-
|
|
934
|
+
peer.transport.send(frame);
|
|
485
935
|
}
|
|
486
936
|
}
|
|
487
937
|
|
|
@@ -490,11 +940,11 @@ class SymNode extends EventEmitter {
|
|
|
490
940
|
for (const [id, peer] of this._peers) {
|
|
491
941
|
if (now - peer.lastSeen > this._heartbeatTimeout) {
|
|
492
942
|
this._log(`Heartbeat timeout: ${peer.name}`);
|
|
493
|
-
peer.
|
|
943
|
+
peer.transport.close();
|
|
494
944
|
this._peers.delete(id);
|
|
495
945
|
this._meshNode.removePeer(id);
|
|
496
946
|
} else if (now - peer.lastSeen > this._heartbeatInterval) {
|
|
497
|
-
|
|
947
|
+
peer.transport.send({ type: 'ping' });
|
|
498
948
|
}
|
|
499
949
|
}
|
|
500
950
|
}
|