@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
|
@@ -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.
|
|
@@ -1,41 +1,80 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* SYM MCP Server for Claude Code.
|
|
6
|
+
*
|
|
7
|
+
* Connects to sym-daemon as a virtual node via IPC if the daemon is running.
|
|
8
|
+
* Falls back to a standalone SymNode if the daemon is not available.
|
|
9
|
+
*
|
|
10
|
+
* MMP v0.2.0: The daemon is the physical node. This MCP server is a virtual node.
|
|
11
|
+
*
|
|
12
|
+
* Copyright (c) 2026 SYM.BOT Ltd. Apache 2.0 License.
|
|
13
|
+
*/
|
|
14
|
+
|
|
4
15
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
5
16
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
6
17
|
const { z } = require('zod');
|
|
7
|
-
const {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
started = true;
|
|
18
|
+
const { connectOrFallback } = require('../../lib/ipc-client');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
// ── Environment ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Load relay config from ~/.sym/relay.env if env vars not set
|
|
25
|
+
if (!process.env.SYM_RELAY_URL) {
|
|
26
|
+
const envFile = path.join(require('os').homedir(), '.sym', 'relay.env');
|
|
27
|
+
if (fs.existsSync(envFile)) {
|
|
28
|
+
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
|
29
|
+
const m = line.match(/^(\w+)=(.*)$/);
|
|
30
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
// ── Node Connection ──────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
let node; // SymDaemonClient or SymNode — same API
|
|
38
|
+
let isDaemon; // true if connected to daemon
|
|
39
|
+
let bridge; // ClaudeMemoryBridge (only for standalone mode)
|
|
40
|
+
|
|
41
|
+
async function initNode() {
|
|
42
|
+
const result = await connectOrFallback({
|
|
43
|
+
name: 'claude-code',
|
|
44
|
+
silent: true,
|
|
45
|
+
relay: process.env.SYM_RELAY_URL || null,
|
|
46
|
+
relayToken: process.env.SYM_RELAY_TOKEN || null,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
node = result.node;
|
|
50
|
+
isDaemon = result.isDaemon;
|
|
51
|
+
|
|
52
|
+
if (isDaemon) {
|
|
53
|
+
process.stderr.write('[SYM MCP] Connected to sym-daemon as virtual node\n');
|
|
54
|
+
} else {
|
|
55
|
+
process.stderr.write('[SYM MCP] Daemon not running — standalone mode\n');
|
|
56
|
+
const { ClaudeMemoryBridge } = require('../../lib/claude-memory-bridge');
|
|
57
|
+
bridge = new ClaudeMemoryBridge(node);
|
|
58
|
+
await node.start();
|
|
59
|
+
bridge.start();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Listen for mesh signals (works for both daemon client and standalone node)
|
|
63
|
+
setupMeshSignalHandlers();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── MCP Server ───────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const server = new McpServer({ name: 'sym', version: '0.2.0' });
|
|
29
69
|
|
|
30
70
|
server.tool(
|
|
31
71
|
'sym_remember',
|
|
32
72
|
'Store a memory in the mesh — shared only with cognitively aligned peers.',
|
|
33
73
|
{ content: z.string(), tags: z.string().optional() },
|
|
34
74
|
async ({ content, tags }) => {
|
|
35
|
-
await ensureStarted();
|
|
36
75
|
const tagList = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
|
37
76
|
const entry = node.remember(content, { tags: tagList.length > 0 ? tagList : undefined });
|
|
38
|
-
const peers = node.peers();
|
|
77
|
+
const peers = isDaemon ? await node.peers() : node.peers();
|
|
39
78
|
const coupled = peers.filter(p => p.coupling !== 'rejected');
|
|
40
79
|
return { content: [{ type: 'text', text: `Stored and shared with ${coupled.length}/${peers.length} peer(s). Key: ${entry.key}` }] };
|
|
41
80
|
}
|
|
@@ -43,19 +82,24 @@ server.tool(
|
|
|
43
82
|
|
|
44
83
|
server.tool(
|
|
45
84
|
'sym_recall',
|
|
46
|
-
'Search memories across the mesh
|
|
85
|
+
'Search memories across the mesh and knowledge feed storage.',
|
|
47
86
|
{ query: z.string() },
|
|
48
87
|
async ({ query }) => {
|
|
49
|
-
|
|
50
|
-
const results = node.recall(query);
|
|
51
|
-
if (results.length === 0) {
|
|
52
|
-
return { content: [{ type: 'text', text: 'No memories found.' }] };
|
|
53
|
-
}
|
|
88
|
+
// 1. Mesh memories
|
|
89
|
+
const results = isDaemon ? await node.recall(query) : node.recall(query);
|
|
54
90
|
const lines = results.map(r => {
|
|
55
91
|
const source = r._source || r.source || 'unknown';
|
|
56
92
|
const t = (r.tags || []).length > 0 ? ` (tags: ${r.tags.join(', ')})` : '';
|
|
57
93
|
return `[${source}] ${r.content}${t}`;
|
|
58
94
|
});
|
|
95
|
+
|
|
96
|
+
// 2. Supabase knowledge feed
|
|
97
|
+
const feedLines = await recallFromStorage(query);
|
|
98
|
+
lines.push(...feedLines);
|
|
99
|
+
|
|
100
|
+
if (lines.length === 0) {
|
|
101
|
+
return { content: [{ type: 'text', text: 'No memories found.' }] };
|
|
102
|
+
}
|
|
59
103
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
60
104
|
}
|
|
61
105
|
);
|
|
@@ -65,8 +109,7 @@ server.tool(
|
|
|
65
109
|
'Show connected peers with coupling state and drift.',
|
|
66
110
|
{},
|
|
67
111
|
async () => {
|
|
68
|
-
await
|
|
69
|
-
const peers = node.peers();
|
|
112
|
+
const peers = isDaemon ? await node.peers() : node.peers();
|
|
70
113
|
if (peers.length === 0) {
|
|
71
114
|
return { content: [{ type: 'text', text: 'No peers connected.' }] };
|
|
72
115
|
}
|
|
@@ -82,8 +125,8 @@ server.tool(
|
|
|
82
125
|
'Full mesh node status — identity, peers, memory count, coherence.',
|
|
83
126
|
{},
|
|
84
127
|
async () => {
|
|
85
|
-
await
|
|
86
|
-
return { content: [{ type: 'text', text: JSON.stringify(
|
|
128
|
+
const status = isDaemon ? await node.status() : node.status();
|
|
129
|
+
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
|
|
87
130
|
}
|
|
88
131
|
);
|
|
89
132
|
|
|
@@ -92,13 +135,25 @@ server.tool(
|
|
|
92
135
|
'Send a message to all connected peers on the SYM mesh.',
|
|
93
136
|
{ message: z.string() },
|
|
94
137
|
async ({ message }) => {
|
|
95
|
-
await ensureStarted();
|
|
96
138
|
node.send(message);
|
|
97
|
-
const peers = node.peers();
|
|
139
|
+
const peers = isDaemon ? await node.peers() : node.peers();
|
|
98
140
|
return { content: [{ type: 'text', text: `Sent to ${peers.length} peer(s): "${message}"` }] };
|
|
99
141
|
}
|
|
100
142
|
);
|
|
101
143
|
|
|
144
|
+
server.tool(
|
|
145
|
+
'sym_digest',
|
|
146
|
+
'Store a summarised digest for a knowledge feed folder. Called after sym_recall returns DIGEST_NEEDED.',
|
|
147
|
+
{ folder: z.string().describe('Feed folder name (e.g. 202603221233)'), digest: z.string().describe('The summarised digest in markdown') },
|
|
148
|
+
async ({ folder, digest }) => {
|
|
149
|
+
const ok = await uploadFile(`twitter/${folder}/digest.md`, digest);
|
|
150
|
+
if (!ok) {
|
|
151
|
+
return { content: [{ type: 'text', text: `Failed to store digest for ${folder}` }] };
|
|
152
|
+
}
|
|
153
|
+
return { content: [{ type: 'text', text: `Digest stored: twitter/${folder}/digest.md` }] };
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
102
157
|
server.tool(
|
|
103
158
|
'sym_mood',
|
|
104
159
|
`Broadcast the user's detected mood to the SYM mesh. Connected agents (e.g. MeloTune) will autonomously respond.
|
|
@@ -123,20 +178,188 @@ Examples of natural detection:
|
|
|
123
178
|
→ call sym_mood with "focused, deep work, concentration needed"`,
|
|
124
179
|
{ mood: z.string().describe('Natural language description of detected mood and context') },
|
|
125
180
|
async ({ mood }) => {
|
|
126
|
-
await ensureStarted();
|
|
127
181
|
node.broadcastMood(mood);
|
|
128
182
|
node.remember(`User mood: ${mood}`, { tags: ['mood'] });
|
|
129
|
-
const peers = node.peers();
|
|
183
|
+
const peers = isDaemon ? await node.peers() : node.peers();
|
|
130
184
|
return { content: [{ type: 'text', text: `Mood broadcast to ${peers.length} peer(s)` }] };
|
|
131
185
|
}
|
|
132
186
|
);
|
|
133
187
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
188
|
+
// ── Supabase Knowledge Feed ──────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function supabaseConfig() {
|
|
191
|
+
const url = process.env.SUPABASE_URL;
|
|
192
|
+
const key = process.env.SUPABASE_KEY;
|
|
193
|
+
const bucket = process.env.SUPABASE_BUCKET || 'sym-knowledge-feed';
|
|
194
|
+
return { url, key, bucket, configured: !!(url && key) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function supabaseHeaders(key) {
|
|
198
|
+
return { 'Authorization': `Bearer ${key}`, 'apikey': key };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function listFeedFolders(datePrefix) {
|
|
202
|
+
const { url, key, bucket, configured } = supabaseConfig();
|
|
203
|
+
if (!configured) return [];
|
|
204
|
+
|
|
205
|
+
const listRes = await fetch(
|
|
206
|
+
`${url}/storage/v1/object/list/${bucket}`,
|
|
207
|
+
{
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { ...supabaseHeaders(key), 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ prefix: 'twitter/', limit: 100, sortBy: { column: 'name', order: 'desc' } }),
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
if (!listRes.ok) return [];
|
|
214
|
+
const folders = await listRes.json();
|
|
215
|
+
return folders.map(f => f.name).filter(name => name.startsWith(datePrefix));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function todayPrefix() {
|
|
219
|
+
const now = new Date();
|
|
220
|
+
return now.getFullYear().toString()
|
|
221
|
+
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
222
|
+
+ String(now.getDate()).padStart(2, '0');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function fetchFile(filePath) {
|
|
226
|
+
const { url, key, bucket } = supabaseConfig();
|
|
227
|
+
const res = await fetch(
|
|
228
|
+
`${url}/storage/v1/object/${bucket}/${filePath}`,
|
|
229
|
+
{ headers: supabaseHeaders(key) }
|
|
230
|
+
);
|
|
231
|
+
if (!res.ok) return null;
|
|
232
|
+
return res;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function uploadFile(filePath, content, contentType = 'text/markdown') {
|
|
236
|
+
const { url, key, bucket } = supabaseConfig();
|
|
237
|
+
const res = await fetch(
|
|
238
|
+
`${url}/storage/v1/object/${bucket}/${filePath}`,
|
|
239
|
+
{
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { ...supabaseHeaders(key), 'Content-Type': contentType },
|
|
242
|
+
body: content,
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
return res.ok;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function recallFromStorage(query) {
|
|
249
|
+
const { configured } = supabaseConfig();
|
|
250
|
+
if (!configured) return [];
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const folders = await listFeedFolders(todayPrefix());
|
|
254
|
+
if (folders.length === 0) return [];
|
|
255
|
+
|
|
256
|
+
const folder = folders[0];
|
|
257
|
+
const lines = [];
|
|
258
|
+
|
|
259
|
+
const digestRes = await fetchFile(`twitter/${folder}/digest.md`);
|
|
260
|
+
if (digestRes) {
|
|
261
|
+
const digest = await digestRes.text();
|
|
262
|
+
lines.push(`[knowledge-feed digest — ${folder}]\n${digest}`);
|
|
263
|
+
return lines;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const feedRes = await fetchFile(`twitter/${folder}/feed.json`);
|
|
267
|
+
if (!feedRes) return [];
|
|
268
|
+
const feed = await feedRes.json();
|
|
269
|
+
const entries = feed.entries || [];
|
|
270
|
+
if (entries.length === 0) return [];
|
|
271
|
+
|
|
272
|
+
lines.push(`[DIGEST_NEEDED — folder: ${folder}] Summarise the entries below into a concise AI news digest, then call sym_digest to store it.`);
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
const tags = entry.tags.length > 0 ? ` (tags: ${entry.tags.join(', ')})` : '';
|
|
275
|
+
lines.push(`[knowledge-feed] ${entry.author} (@${entry.handle}): ${entry.content}${tags}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return lines;
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Mesh Signal Handlers ──────────────────────────────────────
|
|
137
285
|
|
|
138
|
-
|
|
139
|
-
|
|
286
|
+
function setupMeshSignalHandlers() {
|
|
287
|
+
const { spawn } = require('child_process');
|
|
288
|
+
|
|
289
|
+
node.on('message', async (from, content) => {
|
|
290
|
+
if (!content) return;
|
|
291
|
+
|
|
292
|
+
const digestMatch = content.match(/^digest-needed:(\d+)$/);
|
|
293
|
+
if (digestMatch) {
|
|
294
|
+
const folder = digestMatch[1];
|
|
295
|
+
process.stderr.write(`[SYM] Digest request from ${from} for folder ${folder}\n`);
|
|
296
|
+
|
|
297
|
+
const existing = await fetchFile(`twitter/${folder}/digest.md`);
|
|
298
|
+
if (existing) {
|
|
299
|
+
process.stderr.write(`[SYM] Digest already exists for ${folder}, skipping\n`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
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
|
+
const entryLines = entries.map(e => `${e.author} (@${e.handle}): ${e.content}`).join('\n');
|
|
313
|
+
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}`;
|
|
314
|
+
|
|
315
|
+
const child = spawn('claude', [
|
|
316
|
+
'--print',
|
|
317
|
+
'--mcp-config', '{"mcpServers":{}}',
|
|
318
|
+
'--strict-mcp-config',
|
|
319
|
+
], {
|
|
320
|
+
timeout: 120000,
|
|
321
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
let stdout = '';
|
|
325
|
+
let stderr = '';
|
|
326
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
327
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
328
|
+
|
|
329
|
+
child.on('close', async (code) => {
|
|
330
|
+
if (code !== 0) {
|
|
331
|
+
process.stderr.write(`[SYM] Claude CLI failed (exit ${code}): ${stderr}\n`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const digest = stdout.trim();
|
|
335
|
+
if (!digest) return;
|
|
336
|
+
const ok = await uploadFile(`twitter/${folder}/digest.md`, digest);
|
|
337
|
+
if (ok) {
|
|
338
|
+
process.stderr.write(`[SYM] Digest generated and stored for ${folder}\n`);
|
|
339
|
+
node.send(`digest-ready:${folder}`);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
child.stdin.write(prompt);
|
|
344
|
+
child.stdin.end();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Startup ──────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
async function main() {
|
|
352
|
+
await initNode();
|
|
353
|
+
|
|
354
|
+
const transport = new StdioServerTransport();
|
|
355
|
+
await server.connect(transport);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
main().catch((e) => {
|
|
140
359
|
process.stderr.write(`[SYM MCP] Fatal: ${e.message}\n`);
|
|
141
360
|
process.exit(1);
|
|
142
361
|
});
|
|
362
|
+
|
|
363
|
+
// Graceful shutdown
|
|
364
|
+
process.on('SIGTERM', () => { if (bridge) bridge.stop(); node.stop(); });
|
|
365
|
+
process.on('SIGINT', () => { if (bridge) bridge.stop(); node.stop(); });
|