@sym-bot/sym 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRD.md +1 -1
- package/README.md +165 -64
- package/TECHNICAL-SPEC.md +250 -197
- package/bin/setup-claude.sh +31 -1
- 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 +240 -18
- package/integrations/telegram/bot.js +418 -0
- package/lib/node.js +488 -39
- package/lib/transport.js +88 -0
- package/package.json +3 -2
- 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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# MMP Wake Transport
|
|
2
|
+
|
|
3
|
+
**Part of:** [Mesh Memory Protocol v0.1.0](mesh-memory-protocol.md)
|
|
4
|
+
**Layer:** 1 (Transport)
|
|
5
|
+
**Status:** Draft
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
Wake is MMP's third transport layer, alongside TCP (local) and Relay (internet). It reaches nodes that the OS has suspended — where TCP and WebSocket connections no longer exist.
|
|
12
|
+
|
|
13
|
+
Without wake, a sleeping MeloTune cannot receive a mood signal. Without wake, the mesh has holes. Wake makes the mesh complete.
|
|
14
|
+
|
|
15
|
+
## Principle
|
|
16
|
+
|
|
17
|
+
Wake is **peer-to-peer**. The sending peer pushes the wake signal directly using wake channel credentials learned via **peer gossip** (MMP `peer-info` frame). Any node that knows a peer's wake channel can wake that peer — even if they've never been online at the same time.
|
|
18
|
+
|
|
19
|
+
The wake decision is **autonomous** (MMP Layer 4). The sending node's coupling engine decides whether a sleeping peer warrants waking. This follows wu wei: the protocol creates conditions for wake to happen naturally, not through forced rules.
|
|
20
|
+
|
|
21
|
+
## Wake Channel Propagation
|
|
22
|
+
|
|
23
|
+
Wake channels propagate via MMP's SWIM-style gossip, not just direct handshake:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
1. MeloTune connects to relay → handshake includes wake-channel → relay stores it
|
|
27
|
+
2. MeloTune disconnects (iOS suspended)
|
|
28
|
+
3. Claude Code connects to relay → relay gossips MeloTune's wake channel via peer-info
|
|
29
|
+
4. Claude Code can now wake MeloTune via APNs — without ever having met it directly
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The relay is the natural gossip hub because it's always on. But any always-on node serves the same role. This is emergent, not designed.
|
|
33
|
+
|
|
34
|
+
## Frame Definitions
|
|
35
|
+
|
|
36
|
+
Both frames are defined in the [MMP spec](mesh-memory-protocol.md#layer-2-framing). This document specifies their semantics and implementation.
|
|
37
|
+
|
|
38
|
+
### `wake-channel`
|
|
39
|
+
|
|
40
|
+
Exchanged during handshake. Declares how to reach this node when suspended.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"type": "wake-channel",
|
|
45
|
+
"platform": "apns" | "fcm" | "none",
|
|
46
|
+
"token": "<device push token>",
|
|
47
|
+
"environment": "production" | "sandbox"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Field | Required | Description |
|
|
52
|
+
|-------|----------|-------------|
|
|
53
|
+
| `platform` | Yes | Push platform. `"none"` = always-on node |
|
|
54
|
+
| `token` | If platform ≠ none | Platform-specific device token |
|
|
55
|
+
| `environment` | APNs only | `"production"` or `"sandbox"` |
|
|
56
|
+
|
|
57
|
+
Wake channels are learned via `peer-info` gossip and retained across peer disconnection. They persist locally with TTL-based expiry (default: 7 days).
|
|
58
|
+
|
|
59
|
+
### `wake`
|
|
60
|
+
|
|
61
|
+
Sent out-of-band via platform push infrastructure. Deliberately minimal — push payloads have strict size limits (4KB).
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"type": "wake",
|
|
66
|
+
"from": "<nodeId>",
|
|
67
|
+
"fromName": "<name>",
|
|
68
|
+
"reason": "mood" | "message" | "memory"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The `reason` field lets the woken node decide whether to reconnect. A node configured to ignore memory signals during sleep might skip reconnecting for `reason: "memory"` but always reconnect for `reason: "mood"`.
|
|
73
|
+
|
|
74
|
+
## Wake Decision
|
|
75
|
+
|
|
76
|
+
Defined at MMP Layer 4 (Cognition). The sending node evaluates:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
wake(nᵢ → nⱼ) iff:
|
|
80
|
+
1. transport(nⱼ) = down // peer is unreachable
|
|
81
|
+
2. wakeChannel(nⱼ).platform ≠ "none" // peer supports wake
|
|
82
|
+
3. κ(nᵢ, nⱼ) ∈ {aligned, guarded} // peer is coupled
|
|
83
|
+
4. t - lastWake(nⱼ) > cooldown // not recently woken
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This is an autonomous decision — no protocol rule mandates it. Each node's wake policy is part of its cognitive profile.
|
|
87
|
+
|
|
88
|
+
## Flow
|
|
89
|
+
|
|
90
|
+
### Normal: Transport Active
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Node A ──── mood frame via relay ──────► Node B
|
|
94
|
+
(received, processed)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Wake is never used. Frames flow through TCP or relay.
|
|
98
|
+
|
|
99
|
+
### Sleeping Peer: Wake Flow
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Node A (sender) Node B (sleeping)
|
|
103
|
+
│ │ zzz
|
|
104
|
+
├── mood via relay ────────► X │ transport down
|
|
105
|
+
│ (delivery failed) │
|
|
106
|
+
│ │
|
|
107
|
+
│ wake decision: YES │
|
|
108
|
+
│ (coupled + not recently │
|
|
109
|
+
│ woken + has wake channel) │
|
|
110
|
+
│ │
|
|
111
|
+
├── wake via APNs ──────────────►│ silent push
|
|
112
|
+
│ │ OS wakes app (~30s)
|
|
113
|
+
│ ├── reconnects to relay
|
|
114
|
+
│ │
|
|
115
|
+
├── mood via relay ────────────► │ delivered
|
|
116
|
+
│ │ (MeloTune plays music)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Wake Failed
|
|
120
|
+
|
|
121
|
+
If the push notification fails (expired token, network error), the frame is lost. MMP does not guarantee delivery — the mesh is eventually consistent (MMP §5.3).
|
|
122
|
+
|
|
123
|
+
## Platform Implementation
|
|
124
|
+
|
|
125
|
+
### APNs (iOS)
|
|
126
|
+
|
|
127
|
+
**Silent push payload:**
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"aps": {
|
|
132
|
+
"content-available": 1
|
|
133
|
+
},
|
|
134
|
+
"mmp": {
|
|
135
|
+
"type": "wake",
|
|
136
|
+
"from": "0bca7398",
|
|
137
|
+
"fromName": "claude-code",
|
|
138
|
+
"reason": "mood"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
No alert, no sound, no badge. iOS grants ~30s of background execution.
|
|
144
|
+
|
|
145
|
+
**Sending:** HTTP/2 POST to `api.push.apple.com` (production) or `api.sandbox.push.apple.com` (sandbox). Requires APNs authentication key (p8 file), team ID, key ID, and target bundle ID.
|
|
146
|
+
|
|
147
|
+
**Receiving:**
|
|
148
|
+
|
|
149
|
+
```swift
|
|
150
|
+
func application(_ application: UIApplication,
|
|
151
|
+
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
|
152
|
+
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
153
|
+
// Reconnect to mesh
|
|
154
|
+
symNode.reconnect()
|
|
155
|
+
// Process pending frames within ~30s budget
|
|
156
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
|
|
157
|
+
completionHandler(.newData)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Token registration:**
|
|
163
|
+
|
|
164
|
+
```swift
|
|
165
|
+
func application(_ application: UIApplication,
|
|
166
|
+
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
167
|
+
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
168
|
+
symNode.setWakeToken(platform: .apns, token: token, environment: .production)
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### FCM (Android) — Future
|
|
173
|
+
|
|
174
|
+
**Data message payload:**
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"data": {
|
|
179
|
+
"type": "wake",
|
|
180
|
+
"from": "0bca7398",
|
|
181
|
+
"fromName": "claude-code",
|
|
182
|
+
"reason": "mood"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
FCM data messages wake the app without displaying a notification. Requires FCM service account key.
|
|
188
|
+
|
|
189
|
+
### Always-On Nodes
|
|
190
|
+
|
|
191
|
+
Servers, desktop apps, and other always-on nodes send `"platform": "none"` in their `wake-channel` frame (or omit it). They are always reachable via TCP or relay — wake is not needed.
|
|
192
|
+
|
|
193
|
+
## Wake Keys
|
|
194
|
+
|
|
195
|
+
APNs/FCM credentials are **app-level secrets** — they belong to the app developer, not to individual nodes.
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
~/.sym/wake-keys/
|
|
199
|
+
├── apns-key.p8 # APNs authentication key
|
|
200
|
+
├── apns-config.json # { "teamId", "keyId", "bundleId" }
|
|
201
|
+
└── fcm-credentials.json # FCM service account (future)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Any node with these keys can wake peers on the corresponding platform:
|
|
205
|
+
- Claude Code (Mac) → can wake MeloTune (iOS)
|
|
206
|
+
- Telegram bot (Render) → can wake MeloTune (iOS)
|
|
207
|
+
- MeloTune (iOS) → does not need keys (doesn't wake other iOS apps)
|
|
208
|
+
|
|
209
|
+
## SYM Implementation
|
|
210
|
+
|
|
211
|
+
The `wakeIfNeeded` method on the SYM node:
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
async wakeIfNeeded(peer, reason) {
|
|
215
|
+
if (peer.transport?.isAlive()) return false;
|
|
216
|
+
if (!peer.wakeChannel || peer.wakeChannel.platform === 'none') return false;
|
|
217
|
+
|
|
218
|
+
const d = this._meshNode.couplingDecisions.get(peer.peerId);
|
|
219
|
+
if (d && d.decision === 'rejected') return false;
|
|
220
|
+
|
|
221
|
+
if (peer.lastWakeAt && Date.now() - peer.lastWakeAt < WAKE_COOLDOWN_MS) return false;
|
|
222
|
+
|
|
223
|
+
await this._wakeTransport.send(peer.wakeChannel, {
|
|
224
|
+
type: 'wake',
|
|
225
|
+
from: this._identity.nodeId,
|
|
226
|
+
fromName: this.name,
|
|
227
|
+
reason,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
peer.lastWakeAt = Date.now();
|
|
231
|
+
this._log(`Wake sent to ${peer.name}: ${reason}`);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Constraints
|
|
237
|
+
|
|
238
|
+
- **No relay involvement.** The relay does not store wake channels, send pushes, or queue messages.
|
|
239
|
+
- **No guaranteed delivery.** If the push fails, the frame is lost.
|
|
240
|
+
- **No user-visible notifications.** Wake is silent, invisible, autonomous.
|
|
241
|
+
- **No store-and-forward.** The actual payload (mood, memory, message) is delivered after reconnection, not in the push itself.
|
|
242
|
+
- **Battery-conscious.** Cooldown prevents excessive waking. The woken node processes pending frames within ~30s and returns to sleep.
|
|
@@ -7,20 +7,36 @@ const { z } = require('zod');
|
|
|
7
7
|
const { SymNode } = require('../../lib/node');
|
|
8
8
|
const { ClaudeMemoryBridge } = require('../../lib/claude-memory-bridge');
|
|
9
9
|
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
// Claude Code may not pass env vars from mcp.json reliably.
|
|
14
|
+
// Fall back to reading ~/.sym/relay.env if SYM_RELAY_URL is not set.
|
|
15
|
+
if (!process.env.SYM_RELAY_URL) {
|
|
16
|
+
const envFile = path.join(require('os').homedir(), '.sym', 'relay.env');
|
|
17
|
+
if (fs.existsSync(envFile)) {
|
|
18
|
+
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
|
19
|
+
const m = line.match(/^(\w+)=(.*)$/);
|
|
20
|
+
if (m) process.env[m[1]] = m[2].trim();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
const nodeName = process.argv.includes('--name')
|
|
11
26
|
? process.argv[process.argv.indexOf('--name') + 1]
|
|
12
27
|
: 'claude-code';
|
|
13
28
|
|
|
14
|
-
const node = new SymNode({
|
|
29
|
+
const node = new SymNode({
|
|
30
|
+
name: nodeName,
|
|
31
|
+
silent: true,
|
|
32
|
+
relay: process.env.SYM_RELAY_URL || null,
|
|
33
|
+
relayToken: process.env.SYM_RELAY_TOKEN || null,
|
|
34
|
+
});
|
|
15
35
|
const bridge = new ClaudeMemoryBridge(node);
|
|
16
|
-
let started = false;
|
|
17
36
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
bridge.start();
|
|
22
|
-
started = true;
|
|
23
|
-
}
|
|
37
|
+
// Start eagerly — peers need time to discover via Bonjour.
|
|
38
|
+
// If we wait for the first tool call, sym_mood may fire before peers connect.
|
|
39
|
+
node.start().then(() => bridge.start());
|
|
24
40
|
|
|
25
41
|
const server = new McpServer({
|
|
26
42
|
name: 'sym',
|
|
@@ -32,7 +48,7 @@ server.tool(
|
|
|
32
48
|
'Store a memory in the mesh — shared only with cognitively aligned peers.',
|
|
33
49
|
{ content: z.string(), tags: z.string().optional() },
|
|
34
50
|
async ({ content, tags }) => {
|
|
35
|
-
|
|
51
|
+
|
|
36
52
|
const tagList = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
37
53
|
const entry = node.remember(content, { tags: tagList.length > 0 ? tagList : undefined });
|
|
38
54
|
const peers = node.peers();
|
|
@@ -43,29 +59,145 @@ server.tool(
|
|
|
43
59
|
|
|
44
60
|
server.tool(
|
|
45
61
|
'sym_recall',
|
|
46
|
-
'Search memories across the mesh
|
|
62
|
+
'Search memories across the mesh and knowledge feed storage.',
|
|
47
63
|
{ query: z.string() },
|
|
48
64
|
async ({ query }) => {
|
|
49
|
-
|
|
65
|
+
|
|
66
|
+
// 1. Local mesh memories
|
|
50
67
|
const results = node.recall(query);
|
|
51
|
-
if (results.length === 0) {
|
|
52
|
-
return { content: [{ type: 'text', text: 'No memories found.' }] };
|
|
53
|
-
}
|
|
54
68
|
const lines = results.map(r => {
|
|
55
69
|
const source = r._source || r.source || 'unknown';
|
|
56
70
|
const t = (r.tags || []).length > 0 ? ` (tags: ${r.tags.join(', ')})` : '';
|
|
57
71
|
return `[${source}] ${r.content}${t}`;
|
|
58
72
|
});
|
|
73
|
+
|
|
74
|
+
// 2. Supabase knowledge feed
|
|
75
|
+
const feedLines = await recallFromStorage(query);
|
|
76
|
+
lines.push(...feedLines);
|
|
77
|
+
|
|
78
|
+
if (lines.length === 0) {
|
|
79
|
+
return { content: [{ type: 'text', text: 'No memories found.' }] };
|
|
80
|
+
}
|
|
59
81
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
60
82
|
}
|
|
61
83
|
);
|
|
62
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Supabase Storage helpers for knowledge feed.
|
|
87
|
+
*/
|
|
88
|
+
function supabaseConfig() {
|
|
89
|
+
const url = process.env.SUPABASE_URL;
|
|
90
|
+
const key = process.env.SUPABASE_KEY;
|
|
91
|
+
const bucket = process.env.SUPABASE_BUCKET || 'sym-knowledge-feed';
|
|
92
|
+
return { url, key, bucket, configured: !!(url && key) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function supabaseHeaders(key) {
|
|
96
|
+
return { 'Authorization': `Bearer ${key}`, 'apikey': key };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* List feed folders for a given date prefix (yyyymmdd).
|
|
101
|
+
* Returns folder names sorted descending (newest first).
|
|
102
|
+
*/
|
|
103
|
+
async function listFeedFolders(datePrefix) {
|
|
104
|
+
const { url, key, bucket, configured } = supabaseConfig();
|
|
105
|
+
if (!configured) return [];
|
|
106
|
+
|
|
107
|
+
const listRes = await fetch(
|
|
108
|
+
`${url}/storage/v1/object/list/${bucket}`,
|
|
109
|
+
{
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { ...supabaseHeaders(key), 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ prefix: 'twitter/', limit: 100, sortBy: { column: 'name', order: 'desc' } }),
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
if (!listRes.ok) return [];
|
|
116
|
+
const folders = await listRes.json();
|
|
117
|
+
return folders
|
|
118
|
+
.map(f => f.name)
|
|
119
|
+
.filter(name => name.startsWith(datePrefix));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function todayPrefix() {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
return now.getFullYear().toString()
|
|
125
|
+
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
126
|
+
+ String(now.getDate()).padStart(2, '0');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function fetchFile(filePath) {
|
|
130
|
+
const { url, key, bucket } = supabaseConfig();
|
|
131
|
+
const res = await fetch(
|
|
132
|
+
`${url}/storage/v1/object/${bucket}/${filePath}`,
|
|
133
|
+
{ headers: supabaseHeaders(key) }
|
|
134
|
+
);
|
|
135
|
+
if (!res.ok) return null;
|
|
136
|
+
return res;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function uploadFile(filePath, content, contentType = 'text/markdown') {
|
|
140
|
+
const { url, key, bucket } = supabaseConfig();
|
|
141
|
+
const res = await fetch(
|
|
142
|
+
`${url}/storage/v1/object/${bucket}/${filePath}`,
|
|
143
|
+
{
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { ...supabaseHeaders(key), 'Content-Type': contentType },
|
|
146
|
+
body: content,
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
return res.ok;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fetch the latest knowledge feed from Supabase Storage.
|
|
154
|
+
* Returns digest if available, otherwise raw entries + DIGEST_NEEDED.
|
|
155
|
+
*/
|
|
156
|
+
async function recallFromStorage(query) {
|
|
157
|
+
const { configured } = supabaseConfig();
|
|
158
|
+
if (!configured) return [];
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const folders = await listFeedFolders(todayPrefix());
|
|
162
|
+
if (folders.length === 0) return [];
|
|
163
|
+
|
|
164
|
+
// Latest folder only (sorted desc)
|
|
165
|
+
const folder = folders[0];
|
|
166
|
+
const lines = [];
|
|
167
|
+
|
|
168
|
+
// Check for existing digest
|
|
169
|
+
const digestRes = await fetchFile(`twitter/${folder}/digest.md`);
|
|
170
|
+
if (digestRes) {
|
|
171
|
+
const digest = await digestRes.text();
|
|
172
|
+
lines.push(`[knowledge-feed digest — ${folder}]\n${digest}`);
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// No digest — return raw entries for summarisation
|
|
177
|
+
const feedRes = await fetchFile(`twitter/${folder}/feed.json`);
|
|
178
|
+
if (!feedRes) return [];
|
|
179
|
+
const feed = await feedRes.json();
|
|
180
|
+
const entries = feed.entries || [];
|
|
181
|
+
if (entries.length === 0) return [];
|
|
182
|
+
|
|
183
|
+
lines.push(`[DIGEST_NEEDED — folder: ${folder}] Summarise the entries below into a concise AI news digest, then call sym_digest to store it.`);
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const tags = entry.tags.length > 0 ? ` (tags: ${entry.tags.join(', ')})` : '';
|
|
186
|
+
lines.push(`[knowledge-feed] ${entry.author} (@${entry.handle}): ${entry.content}${tags}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines;
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
63
195
|
server.tool(
|
|
64
196
|
'sym_peers',
|
|
65
197
|
'Show connected peers with coupling state and drift.',
|
|
66
198
|
{},
|
|
67
199
|
async () => {
|
|
68
|
-
|
|
200
|
+
|
|
69
201
|
const peers = node.peers();
|
|
70
202
|
if (peers.length === 0) {
|
|
71
203
|
return { content: [{ type: 'text', text: 'No peers connected.' }] };
|
|
@@ -82,7 +214,7 @@ server.tool(
|
|
|
82
214
|
'Full mesh node status — identity, peers, memory count, coherence.',
|
|
83
215
|
{},
|
|
84
216
|
async () => {
|
|
85
|
-
|
|
217
|
+
|
|
86
218
|
return { content: [{ type: 'text', text: JSON.stringify(node.status(), null, 2) }] };
|
|
87
219
|
}
|
|
88
220
|
);
|
|
@@ -92,13 +224,27 @@ server.tool(
|
|
|
92
224
|
'Send a message to all connected peers on the SYM mesh.',
|
|
93
225
|
{ message: z.string() },
|
|
94
226
|
async ({ message }) => {
|
|
95
|
-
|
|
227
|
+
|
|
96
228
|
node.send(message);
|
|
97
229
|
const peers = node.peers();
|
|
98
230
|
return { content: [{ type: 'text', text: `Sent to ${peers.length} peer(s): "${message}"` }] };
|
|
99
231
|
}
|
|
100
232
|
);
|
|
101
233
|
|
|
234
|
+
server.tool(
|
|
235
|
+
'sym_digest',
|
|
236
|
+
'Store a summarised digest for a knowledge feed folder. Called after sym_recall returns DIGEST_NEEDED.',
|
|
237
|
+
{ folder: z.string().describe('Feed folder name (e.g. 202603221233)'), digest: z.string().describe('The summarised digest in markdown') },
|
|
238
|
+
async ({ folder, digest }) => {
|
|
239
|
+
|
|
240
|
+
const ok = await uploadFile(`twitter/${folder}/digest.md`, digest);
|
|
241
|
+
if (!ok) {
|
|
242
|
+
return { content: [{ type: 'text', text: `Failed to store digest for ${folder}` }] };
|
|
243
|
+
}
|
|
244
|
+
return { content: [{ type: 'text', text: `Digest stored: twitter/${folder}/digest.md` }] };
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
|
|
102
248
|
server.tool(
|
|
103
249
|
'sym_mood',
|
|
104
250
|
`Broadcast the user's detected mood to the SYM mesh. Connected agents (e.g. MeloTune) will autonomously respond.
|
|
@@ -123,7 +269,7 @@ Examples of natural detection:
|
|
|
123
269
|
→ call sym_mood with "focused, deep work, concentration needed"`,
|
|
124
270
|
{ mood: z.string().describe('Natural language description of detected mood and context') },
|
|
125
271
|
async ({ mood }) => {
|
|
126
|
-
|
|
272
|
+
|
|
127
273
|
node.broadcastMood(mood);
|
|
128
274
|
node.remember(`User mood: ${mood}`, { tags: ['mood'] });
|
|
129
275
|
const peers = node.peers();
|
|
@@ -131,6 +277,82 @@ Examples of natural detection:
|
|
|
131
277
|
}
|
|
132
278
|
);
|
|
133
279
|
|
|
280
|
+
// ── Mesh Signal Handlers ──────────────────────────────────────
|
|
281
|
+
// When a peer broadcasts a need this node is aligned with,
|
|
282
|
+
// invoke Claude CLI to handle it autonomously.
|
|
283
|
+
|
|
284
|
+
const { spawn } = require('child_process');
|
|
285
|
+
|
|
286
|
+
node.on('message', async (from, content) => {
|
|
287
|
+
if (!content) return;
|
|
288
|
+
|
|
289
|
+
// Digest generation request from a mesh peer
|
|
290
|
+
const digestMatch = content.match(/^digest-needed:(\d+)$/);
|
|
291
|
+
if (digestMatch) {
|
|
292
|
+
const folder = digestMatch[1];
|
|
293
|
+
process.stderr.write(`[SYM] Digest request from ${from} for folder ${folder}\n`);
|
|
294
|
+
|
|
295
|
+
// Check if digest already exists
|
|
296
|
+
const existing = await fetchFile(`twitter/${folder}/digest.md`);
|
|
297
|
+
if (existing) {
|
|
298
|
+
process.stderr.write(`[SYM] Digest already exists for ${folder}, skipping\n`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fetch feed entries
|
|
303
|
+
const feedRes = await fetchFile(`twitter/${folder}/feed.json`);
|
|
304
|
+
if (!feedRes) {
|
|
305
|
+
process.stderr.write(`[SYM] No feed.json for ${folder}\n`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const feed = await feedRes.json();
|
|
309
|
+
const entries = feed.entries || [];
|
|
310
|
+
if (entries.length === 0) return;
|
|
311
|
+
|
|
312
|
+
// Build prompt for Claude CLI
|
|
313
|
+
const entryLines = entries.map(e =>
|
|
314
|
+
`${e.author} (@${e.handle}): ${e.content}`
|
|
315
|
+
).join('\n');
|
|
316
|
+
|
|
317
|
+
const prompt = `Summarise these AI news entries into a concise digest in markdown. Group by theme. Include key highlights and notable quotes. No preamble, just the digest.\n\n${entryLines}`;
|
|
318
|
+
|
|
319
|
+
// Pipe prompt via stdin to claude --print
|
|
320
|
+
// --strict-mcp-config with empty config prevents spawning SYM MCP
|
|
321
|
+
const child = spawn('claude', [
|
|
322
|
+
'--print',
|
|
323
|
+
'--mcp-config', '{"mcpServers":{}}',
|
|
324
|
+
'--strict-mcp-config',
|
|
325
|
+
], {
|
|
326
|
+
timeout: 120000,
|
|
327
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
let stdout = '';
|
|
331
|
+
let stderr = '';
|
|
332
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
333
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
334
|
+
|
|
335
|
+
child.on('close', async (code) => {
|
|
336
|
+
if (code !== 0) {
|
|
337
|
+
process.stderr.write(`[SYM] Claude CLI failed (exit ${code}): ${stderr}\n`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const digest = stdout.trim();
|
|
342
|
+
if (!digest) return;
|
|
343
|
+
|
|
344
|
+
const ok = await uploadFile(`twitter/${folder}/digest.md`, digest);
|
|
345
|
+
if (ok) {
|
|
346
|
+
process.stderr.write(`[SYM] Digest generated and stored for ${folder}\n`);
|
|
347
|
+
node.send(`digest-ready:${folder}`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
child.stdin.write(prompt);
|
|
352
|
+
child.stdin.end();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
134
356
|
// Graceful shutdown
|
|
135
357
|
process.on('SIGTERM', () => { bridge.stop(); node.stop(); });
|
|
136
358
|
process.on('SIGINT', () => { bridge.stop(); node.stop(); });
|