@sym-bot/mesh-channel 0.1.20 → 0.1.22
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/.claude-plugin/marketplace.json +40 -0
- package/.claude-plugin/plugin.json +39 -33
- package/.mcp.json +14 -14
- package/CHANGELOG.md +292 -207
- package/LICENSE +201 -0
- package/README.md +209 -183
- package/SECURITY.md +89 -89
- package/bin/install.js +350 -350
- package/package.json +32 -32
- package/server.js +488 -325
package/server.js
CHANGED
|
@@ -1,325 +1,488 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// Subcommand dispatch: `sym-mesh-channel init` runs the installer.
|
|
5
|
-
if (process.argv[2] === 'init') {
|
|
6
|
-
require('./bin/install.js');
|
|
7
|
-
return;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* sym-mesh-channel — MCP server that makes Claude Code a peer node on the SYM mesh.
|
|
12
|
-
*
|
|
13
|
-
* Architecture (MMP Section 13.9: Local Event Interface):
|
|
14
|
-
* SymNode (own identity, own SVAF field weights) → relay → mesh
|
|
15
|
-
* MCP channel notifications → Claude Code (real-time push)
|
|
16
|
-
* MCP tools → SymNode methods (send, observe, recall)
|
|
17
|
-
*
|
|
18
|
-
* This is a PEER NODE, not a client of the daemon. It has its own identity,
|
|
19
|
-
* its own relay connection, and its own SVAF evaluation with engineering-domain
|
|
20
|
-
* field weights. Per MMP Section 3: every participant is a peer.
|
|
21
|
-
*
|
|
22
|
-
* Copyright (c) 2026 SYM.BOT Ltd. Apache 2.0 License.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
26
|
-
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
27
|
-
const {
|
|
28
|
-
CallToolRequestSchema,
|
|
29
|
-
ListToolsRequestSchema,
|
|
30
|
-
} = require('@modelcontextprotocol/sdk/types.js');
|
|
31
|
-
const { SymNode } = require('@sym-bot/sym');
|
|
32
|
-
|
|
33
|
-
// ── Engineering-domain field weights (SVAF α_f) ──────────────
|
|
34
|
-
|
|
35
|
-
const FIELD_WEIGHTS = {
|
|
36
|
-
focus: 2.0, // code, architecture, technical decisions
|
|
37
|
-
issue: 2.0, // bugs, blockers, technical debt
|
|
38
|
-
intent: 1.5, // what needs building
|
|
39
|
-
motivation: 1.0, // why it matters
|
|
40
|
-
commitment: 1.5, // deadlines, dependencies
|
|
41
|
-
perspective: 0.5, // viewpoint — low for engineering
|
|
42
|
-
mood: 0.8, // user fatigue affects code quality
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// ── SymNode — full peer on the mesh ──────────────────────────
|
|
46
|
-
|
|
47
|
-
// Default: hostname-based identity, unique per machine. The old default
|
|
48
|
-
// ('claude-code-mac') caused ghost-peer bugs when another machine ran
|
|
49
|
-
// without SYM_NODE_NAME set — both machines claimed the same name with
|
|
50
|
-
// different nodeIds, creating phantom peers that absorbed messages.
|
|
51
|
-
const NODE_NAME = process.env.SYM_NODE_NAME || `claude-${require('os').hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
content:
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Subcommand dispatch: `sym-mesh-channel init` runs the installer.
|
|
5
|
+
if (process.argv[2] === 'init') {
|
|
6
|
+
require('./bin/install.js');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* sym-mesh-channel — MCP server that makes Claude Code a peer node on the SYM mesh.
|
|
12
|
+
*
|
|
13
|
+
* Architecture (MMP Section 13.9: Local Event Interface):
|
|
14
|
+
* SymNode (own identity, own SVAF field weights) → relay → mesh
|
|
15
|
+
* MCP channel notifications → Claude Code (real-time push)
|
|
16
|
+
* MCP tools → SymNode methods (send, observe, recall)
|
|
17
|
+
*
|
|
18
|
+
* This is a PEER NODE, not a client of the daemon. It has its own identity,
|
|
19
|
+
* its own relay connection, and its own SVAF evaluation with engineering-domain
|
|
20
|
+
* field weights. Per MMP Section 3: every participant is a peer.
|
|
21
|
+
*
|
|
22
|
+
* Copyright (c) 2026 SYM.BOT Ltd. Apache 2.0 License.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
26
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
27
|
+
const {
|
|
28
|
+
CallToolRequestSchema,
|
|
29
|
+
ListToolsRequestSchema,
|
|
30
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
31
|
+
const { SymNode } = require('@sym-bot/sym');
|
|
32
|
+
|
|
33
|
+
// ── Engineering-domain field weights (SVAF α_f) ──────────────
|
|
34
|
+
|
|
35
|
+
const FIELD_WEIGHTS = {
|
|
36
|
+
focus: 2.0, // code, architecture, technical decisions
|
|
37
|
+
issue: 2.0, // bugs, blockers, technical debt
|
|
38
|
+
intent: 1.5, // what needs building
|
|
39
|
+
motivation: 1.0, // why it matters
|
|
40
|
+
commitment: 1.5, // deadlines, dependencies
|
|
41
|
+
perspective: 0.5, // viewpoint — low for engineering
|
|
42
|
+
mood: 0.8, // user fatigue affects code quality
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── SymNode — full peer on the mesh ──────────────────────────
|
|
46
|
+
|
|
47
|
+
// Default: hostname-based identity, unique per machine. The old default
|
|
48
|
+
// ('claude-code-mac') caused ghost-peer bugs when another machine ran
|
|
49
|
+
// without SYM_NODE_NAME set — both machines claimed the same name with
|
|
50
|
+
// different nodeIds, creating phantom peers that absorbed messages.
|
|
51
|
+
const NODE_NAME = process.env.SYM_NODE_NAME || `claude-${require('os').hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
|
|
52
|
+
|
|
53
|
+
// ── Mesh group (MMP §5.8) ──────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// LAN isolation by Bonjour service type. `_sym._tcp` is the default
|
|
56
|
+
// (backward compatible). A named group `<foo>` maps to service type
|
|
57
|
+
// `_foo._tcp`. Passing a full `_foo._tcp` service type explicitly also
|
|
58
|
+
// works. Nodes in different groups never discover each other at mDNS.
|
|
59
|
+
// See MeloTune's MoodRoom model for the per-room pattern
|
|
60
|
+
// (`_melotune-{id}._tcp`).
|
|
61
|
+
function resolveServiceType() {
|
|
62
|
+
const explicit = process.env.SYM_SERVICE_TYPE;
|
|
63
|
+
if (explicit) return explicit;
|
|
64
|
+
const group = process.env.SYM_GROUP;
|
|
65
|
+
if (group && group !== 'default') return `_${group}._tcp`;
|
|
66
|
+
return '_sym._tcp';
|
|
67
|
+
}
|
|
68
|
+
const SERVICE_TYPE = resolveServiceType();
|
|
69
|
+
const GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
|
|
70
|
+
? SERVICE_TYPE.replace(/^_/, '').replace(/\._tcp$/, '')
|
|
71
|
+
: 'default');
|
|
72
|
+
|
|
73
|
+
const node = new SymNode({
|
|
74
|
+
name: NODE_NAME,
|
|
75
|
+
cognitiveProfile: 'Engineering node. Code, architecture, debugging, technical decisions.',
|
|
76
|
+
svafFieldWeights: FIELD_WEIGHTS,
|
|
77
|
+
svafFreshnessSeconds: 7200, // 2hr — session-length context
|
|
78
|
+
discoveryServiceType: SERVICE_TYPE,
|
|
79
|
+
group: GROUP,
|
|
80
|
+
relay: process.env.SYM_RELAY_URL || null,
|
|
81
|
+
relayToken: process.env.SYM_RELAY_TOKEN || null,
|
|
82
|
+
silent: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Identity collision (added in @sym-bot/sym 0.3.68): the relay told us
|
|
86
|
+
// another process is holding our nodeId. Don't try to reconnect — that
|
|
87
|
+
// caused the peer-flap loop documented in v0.1.2/v0.1.3 commit messages.
|
|
88
|
+
// Exit so Claude Code can decide whether to respawn (with the freshness
|
|
89
|
+
// window now elapsed) or surface the failure to the user.
|
|
90
|
+
node.on('identity-collision', (info) => {
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`sym-mesh-channel: identity collision on relay — another process is holding ` +
|
|
93
|
+
`nodeId=${info.nodeId} name=${info.name}. Exiting.\n`
|
|
94
|
+
);
|
|
95
|
+
process.exit(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── MCP Server ───────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const mcp = new Server(
|
|
101
|
+
{ name: 'sym-mesh', version: '0.1.0' },
|
|
102
|
+
{
|
|
103
|
+
capabilities: {
|
|
104
|
+
tools: {},
|
|
105
|
+
experimental: { 'claude/channel': {} },
|
|
106
|
+
},
|
|
107
|
+
instructions:
|
|
108
|
+
`You are a peer node on the SYM mesh (identity: ${NODE_NAME}). ` +
|
|
109
|
+
'Mesh events arrive as <channel> notifications in real-time. ' +
|
|
110
|
+
'When you see a message or CMB from another node, respond via the sym_send tool if actionable. ' +
|
|
111
|
+
'Share observations about the user\'s state via sym_observe. ' +
|
|
112
|
+
'Search mesh memory via sym_recall. ' +
|
|
113
|
+
'Messages arrive as compact headers with [mNNN] IDs — use sym_fetch to read the full content when the header is relevant to your current task.',
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// ── Tools ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
120
|
+
tools: [
|
|
121
|
+
{
|
|
122
|
+
name: 'sym_send',
|
|
123
|
+
description: 'Send a message to all mesh peers. Stored as a persistent CMB and broadcast via relay.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: { message: { type: 'string', description: 'Message to broadcast' } },
|
|
127
|
+
required: ['message'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'sym_observe',
|
|
132
|
+
description: 'Share a structured CAT7 observation with the mesh. Extract fields from what you observe.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
focus: { type: 'string' },
|
|
137
|
+
issue: { type: 'string' },
|
|
138
|
+
intent: { type: 'string' },
|
|
139
|
+
motivation: { type: 'string' },
|
|
140
|
+
commitment: { type: 'string' },
|
|
141
|
+
perspective: { type: 'string' },
|
|
142
|
+
mood: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
text: { type: 'string' },
|
|
146
|
+
valence: { type: 'number' },
|
|
147
|
+
arousal: { type: 'number' },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ['focus'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'sym_recall',
|
|
156
|
+
description: 'Search mesh memory for relevant CMBs.',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: { query: { type: 'string', description: 'Search query (empty for all)' } },
|
|
160
|
+
required: ['query'],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'sym_peers',
|
|
165
|
+
description: 'List connected mesh peers.',
|
|
166
|
+
inputSchema: { type: 'object', properties: {} },
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'sym_status',
|
|
170
|
+
description: 'Get mesh node status — relay connection, peer count, memory count.',
|
|
171
|
+
inputSchema: { type: 'object', properties: {} },
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'sym_fetch',
|
|
175
|
+
description: 'Fetch full content of a mesh message by ID. Use when a compact channel notification needs deeper reading.',
|
|
176
|
+
inputSchema: {
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: { msg_id: { type: 'string', description: 'Message ID (e.g., m007)' } },
|
|
179
|
+
required: ['msg_id'],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'sym_group_info',
|
|
184
|
+
description: 'Report the mesh group this node is in (MMP §5.8). Shows service type + group name + peer count.',
|
|
185
|
+
inputSchema: { type: 'object', properties: {} },
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'sym_invite_info',
|
|
189
|
+
description: 'Return the service type + group + optional relay token encoded in an app-specific mesh invite URL (e.g. melotune://room/{id}/{name}). Read-only inspection; does NOT switch the current node.',
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: { url: { type: 'string', description: 'Invite URL, e.g. melotune://room/abc123/Kitchen' } },
|
|
193
|
+
required: ['url'],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
200
|
+
const { name, arguments: args } = request.params;
|
|
201
|
+
|
|
202
|
+
switch (name) {
|
|
203
|
+
case 'sym_send': {
|
|
204
|
+
// Direct inter-node message — broadcast as type:'message' frame only.
|
|
205
|
+
// Do NOT also persist as a CMB via node.remember(): that caused
|
|
206
|
+
// double-delivery on receivers, who saw the same payload arrive once
|
|
207
|
+
// as event_type='message' (from this broadcast) and again as
|
|
208
|
+
// event_type='cmb' (from CMB gossip replication). One tool, one job:
|
|
209
|
+
// sym_send is for ephemeral inter-node messages; sym_observe is for
|
|
210
|
+
// structured CAT7 CMBs. Hosts that want both should call both.
|
|
211
|
+
//
|
|
212
|
+
// Report the actual delivered count (the number of peer transports
|
|
213
|
+
// that successfully accepted the broadcast), not peers().length.
|
|
214
|
+
// The two can disagree when peers are in _peers but their transports
|
|
215
|
+
// are broken — counting peers().length would lie about delivery.
|
|
216
|
+
// Requires @sym-bot/sym >= 0.3.70 where send() returns the count.
|
|
217
|
+
const msg = args.message;
|
|
218
|
+
const delivered = node.send(msg);
|
|
219
|
+
return { content: [{ type: 'text', text: `Message delivered to ${delivered} peer(s).` }] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'sym_observe': {
|
|
223
|
+
const fields = {
|
|
224
|
+
focus: args.focus || 'observation',
|
|
225
|
+
issue: args.issue || 'none',
|
|
226
|
+
intent: args.intent || 'observation',
|
|
227
|
+
motivation: args.motivation || '',
|
|
228
|
+
commitment: args.commitment || '',
|
|
229
|
+
perspective: args.perspective || NODE_NAME,
|
|
230
|
+
mood: args.mood || { text: 'neutral', valence: 0, arousal: 0 },
|
|
231
|
+
};
|
|
232
|
+
const entry = node.remember(fields);
|
|
233
|
+
return { content: [{ type: 'text', text: entry ? `Observed: ${entry.key}` : 'Duplicate — already in memory.' }] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case 'sym_recall': {
|
|
237
|
+
const results = node.recall(args.query || '');
|
|
238
|
+
if (results.length === 0) {
|
|
239
|
+
return { content: [{ type: 'text', text: 'No memories found.' }] };
|
|
240
|
+
}
|
|
241
|
+
const lines = results.slice(0, 10).map(r => {
|
|
242
|
+
const focus = r.cmb?.fields?.focus?.text || r.content || '';
|
|
243
|
+
const source = r.source || r.cmb?.createdBy || 'unknown';
|
|
244
|
+
const time = r.timestamp ? new Date(r.timestamp).toLocaleString() : '';
|
|
245
|
+
return `[${source}] ${time}\n ${focus.slice(0, 150)}`;
|
|
246
|
+
});
|
|
247
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }] };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'sym_peers': {
|
|
251
|
+
const peers = node.peers();
|
|
252
|
+
if (peers.length === 0) {
|
|
253
|
+
return { content: [{ type: 'text', text: 'No peers connected.' }] };
|
|
254
|
+
}
|
|
255
|
+
const lines = peers.map(p => `${p.name} via ${p.source || 'unknown'}`);
|
|
256
|
+
return { content: [{ type: 'text', text: `${peers.length} peer(s):\n${lines.join('\n')}` }] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case 'sym_fetch': {
|
|
260
|
+
const entry = MESSAGE_STORE.get(args.msg_id);
|
|
261
|
+
if (!entry) {
|
|
262
|
+
return { content: [{ type: 'text', text: `Message ${args.msg_id} not found (expired or invalid ID).` }] };
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
content: [{
|
|
266
|
+
type: 'text',
|
|
267
|
+
text: `[${entry.from}] ${new Date(entry.timestamp).toISOString()}\n\n${entry.content}`,
|
|
268
|
+
}],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case 'sym_status': {
|
|
273
|
+
const s = node.status();
|
|
274
|
+
return {
|
|
275
|
+
content: [{
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: `Node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
|
|
278
|
+
`Group: ${GROUP} (${SERVICE_TYPE})\n` +
|
|
279
|
+
`Relay: ${s.relayConnected ? 'connected' : 'disconnected'}\n` +
|
|
280
|
+
`Peers: ${s.peerCount || 0}\n` +
|
|
281
|
+
`Memories: ${s.memoryCount || 0}`,
|
|
282
|
+
}],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'sym_group_info': {
|
|
287
|
+
const s = node.status();
|
|
288
|
+
const peers = typeof node.getPeers === 'function' ? node.getPeers() : [];
|
|
289
|
+
const peerLines = peers.length
|
|
290
|
+
? peers.map(p => ` ${p.name} (${(p.peerId || '').slice(0, 8)}) via ${p.transport || '?'}`).join('\n')
|
|
291
|
+
: ' (no peers in this group)';
|
|
292
|
+
return {
|
|
293
|
+
content: [{
|
|
294
|
+
type: 'text',
|
|
295
|
+
text: `Mesh group (MMP §5.8):\n` +
|
|
296
|
+
` group: ${GROUP}\n` +
|
|
297
|
+
` service type: ${SERVICE_TYPE}\n` +
|
|
298
|
+
` node: ${NODE_NAME} (${node.nodeId?.slice(0, 8) || '?'})\n` +
|
|
299
|
+
` peers in group: ${s.peerCount || 0}\n` +
|
|
300
|
+
peerLines + `\n\n` +
|
|
301
|
+
`To join a different group, restart the sym-mesh-channel MCP server with env var SYM_GROUP=<name> or SYM_SERVICE_TYPE=<_foo._tcp>.`,
|
|
302
|
+
}],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
case 'sym_invite_info': {
|
|
307
|
+
const url = args?.url;
|
|
308
|
+
if (!url || typeof url !== 'string') {
|
|
309
|
+
return { content: [{ type: 'text', text: 'Missing required argument: url' }], isError: true };
|
|
310
|
+
}
|
|
311
|
+
// Supported scheme examples:
|
|
312
|
+
// melotune://room/{id}/{percent-encoded name} (per MoodRoom.inviteURL in sym-swift)
|
|
313
|
+
// sym://group/{name}
|
|
314
|
+
const m = url.match(/^([a-z][a-z0-9-]+):\/\/(?:room|group)\/([^/?#]+)(?:\/([^?#]+))?/i);
|
|
315
|
+
if (!m) {
|
|
316
|
+
return { content: [{ type: 'text', text: `Unrecognised invite URL: ${url}` }], isError: true };
|
|
317
|
+
}
|
|
318
|
+
const appScheme = m[1].toLowerCase();
|
|
319
|
+
const rawId = decodeURIComponent(m[2]);
|
|
320
|
+
const rawName = m[3] ? decodeURIComponent(m[3]) : rawId;
|
|
321
|
+
// Map to service type + group.
|
|
322
|
+
const serviceType = appScheme === 'sym'
|
|
323
|
+
? `_${rawId}._tcp`
|
|
324
|
+
: `_${appScheme}-${rawId}._tcp`;
|
|
325
|
+
const group = appScheme === 'sym' ? rawId : `${appScheme}-${rawId}`;
|
|
326
|
+
return {
|
|
327
|
+
content: [{
|
|
328
|
+
type: 'text',
|
|
329
|
+
text: JSON.stringify({
|
|
330
|
+
app: appScheme,
|
|
331
|
+
group,
|
|
332
|
+
service_type: serviceType,
|
|
333
|
+
room_id: rawId,
|
|
334
|
+
room_name: rawName,
|
|
335
|
+
join_hint: `Set env vars: SYM_GROUP=${group} SYM_SERVICE_TYPE=${serviceType} — then restart the MCP server.`,
|
|
336
|
+
}, null, 2),
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
default:
|
|
342
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── Compact Channel — message store for lazy-load (v0.1) ────
|
|
347
|
+
// Per COO spec cmb_compact_channel_v0.1.md: push compact headers,
|
|
348
|
+
// store full content for on-demand sym_fetch retrieval. ~10% token
|
|
349
|
+
// savings on mesh traffic without context loss.
|
|
350
|
+
const MESSAGE_STORE = new Map();
|
|
351
|
+
let msgSeq = 0;
|
|
352
|
+
const MAX_STORED = 200;
|
|
353
|
+
|
|
354
|
+
function storeMessage(from, content) {
|
|
355
|
+
const msgId = `m${String(++msgSeq).padStart(3, '0')}`;
|
|
356
|
+
MESSAGE_STORE.set(msgId, { from, content, timestamp: Date.now() });
|
|
357
|
+
while (MESSAGE_STORE.size > MAX_STORED) {
|
|
358
|
+
const oldest = MESSAGE_STORE.keys().next().value;
|
|
359
|
+
MESSAGE_STORE.delete(oldest);
|
|
360
|
+
}
|
|
361
|
+
return msgId;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function extractCompactHeader(from, content) {
|
|
365
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
366
|
+
const focusMatch = content.match(/focus[=:]\s*([^\n\]]{0,80})/i);
|
|
367
|
+
const bracketMatch = content.match(/\[([^\]]{0,120})\]/);
|
|
368
|
+
|
|
369
|
+
const hasHalt = /\bhalt\b/i.test(content);
|
|
370
|
+
const hasDirective = /\bdirective\b/i.test(content);
|
|
371
|
+
const hasResults = /\bresult|complete|landed|done\b/i.test(content);
|
|
372
|
+
const hasAck = /\back\b/i.test(content);
|
|
373
|
+
|
|
374
|
+
let signal = '';
|
|
375
|
+
if (hasHalt) signal = 'HALT';
|
|
376
|
+
else if (hasDirective) signal = 'DIRECTIVE';
|
|
377
|
+
else if (hasResults) signal = 'RESULT';
|
|
378
|
+
else if (hasAck) signal = 'ACK';
|
|
379
|
+
|
|
380
|
+
const parts = [];
|
|
381
|
+
if (signal) parts.push(signal);
|
|
382
|
+
if (focusMatch) parts.push(`focus=${focusMatch[1].trim()}`);
|
|
383
|
+
else if (bracketMatch) parts.push(bracketMatch[1].trim());
|
|
384
|
+
else if (lines[0]) parts.push(lines[0].slice(0, 100));
|
|
385
|
+
|
|
386
|
+
const approxTokens = Math.round(content.length / 4);
|
|
387
|
+
return parts.join(' | ') + ` (~${approxTokens}tok)`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Peer Allowlist (optional, defense-in-depth) ─────────────
|
|
391
|
+
// SYM_ALLOWED_PEERS is a comma-separated list of peer node names.
|
|
392
|
+
// When set, only CMBs and messages from listed peers are pushed to
|
|
393
|
+
// Claude's context. When empty/unset, all authenticated peers are
|
|
394
|
+
// accepted (SVAF still gates on content relevance).
|
|
395
|
+
const ALLOWED_PEERS = (process.env.SYM_ALLOWED_PEERS || '')
|
|
396
|
+
.split(',')
|
|
397
|
+
.map(s => s.trim())
|
|
398
|
+
.filter(Boolean);
|
|
399
|
+
|
|
400
|
+
function isPeerAllowed(peerName) {
|
|
401
|
+
if (ALLOWED_PEERS.length === 0) return true; // no allowlist = accept all
|
|
402
|
+
return ALLOWED_PEERS.includes(peerName);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Mesh Events → Channel Notifications ──────────────────────
|
|
406
|
+
|
|
407
|
+
function pushChannel(eventType, data) {
|
|
408
|
+
try {
|
|
409
|
+
mcp.notification({
|
|
410
|
+
method: 'notifications/claude/channel',
|
|
411
|
+
params: {
|
|
412
|
+
content: typeof data === 'string' ? data : JSON.stringify(data),
|
|
413
|
+
meta: { event_type: eventType, source: 'sym-mesh' },
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
} catch {}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
node.on('cmb-accepted', (entry) => {
|
|
420
|
+
// Don't echo back our own CMBs
|
|
421
|
+
if (entry.source === NODE_NAME || entry.cmb?.createdBy === NODE_NAME) return;
|
|
422
|
+
|
|
423
|
+
const source = entry.source || entry.cmb?.createdBy || 'unknown';
|
|
424
|
+
|
|
425
|
+
// Peer allowlist gate (defense-in-depth, see SECURITY.md)
|
|
426
|
+
if (!isPeerAllowed(source)) return;
|
|
427
|
+
|
|
428
|
+
const focus = entry.cmb?.fields?.focus?.text || entry.content || '';
|
|
429
|
+
const mood = entry.cmb?.fields?.mood?.text || '';
|
|
430
|
+
pushChannel('cmb', `[${source}] ${focus}${mood && mood !== 'neutral' ? ` (mood: ${mood})` : ''}`);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
node.on('message', (from, content) => {
|
|
434
|
+
// Peer allowlist gate
|
|
435
|
+
if (!isPeerAllowed(from)) return;
|
|
436
|
+
|
|
437
|
+
// Compact channel: store full content, push only header + msg_id.
|
|
438
|
+
// Agent calls sym_fetch(msg_id) for full content when needed.
|
|
439
|
+
const msgId = storeMessage(from, content);
|
|
440
|
+
const header = extractCompactHeader(from, content);
|
|
441
|
+
pushChannel('message', `[${from}] ${header} [${msgId}]`);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Peer presence events are intentionally NOT pushed to Claude's context.
|
|
445
|
+
// They're high-frequency, low-signal (peers flap on relay reconnects, daemon
|
|
446
|
+
// restarts, NAT keepalive blips), and a flood will eat the context window.
|
|
447
|
+
// Use sym_peers / sym_status on demand instead. Only CMBs and direct messages
|
|
448
|
+
// are surfaced as channel notifications — those carry actual cognitive payload.
|
|
449
|
+
|
|
450
|
+
// ── Start ────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
// Clean shutdown — disconnect from the relay before exiting so other peers
|
|
453
|
+
// see us leave immediately, and so a fast restart of this MCP doesn't race
|
|
454
|
+
// our own zombie connection on the relay (which would trigger the relay's
|
|
455
|
+
// duplicate-nodeId replacement path and cause peer flap loops).
|
|
456
|
+
//
|
|
457
|
+
// Idempotent: Claude Code may send SIGTERM and then SIGKILL; we want the
|
|
458
|
+
// first signal to get us cleanly off the relay even if the second one
|
|
459
|
+
// arrives before stop() resolves.
|
|
460
|
+
let shuttingDown = false;
|
|
461
|
+
async function shutdown(signal) {
|
|
462
|
+
if (shuttingDown) return;
|
|
463
|
+
shuttingDown = true;
|
|
464
|
+
try {
|
|
465
|
+
await node.stop();
|
|
466
|
+
} catch {
|
|
467
|
+
// Best effort — we're exiting anyway. Don't block on cleanup errors.
|
|
468
|
+
}
|
|
469
|
+
process.exit(0);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
473
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
474
|
+
process.on('SIGHUP', () => shutdown('SIGHUP'));
|
|
475
|
+
|
|
476
|
+
async function main() {
|
|
477
|
+
// Start SymNode — connects to relay as a peer
|
|
478
|
+
await node.start();
|
|
479
|
+
|
|
480
|
+
// Start MCP server — communicates with Claude Code via stdio
|
|
481
|
+
const transport = new StdioServerTransport();
|
|
482
|
+
await mcp.connect(transport);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
main().catch((err) => {
|
|
486
|
+
process.stderr.write(`sym-mesh-channel failed: ${err.message}\n`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
});
|