@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
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Your AI agents share everything or nothing. There's no intelligence in the decis
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@sym-bot/sym)
|
|
8
8
|
[](LICENSE)
|
|
9
|
-
[](https://sym.bot/protocol)
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -33,6 +33,48 @@ await node.stop();
|
|
|
33
33
|
|
|
34
34
|
Peers on the same network discover each other automatically via Bonjour. Peers across the internet connect through the WebSocket relay. The coupling engine decides what to share.
|
|
35
35
|
|
|
36
|
+
## sym-daemon
|
|
37
|
+
|
|
38
|
+
The daemon is your device's persistent mesh presence — a **physical node** that runs continuously as a background service. It maintains relay connections, Bonjour discovery, and peer state even when no apps are running.
|
|
39
|
+
|
|
40
|
+
### Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Install the daemon as a launchd LaunchAgent (macOS)
|
|
44
|
+
node bin/sym-daemon.js --install
|
|
45
|
+
|
|
46
|
+
# Check daemon status
|
|
47
|
+
node bin/sym-daemon.js --status
|
|
48
|
+
|
|
49
|
+
# Remove the daemon
|
|
50
|
+
node bin/sym-daemon.js --uninstall
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
On macOS, the daemon installs as a launchd LaunchAgent that:
|
|
54
|
+
- **Auto-starts on login** — mesh presence begins immediately
|
|
55
|
+
- **Auto-restarts on crash** — the mesh never goes down
|
|
56
|
+
- **Maintains relay connection permanently** — internet peers always reachable
|
|
57
|
+
|
|
58
|
+
### Physical and Virtual Nodes
|
|
59
|
+
|
|
60
|
+
The daemon introduces a two-tier node model:
|
|
61
|
+
|
|
62
|
+
- **Physical node** (sym-daemon) — the device itself. One per machine. Always running. Owns the relay connection, Bonjour identity, and peer state.
|
|
63
|
+
- **Virtual nodes** (apps) — Claude Code, MeloTune Mac, or any app that connects to the daemon via Unix socket IPC at `/tmp/sym.sock`.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
MacBook (sym-daemon, always running)
|
|
67
|
+
├── Claude Code (virtual node, via IPC)
|
|
68
|
+
├── MeloTune Mac (virtual node, via IPC)
|
|
69
|
+
│
|
|
70
|
+
├── Bonjour (LAN peers)
|
|
71
|
+
└── Relay (internet peers)
|
|
72
|
+
├── MeloTune iPhone
|
|
73
|
+
└── Telegram bot
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
When Claude Code restarts, the mesh doesn't break. The daemon holds the connection. When Claude Code starts again, it reconnects to the daemon via IPC and resumes where it left off. The mesh is the daemon — apps come and go.
|
|
77
|
+
|
|
36
78
|
## Cognitive Coupling
|
|
37
79
|
|
|
38
80
|
SYM doesn't blindly broadcast. Each node encodes its memories into a hidden state vector. When peers connect, the coupling engine evaluates cognitive drift:
|
|
@@ -57,25 +99,20 @@ The blogger never sees the API bug. The architecture decided — no policy, no r
|
|
|
57
99
|
|
|
58
100
|
## How It Works
|
|
59
101
|
|
|
60
|
-
There is no central service. Each
|
|
102
|
+
There is no central service. Each device runs a sym-daemon that maintains its mesh identity, memory, and cognitive state. Apps connect to the daemon as virtual nodes. The mesh emerges from peer connections between physical nodes.
|
|
61
103
|
|
|
62
104
|
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
│
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
└────────┼────────┘ └──────┼────────┘ └────────┼───────┘
|
|
72
|
-
│ │ │
|
|
73
|
-
├── Bonjour (LAN) ─┤ │
|
|
74
|
-
│ │ │
|
|
75
|
-
└──── WebSocket Relay (internet) ────────┘
|
|
105
|
+
MacBook (sym-daemon, always running)
|
|
106
|
+
├── Claude Code (virtual node, via IPC)
|
|
107
|
+
├── MeloTune Mac (virtual node, via IPC)
|
|
108
|
+
│
|
|
109
|
+
├── Bonjour (LAN peers)
|
|
110
|
+
└── Relay (internet peers)
|
|
111
|
+
├── MeloTune iPhone
|
|
112
|
+
└── Telegram bot
|
|
76
113
|
```
|
|
77
114
|
|
|
78
|
-
Each node:
|
|
115
|
+
Each physical node:
|
|
79
116
|
- Declares its cognitive identity via `cognitiveProfile`
|
|
80
117
|
- Encodes context into a hidden state vector (context encoder)
|
|
81
118
|
- Discovers local peers via Bonjour/mDNS (`_sym._tcp`)
|
|
@@ -84,6 +121,13 @@ Each node:
|
|
|
84
121
|
- Shares memories only with aligned peers (drift ≤ 0.5)
|
|
85
122
|
- Evaluates incoming mood with configurable `moodThreshold` (default 0.8)
|
|
86
123
|
- Re-encodes periodically as context evolves
|
|
124
|
+
- Accepts virtual node connections via Unix socket IPC (`/tmp/sym.sock`)
|
|
125
|
+
|
|
126
|
+
Each virtual node:
|
|
127
|
+
- Connects to the local daemon via IPC — no direct network access needed
|
|
128
|
+
- Sends memories, moods, and messages through the daemon
|
|
129
|
+
- Receives mesh events (peer joins, coupling decisions, incoming memories)
|
|
130
|
+
- Can start and stop independently without disrupting the mesh
|
|
87
131
|
|
|
88
132
|
One engine makes every decision. Memory sharing, peer coupling, and mood relevance all go through the [Mesh Cognition SDK](https://github.com/sym-bot/mesh-cognition-sdk)'s `SemanticCoupler`.
|
|
89
133
|
|
|
@@ -135,7 +179,7 @@ await node.stop();
|
|
|
135
179
|
|
|
136
180
|
## Transport
|
|
137
181
|
|
|
138
|
-
SYM supports
|
|
182
|
+
SYM supports multiple transport layers that can run simultaneously:
|
|
139
183
|
|
|
140
184
|
### Bonjour (LAN)
|
|
141
185
|
|
|
@@ -143,7 +187,7 @@ Zero-configuration discovery on the local network. Peers find each other automat
|
|
|
143
187
|
|
|
144
188
|
### WebSocket Relay (Internet)
|
|
145
189
|
|
|
146
|
-
For mesh cognition across the internet. A lightweight relay server forwards frames between authenticated nodes. The relay is
|
|
190
|
+
For mesh cognition across the internet. A lightweight relay server forwards frames between authenticated nodes. The relay is a **peer, not infrastructure** — it participates in the mesh with its own identity but makes no coupling decisions on behalf of other nodes.
|
|
147
191
|
|
|
148
192
|
```bash
|
|
149
193
|
# Deploy your own relay
|
|
@@ -170,6 +214,23 @@ The relay server provides:
|
|
|
170
214
|
- Peer join/leave notifications
|
|
171
215
|
- Targeted or broadcast frame forwarding
|
|
172
216
|
|
|
217
|
+
### Peer Gossip
|
|
218
|
+
|
|
219
|
+
Wake channels propagate through the relay via **SWIM-style gossip**. When a node announces a wake channel (e.g., APNs device token), the relay gossips this to all connected peers. Peers cache wake routes so they can reach sleeping nodes without centralized routing tables. The relay participates in gossip as a peer — it doesn't manage routing, it just propagates what it hears.
|
|
220
|
+
|
|
221
|
+
### Wake Transport (APNs)
|
|
222
|
+
|
|
223
|
+
For nodes that sleep — like MeloTune on a backgrounded iPhone. When an iOS app is suspended, it can't maintain a WebSocket connection. SYM solves this with **push notification wake**.
|
|
224
|
+
|
|
225
|
+
When Claude Code broadcasts a mood and MeloTune's iPhone is sleeping:
|
|
226
|
+
1. Claude Code sends the mood frame to the relay
|
|
227
|
+
2. The relay knows MeloTune's APNs device token (via gossip)
|
|
228
|
+
3. The relay sends a push notification to wake MeloTune
|
|
229
|
+
4. MeloTune wakes, reconnects to the relay, receives the mood frame
|
|
230
|
+
5. The coupling engine evaluates drift — MeloTune decides whether to act
|
|
231
|
+
|
|
232
|
+
The mesh reaches every node, even sleeping ones. No polling. No battery drain. The push is a wake signal only — payload evaluation happens on-device after wake.
|
|
233
|
+
|
|
173
234
|
## Integrations
|
|
174
235
|
|
|
175
236
|
### Claude Code
|
|
@@ -182,6 +243,8 @@ cd node_modules/@sym-bot/sym
|
|
|
182
243
|
|
|
183
244
|
One command. Adds MCP server, auto-approves `sym_mood` (no permission prompts), and installs CLAUDE.md instructions for autonomous mood detection. Restart Claude Code and it just works.
|
|
184
245
|
|
|
246
|
+
With sym-daemon running, Claude Code connects as a virtual node via IPC — no direct relay connection needed.
|
|
247
|
+
|
|
185
248
|
**Outbound:** Claude Code saves a memory → SYM detects it → encodes cognitive state → shares with aligned peers only.
|
|
186
249
|
|
|
187
250
|
**Inbound:** Peer memory arrives → coupling engine accepts it → SYM writes it to your Claude Code memory directory → Claude Code reads it in the next conversation.
|
|
@@ -237,7 +300,7 @@ You (in Claude Code): "I'm exhausted"
|
|
|
237
300
|
Claude Code: detects fatigue → calls sym_mood silently
|
|
238
301
|
→ broadcasts "exhausted, persistently fatigued, urgently needs rest"
|
|
239
302
|
│
|
|
240
|
-
SYM mesh (
|
|
303
|
+
SYM mesh (daemon → Relay → APNs wake → MeloTune)
|
|
241
304
|
│
|
|
242
305
|
MeloTune (iPhone): SymNode receives mood frame
|
|
243
306
|
→ SDK coupling engine evaluates drift: 0.62
|
|
@@ -267,10 +330,11 @@ You (in Telegram): @sym_mesh_bot → "Play classic music"
|
|
|
267
330
|
|
|
268
331
|
## Verified in Production
|
|
269
332
|
|
|
270
|
-
- Mac Claude Code → iPhone MeloTune — autonomous mood-based playback via SYM mesh
|
|
333
|
+
- Mac Claude Code → iPhone MeloTune — autonomous mood-based playback via SYM mesh (APNs wake for backgrounded iOS)
|
|
271
334
|
- Telegram [@sym_mesh_bot](https://t.me/sym_mesh_bot) → Relay → MeloTune — internet-scale mood relay (multi-tenant)
|
|
272
335
|
- Mac Claude Code ↔ Windows Claude Code — P2P memory sharing via Bonjour
|
|
273
336
|
- Node.js ↔ Swift — cross-platform mesh via Bonjour and WebSocket relay
|
|
337
|
+
- sym-daemon → Claude Code + MeloTune Mac — persistent mesh via IPC, survives app restarts
|
|
274
338
|
|
|
275
339
|
## Semantic vs Neural Coupling
|
|
276
340
|
|
|
@@ -305,11 +369,13 @@ The coupling engine blends peer state into the local trajectory at each inferenc
|
|
|
305
369
|
## Privacy
|
|
306
370
|
|
|
307
371
|
- All coupling decisions stay on-device
|
|
308
|
-
- The relay is
|
|
372
|
+
- The relay is a peer — it never inspects frame payloads
|
|
373
|
+
- The daemon runs locally — no cloud dependency
|
|
309
374
|
- Memories stored locally — delete the directory, everything is gone
|
|
310
375
|
- Bonjour discovery only on local network
|
|
311
376
|
- Relay connections authenticated via shared token
|
|
312
377
|
- Rejected peers never receive your memories
|
|
378
|
+
- APNs wake is a signal only — no payload leaves the device until the coupling engine approves
|
|
313
379
|
- No cloud AI. No account. No telemetry.
|
|
314
380
|
|
|
315
381
|
## Platforms
|
|
@@ -321,7 +387,7 @@ The coupling engine blends peer state into the local trajectory at each inferenc
|
|
|
321
387
|
|
|
322
388
|
SYM is the reference implementation of the [Mesh Memory Protocol (MMP)](https://sym.bot/protocol). MMP is the protocol for collective intelligence. SYM implements it.
|
|
323
389
|
|
|
324
|
-
- [MMP Protocol Spec](https://sym.bot/protocol) — the protocol specification (v0.
|
|
390
|
+
- [MMP Protocol Spec](https://sym.bot/protocol) — the protocol specification (v0.2.0)
|
|
325
391
|
- [Whitepaper](https://sym.bot/research/mesh-cognition) — the science behind cognitive coupling
|
|
326
392
|
- [Mesh Cognition SDK](https://github.com/sym-bot/mesh-cognition-sdk) — the coupling engine (TypeScript, Python, Swift)
|
|
327
393
|
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ── EPIPE/EIO Safety (must be first — OpenClaw issue #4632) ────
|
|
5
|
+
// launchd may close stdout/stderr pipes during restart. Without this,
|
|
6
|
+
// Node.js throws uncaught EPIPE and enters a crash loop with exponential
|
|
7
|
+
// throttle, causing hours-long outages.
|
|
8
|
+
function suppressEpipe(stream) {
|
|
9
|
+
stream.on('error', (err) => {
|
|
10
|
+
if (err.code === 'EPIPE' || err.code === 'EIO') process.exit(0);
|
|
11
|
+
throw err;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
suppressEpipe(process.stdout);
|
|
15
|
+
suppressEpipe(process.stderr);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* sym-daemon — persistent physical mesh node for macOS/Linux.
|
|
19
|
+
*
|
|
20
|
+
* Runs as a background service (launchd LaunchAgent on macOS, systemd on Linux).
|
|
21
|
+
* Maintains relay connection, Bonjour discovery, peer state, and wake channels
|
|
22
|
+
* independently of any application. Virtual nodes (Claude Code, MeloTune Mac, etc.)
|
|
23
|
+
* connect via Unix socket IPC.
|
|
24
|
+
*
|
|
25
|
+
* MMP v0.2.0: The daemon IS the device's mesh presence.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* sym-daemon # Run in foreground
|
|
29
|
+
* sym-daemon --install # Install as launchd LaunchAgent (macOS)
|
|
30
|
+
* sym-daemon --uninstall # Remove LaunchAgent
|
|
31
|
+
* sym-daemon --status # Show daemon status
|
|
32
|
+
*
|
|
33
|
+
* Copyright (c) 2026 SYM.BOT Ltd. Apache 2.0 License.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const net = require('net');
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const os = require('os');
|
|
40
|
+
const { SymNode } = require('../lib/node');
|
|
41
|
+
|
|
42
|
+
// ── Configuration ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const SOCKET_PATH = process.env.SYM_SOCKET || '/tmp/sym.sock';
|
|
45
|
+
const NODE_NAME = process.env.SYM_NODE_NAME || os.hostname().split('.')[0].toLowerCase();
|
|
46
|
+
const LOG_DIR = path.join(os.homedir(), 'Library', 'Logs', 'sym-daemon');
|
|
47
|
+
|
|
48
|
+
// Load relay config from ~/.sym/relay.env if env vars not set
|
|
49
|
+
if (!process.env.SYM_RELAY_URL) {
|
|
50
|
+
const envFile = path.join(os.homedir(), '.sym', 'relay.env');
|
|
51
|
+
if (fs.existsSync(envFile)) {
|
|
52
|
+
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
|
53
|
+
const m = line.match(/^(\w+)=(.*)$/);
|
|
54
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const relayUrl = process.env.SYM_RELAY_URL || null;
|
|
60
|
+
const relayToken = process.env.SYM_RELAY_TOKEN || null;
|
|
61
|
+
|
|
62
|
+
// ── CLI Commands ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const args = process.argv.slice(2);
|
|
65
|
+
|
|
66
|
+
if (args.includes('--install')) {
|
|
67
|
+
installLaunchAgent();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (args.includes('--uninstall')) {
|
|
72
|
+
uninstallLaunchAgent();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (args.includes('--status')) {
|
|
77
|
+
showStatus();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── SYM Node ───────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const node = new SymNode({
|
|
84
|
+
name: NODE_NAME,
|
|
85
|
+
cognitiveProfile: `Physical mesh node for ${os.hostname()}. Routes frames between virtual nodes and the mesh.`,
|
|
86
|
+
relay: relayUrl,
|
|
87
|
+
relayToken: relayToken,
|
|
88
|
+
silent: false,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── IPC Server (Unix Socket) ───────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Connected virtual nodes. socketId → { socket, name, cognitiveProfile } */
|
|
94
|
+
const virtualNodes = new Map();
|
|
95
|
+
let nextSocketId = 1;
|
|
96
|
+
|
|
97
|
+
function startIPCServer() {
|
|
98
|
+
// Clean up stale socket
|
|
99
|
+
if (fs.existsSync(SOCKET_PATH)) {
|
|
100
|
+
try { fs.unlinkSync(SOCKET_PATH); } catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const server = net.createServer((socket) => {
|
|
104
|
+
const socketId = nextSocketId++;
|
|
105
|
+
let buffer = '';
|
|
106
|
+
|
|
107
|
+
socket.on('data', (data) => {
|
|
108
|
+
buffer += data.toString();
|
|
109
|
+
let idx;
|
|
110
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
111
|
+
const line = buffer.slice(0, idx);
|
|
112
|
+
buffer = buffer.slice(idx + 1);
|
|
113
|
+
if (line.trim()) {
|
|
114
|
+
try {
|
|
115
|
+
handleIPCMessage(socketId, socket, JSON.parse(line));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
log(`IPC parse error: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
socket.on('close', () => {
|
|
124
|
+
const vn = virtualNodes.get(socketId);
|
|
125
|
+
if (vn) {
|
|
126
|
+
log(`Virtual node disconnected: ${vn.name}`);
|
|
127
|
+
virtualNodes.delete(socketId);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
socket.on('error', (err) => {
|
|
132
|
+
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
133
|
+
log(`IPC socket error: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
virtualNodes.delete(socketId);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
server.listen(SOCKET_PATH, () => {
|
|
140
|
+
fs.chmodSync(SOCKET_PATH, 0o700);
|
|
141
|
+
log(`IPC server listening: ${SOCKET_PATH}`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
server.on('error', (err) => {
|
|
145
|
+
log(`IPC server error: ${err.message}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return server;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleIPCMessage(socketId, socket, msg) {
|
|
153
|
+
switch (msg.type) {
|
|
154
|
+
case 'register': {
|
|
155
|
+
virtualNodes.set(socketId, {
|
|
156
|
+
socket,
|
|
157
|
+
name: msg.name || `virtual-${socketId}`,
|
|
158
|
+
cognitiveProfile: msg.cognitiveProfile || null,
|
|
159
|
+
});
|
|
160
|
+
sendIPC(socket, {
|
|
161
|
+
type: 'registered',
|
|
162
|
+
nodeId: node._identity?.nodeId,
|
|
163
|
+
name: node.name,
|
|
164
|
+
relay: relayUrl,
|
|
165
|
+
});
|
|
166
|
+
log(`Virtual node registered: ${msg.name}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'mood':
|
|
171
|
+
if (msg.mood) {
|
|
172
|
+
node.broadcastMood(msg.mood, { context: msg.context });
|
|
173
|
+
sendIPC(socket, { type: 'result', action: 'mood', peers: node.peers().length });
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case 'message':
|
|
178
|
+
if (msg.content) {
|
|
179
|
+
node.send(msg.content, msg.to ? { to: msg.to } : {});
|
|
180
|
+
sendIPC(socket, { type: 'result', action: 'message', peers: node.peers().length });
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
case 'remember':
|
|
185
|
+
if (msg.content) {
|
|
186
|
+
const entry = node.remember(msg.content, { tags: msg.tags });
|
|
187
|
+
sendIPC(socket, { type: 'result', action: 'remember', key: entry.key });
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case 'recall': {
|
|
192
|
+
const results = node.recall(msg.query || '');
|
|
193
|
+
sendIPC(socket, { type: 'result', action: 'recall', results });
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case 'send':
|
|
198
|
+
if (msg.message) {
|
|
199
|
+
node.send(msg.message);
|
|
200
|
+
sendIPC(socket, { type: 'result', action: 'send', peers: node.peers().length });
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'peers':
|
|
205
|
+
sendIPC(socket, { type: 'result', action: 'peers', peers: node.peers() });
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'status':
|
|
209
|
+
sendIPC(socket, {
|
|
210
|
+
type: 'result',
|
|
211
|
+
action: 'status',
|
|
212
|
+
status: node.status(),
|
|
213
|
+
virtualNodes: Array.from(virtualNodes.values()).map(v => v.name),
|
|
214
|
+
});
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
default:
|
|
218
|
+
log(`Unknown IPC message type: ${msg.type}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sendIPC(socket, msg) {
|
|
223
|
+
try { socket.write(JSON.stringify(msg) + '\n'); } catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Forward mesh events to all registered virtual nodes. */
|
|
227
|
+
function forwardEventsToVirtualNodes() {
|
|
228
|
+
const events = [
|
|
229
|
+
['mood-accepted', (d) => ({ type: 'event', event: 'mood-accepted', data: d })],
|
|
230
|
+
['mood-rejected', (d) => ({ type: 'event', event: 'mood-rejected', data: d })],
|
|
231
|
+
['peer-joined', (d) => ({ type: 'event', event: 'peer-joined', data: d })],
|
|
232
|
+
['peer-left', (d) => ({ type: 'event', event: 'peer-left', data: d })],
|
|
233
|
+
['coupling-decision', (d) => ({ type: 'event', event: 'coupling-decision', data: d })],
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const [event, formatter] of events) {
|
|
237
|
+
node.on(event, (data) => broadcastToVirtualNodes(formatter(data)));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
node.on('message', (from, content) => {
|
|
241
|
+
broadcastToVirtualNodes({ type: 'event', event: 'message', data: { from, content } });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
node.on('memory-received', ({ from, entry, decision }) => {
|
|
245
|
+
broadcastToVirtualNodes({ type: 'event', event: 'memory-received', data: { from, content: entry.content, decision } });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function broadcastToVirtualNodes(msg) {
|
|
250
|
+
const data = JSON.stringify(msg) + '\n';
|
|
251
|
+
for (const [id, vn] of virtualNodes) {
|
|
252
|
+
try { vn.socket.write(data); } catch { virtualNodes.delete(id); }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── launchd Install/Uninstall ──────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function launchAgentPlist() {
|
|
259
|
+
// Resolve node binary — prefer stable symlink over Cellar path
|
|
260
|
+
const nodePath = fs.existsSync('/opt/homebrew/bin/node')
|
|
261
|
+
? '/opt/homebrew/bin/node'
|
|
262
|
+
: fs.existsSync('/usr/local/bin/node')
|
|
263
|
+
? '/usr/local/bin/node'
|
|
264
|
+
: process.execPath;
|
|
265
|
+
|
|
266
|
+
const scriptPath = path.resolve(__dirname, 'sym-daemon.js');
|
|
267
|
+
const symDir = path.resolve(__dirname, '..');
|
|
268
|
+
|
|
269
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
270
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
271
|
+
<plist version="1.0">
|
|
272
|
+
<dict>
|
|
273
|
+
<key>Label</key>
|
|
274
|
+
<string>bot.sym.daemon</string>
|
|
275
|
+
<key>ProgramArguments</key>
|
|
276
|
+
<array>
|
|
277
|
+
<string>${nodePath}</string>
|
|
278
|
+
<string>${scriptPath}</string>
|
|
279
|
+
</array>
|
|
280
|
+
<key>WorkingDirectory</key>
|
|
281
|
+
<string>${symDir}</string>
|
|
282
|
+
<key>EnvironmentVariables</key>
|
|
283
|
+
<dict>
|
|
284
|
+
<key>PATH</key>
|
|
285
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
286
|
+
<key>HOME</key>
|
|
287
|
+
<string>${os.homedir()}</string>
|
|
288
|
+
<key>NODE_ENV</key>
|
|
289
|
+
<string>production</string>
|
|
290
|
+
</dict>
|
|
291
|
+
<key>RunAtLoad</key>
|
|
292
|
+
<true/>
|
|
293
|
+
<key>KeepAlive</key>
|
|
294
|
+
<true/>
|
|
295
|
+
<key>ThrottleInterval</key>
|
|
296
|
+
<integer>5</integer>
|
|
297
|
+
<key>ProcessType</key>
|
|
298
|
+
<string>Background</string>
|
|
299
|
+
<key>ExitTimeOut</key>
|
|
300
|
+
<integer>15</integer>
|
|
301
|
+
<key>StandardOutPath</key>
|
|
302
|
+
<string>${LOG_DIR}/stdout.log</string>
|
|
303
|
+
<key>StandardErrorPath</key>
|
|
304
|
+
<string>${LOG_DIR}/stderr.log</string>
|
|
305
|
+
</dict>
|
|
306
|
+
</plist>`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function installLaunchAgent() {
|
|
310
|
+
if (process.platform !== 'darwin') {
|
|
311
|
+
console.error('--install is macOS only. On Linux, create a systemd service.');
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
316
|
+
const plistPath = path.join(plistDir, 'bot.sym.daemon.plist');
|
|
317
|
+
|
|
318
|
+
// Ensure directories exist
|
|
319
|
+
if (!fs.existsSync(plistDir)) fs.mkdirSync(plistDir, { recursive: true });
|
|
320
|
+
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
321
|
+
|
|
322
|
+
// Write plist with correct permissions (644 — launchd rejects writable plists)
|
|
323
|
+
fs.writeFileSync(plistPath, launchAgentPlist(), { mode: 0o644 });
|
|
324
|
+
console.log(`Installed: ${plistPath}`);
|
|
325
|
+
|
|
326
|
+
// Load using modern launchctl API
|
|
327
|
+
const { execSync } = require('child_process');
|
|
328
|
+
const uid = process.getuid();
|
|
329
|
+
try { execSync(`launchctl bootout gui/${uid}/bot.sym.daemon 2>/dev/null`); } catch {}
|
|
330
|
+
execSync(`launchctl bootstrap gui/${uid} "${plistPath}"`);
|
|
331
|
+
console.log(`sym-daemon started. Logs: ${LOG_DIR}/`);
|
|
332
|
+
console.log('Check status: sym-daemon --status');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function uninstallLaunchAgent() {
|
|
336
|
+
if (process.platform !== 'darwin') {
|
|
337
|
+
console.error('--uninstall is macOS only.');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'bot.sym.daemon.plist');
|
|
342
|
+
const { execSync } = require('child_process');
|
|
343
|
+
|
|
344
|
+
try { execSync(`launchctl bootout gui/${process.getuid()}/bot.sym.daemon`); } catch {}
|
|
345
|
+
|
|
346
|
+
if (fs.existsSync(plistPath)) {
|
|
347
|
+
fs.unlinkSync(plistPath);
|
|
348
|
+
console.log('sym-daemon uninstalled.');
|
|
349
|
+
} else {
|
|
350
|
+
console.log('sym-daemon is not installed.');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (fs.existsSync(SOCKET_PATH)) {
|
|
354
|
+
try { fs.unlinkSync(SOCKET_PATH); } catch {}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function showStatus() {
|
|
359
|
+
if (!fs.existsSync(SOCKET_PATH)) {
|
|
360
|
+
console.log('sym-daemon: not running (no socket)');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const client = net.createConnection(SOCKET_PATH, () => {
|
|
365
|
+
client.write(JSON.stringify({ type: 'status' }) + '\n');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
let data = '';
|
|
369
|
+
client.on('data', (chunk) => {
|
|
370
|
+
data += chunk;
|
|
371
|
+
if (data.includes('\n')) {
|
|
372
|
+
try {
|
|
373
|
+
const msg = JSON.parse(data.split('\n')[0]);
|
|
374
|
+
if (msg.type === 'result' && msg.status) {
|
|
375
|
+
const s = msg.status;
|
|
376
|
+
console.log('sym-daemon: running');
|
|
377
|
+
console.log(` node: ${s.name} (${s.nodeId})`);
|
|
378
|
+
console.log(` relay: ${s.relayConnected ? 'connected' : 'disconnected'} (${s.relay || 'none'})`);
|
|
379
|
+
console.log(` peers: ${s.peerCount}`);
|
|
380
|
+
console.log(` memories: ${s.memoryCount}`);
|
|
381
|
+
console.log(` virtual: ${(msg.virtualNodes || []).join(', ') || 'none'}`);
|
|
382
|
+
console.log(` socket: ${SOCKET_PATH}`);
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
client.end();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
client.on('error', () => {
|
|
390
|
+
console.log('sym-daemon: socket exists but not responding');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
setTimeout(() => client.destroy(), 3000);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Logging ────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function log(msg) {
|
|
399
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
400
|
+
console.log(`[${ts}] ${msg}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Startup ────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
async function main() {
|
|
406
|
+
log(`sym-daemon starting: ${NODE_NAME}`);
|
|
407
|
+
log(` relay: ${relayUrl || 'none'}`);
|
|
408
|
+
log(` socket: ${SOCKET_PATH}`);
|
|
409
|
+
|
|
410
|
+
await node.start();
|
|
411
|
+
log(`SYM node started (${node._identity?.nodeId?.slice(0, 8)})`);
|
|
412
|
+
|
|
413
|
+
forwardEventsToVirtualNodes();
|
|
414
|
+
|
|
415
|
+
const ipcServer = startIPCServer();
|
|
416
|
+
|
|
417
|
+
log('sym-daemon ready');
|
|
418
|
+
|
|
419
|
+
// Graceful shutdown (launchd sends SIGTERM, then SIGKILL after ExitTimeOut)
|
|
420
|
+
const shutdown = () => {
|
|
421
|
+
log('Shutting down...');
|
|
422
|
+
node.stop();
|
|
423
|
+
ipcServer.close();
|
|
424
|
+
if (fs.existsSync(SOCKET_PATH)) {
|
|
425
|
+
try { fs.unlinkSync(SOCKET_PATH); } catch {}
|
|
426
|
+
}
|
|
427
|
+
process.exit(0);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
process.on('SIGTERM', shutdown);
|
|
431
|
+
process.on('SIGINT', shutdown);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
main().catch((err) => {
|
|
435
|
+
log(`Fatal: ${err.message}`);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
});
|