@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.
@@ -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 { SymNode } = require('../../lib/node');
8
- const { ClaudeMemoryBridge } = require('../../lib/claude-memory-bridge');
9
-
10
- const nodeName = process.argv.includes('--name')
11
- ? process.argv[process.argv.indexOf('--name') + 1]
12
- : 'claude-code';
13
-
14
- const node = new SymNode({ name: nodeName, silent: true });
15
- const bridge = new ClaudeMemoryBridge(node);
16
- let started = false;
17
-
18
- async function ensureStarted() {
19
- if (started) return;
20
- await node.start();
21
- bridge.start();
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
- const server = new McpServer({
26
- name: 'sym',
27
- version: '0.2.0',
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 yours and coupled peers.',
85
+ 'Search memories across the mesh and knowledge feed storage.',
47
86
  { query: z.string() },
48
87
  async ({ query }) => {
49
- await ensureStarted();
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 ensureStarted();
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 ensureStarted();
86
- return { content: [{ type: 'text', text: JSON.stringify(node.status(), null, 2) }] };
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
- // Graceful shutdown
135
- process.on('SIGTERM', () => { bridge.stop(); node.stop(); });
136
- process.on('SIGINT', () => { bridge.stop(); node.stop(); });
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
- const transport = new StdioServerTransport();
139
- server.connect(transport).catch((e) => {
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(); });