@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.
@@ -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({ name: nodeName, silent: true });
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
- async function ensureStarted() {
19
- if (started) return;
20
- await node.start();
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
- await ensureStarted();
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 yours and coupled peers.',
62
+ 'Search memories across the mesh and knowledge feed storage.',
47
63
  { query: z.string() },
48
64
  async ({ query }) => {
49
- await ensureStarted();
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
- await ensureStarted();
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
- await ensureStarted();
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
- await ensureStarted();
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
- await ensureStarted();
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(); });