@mooncompany/uplink-chat 0.5.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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Config Module - Environment variables and constants
3
+ */
4
+
5
+ import { config } from 'dotenv';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { createLogger } from './logger.js';
9
+
10
+ const log = createLogger('config');
11
+
12
+ // Load environment variables
13
+ config();
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ export const ROOT_DIR = path.dirname(__dirname);
17
+
18
+ // Server
19
+ export const PORT = process.env.PORT || 3456;
20
+
21
+ // Gateway
22
+ export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://127.0.0.1:18789';
23
+ export const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN;
24
+
25
+ // TTS
26
+ export const ELEVENLABS_API_KEY = process.env.ELEVENLABS_API_KEY;
27
+ export const ELEVENLABS_VOICE_ID = process.env.ELEVENLABS_VOICE_ID || 'OPnx2r3vaTlo7wQIPhcM';
28
+ export const TTS_VOICE_NAME = process.env.TTS_VOICE_NAME || 'Assistant';
29
+
30
+ // Session
31
+ export const SESSION_USER = process.env.SESSION_USER || 'uplink-user';
32
+ export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Assistant';
33
+
34
+ // Whisper
35
+ export const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
36
+ export const TRANSCRIBE_SCRIPT = process.env.TRANSCRIBE_SCRIPT || '/usr/local/bin/transcribe.sh';
37
+
38
+ // Wake word
39
+ export const WAKE_WORD = (process.env.WAKE_WORD || 'uplink').toLowerCase();
40
+
41
+ // Webhooks
42
+ export const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN;
43
+
44
+ // OpenClaw Channel Integration
45
+ export const OPENCLAW_CALLBACK_SECRET = process.env.OPENCLAW_CALLBACK_SECRET || '';
46
+ export const OPENCLAW_WEBHOOK_URL = process.env.OPENCLAW_WEBHOOK_URL || ''; // e.g., http://localhost:18789/uplink-webhook
47
+ export const USE_CHANNEL_WEBHOOK = process.env.USE_CHANNEL_WEBHOOK === 'true'; // Enable to route through channel plugin
48
+
49
+ // CORS
50
+ export const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
51
+ ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
52
+ : ['http://localhost:3456'];
53
+
54
+ // Security: Allowed file types (magic bytes)
55
+ export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
56
+ export const ALLOWED_AUDIO_TYPES = ['audio/webm', 'audio/mp4', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'video/webm'];
57
+ export const ALLOWED_VIDEO_TYPES = ['video/webm', 'video/mp4', 'video/quicktime', 'video/x-matroska'];
58
+
59
+ // Reliability
60
+ export const MAX_CONCURRENT_REQUESTS = 3;
61
+ export const REQUEST_TIMEOUT = 300000; // 5 minutes (for longer responses with thinking)
62
+ export const CHANNEL_FETCH_TIMEOUT_MS = 300000; // 5 minutes for initial fetch
63
+ export const STREAM_READ_TIMEOUT_MS = 300000; // 5 minutes between stream chunks (allows for tool use)
64
+ export const RPC_CALL_TIMEOUT_MS = 10000; // 10 seconds for gateway RPC calls
65
+ export const SSE_KEEPALIVE_INTERVAL_MS = 5000; // 5 seconds between SSE keepalive comments
66
+ export const GATEWAY_RESTART_DELAY_MS = 2000; // 2 seconds delay before gateway restart
67
+ export const FILE_EXTRACTION_TIMEOUT_MS = 30000; // 30 seconds for file text extraction
68
+ export const MAX_FILE_EXTRACT_SIZE = 500 * 1024; // 500KB max extracted text
69
+ export const IMAGE_COMPRESSION_THRESHOLD = 500000; // 500KB threshold for image compression
70
+ export const GATEWAY_VALIDATION_TIMEOUT_MS = 5000; // 5 seconds for gateway validation
71
+ export const FILE_CLEANUP_DELAY_MS = 15000; // 15 seconds before cleaning up uploaded files
72
+ export const GATEWAY_HEALTH_CHECK_TIMEOUT_MS = 10000; // 10 seconds for gateway health checks
73
+ export const MIN_RECONNECT_INTERVAL_MS = 1000; // Minimum reconnect interval (1 second)
74
+
75
+ // WebSocket Configuration
76
+ export const WEBSOCKET = {
77
+ heartbeatIntervalMs: 30 * 1000, // 30 seconds (relaxed for mobile/cellular)
78
+ maxMissedPongs: 4, // Close connection after 4 missed pongs (2 min timeout)
79
+ syncDeltaThrottleMs: 150, // Throttle sync deltas to 150ms
80
+ messageRateLimitWindow: 60 * 1000, // 1 minute rate limit window
81
+ maxMessagesPerWindow: 30, // Max 30 messages per minute per client
82
+ maxRateLimitEntries: 500, // Max rate limit entries to prevent unbounded growth
83
+ maxStreamingRequests: 100, // Max active streaming requests
84
+ maxRecentBroadcasts: 500, // Max broadcast dedup cache size
85
+ dedupWindowMs: 5000, // 5 second dedup window for broadcasts
86
+ connectionLimits: {
87
+ maxPerIp: 10, // Max connections per IP
88
+ maxTotal: 100, // Max total connections
89
+ },
90
+ maxMessageSize: 1024 * 1024, // 1MB message size limit
91
+ broadcastCircuitBreaker: {
92
+ threshold: 10, // Open circuit after 10 failures
93
+ resetTimeMs: 30000, // Reset after 30 seconds
94
+ },
95
+ syncBroadcastRateLimit: {
96
+ maxPerSecond: 10, // Max 10 sync broadcasts per second
97
+ },
98
+ };
99
+
100
+ // Directories
101
+ export const AUDIO_DIR = path.join(ROOT_DIR, 'public', 'audio');
102
+ export const UPLOADS_DIR = path.join(ROOT_DIR, 'uploads');
103
+ export const SHARES_DIR = path.join(ROOT_DIR, 'shared-conversations');
104
+ export const SYNC_DIR = path.join(ROOT_DIR, 'sync-data');
105
+ export const ACTIVITY_FILE = path.join(ROOT_DIR, 'activity.json');
106
+ export const MESSAGES_FILE = path.join(ROOT_DIR, 'messages-sync.json');
107
+
108
+ // Agent media configuration
109
+ // Allowed directories for agent-generated media (security whitelist)
110
+ const homeDir = process.env.HOME || process.env.USERPROFILE || process.cwd();
111
+ export const AGENT_MEDIA_DIRS = [
112
+ // OpenClaw media directory (screenshots, TTS, generated files)
113
+ process.env.OPENCLAW_MEDIA_DIR || path.join(homeDir, '.openclaw', 'media'),
114
+ // Workspace artifacts directory
115
+ path.join(ROOT_DIR, 'artifacts'),
116
+ // Uploads directory (for compatibility)
117
+ UPLOADS_DIR,
118
+ ];
119
+
120
+ // Allowed file extensions for agent media (security whitelist)
121
+ export const AGENT_MEDIA_ALLOWED_EXTENSIONS = [
122
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', // Images
123
+ '.pdf', // Documents
124
+ '.mp3', '.wav', '.ogg', '.m4a', // Audio
125
+ ];
126
+
127
+ // Maximum agent media file size (10MB)
128
+ export const AGENT_MEDIA_MAX_SIZE = 10 * 1024 * 1024;
129
+
130
+ // Cleanup
131
+ export const AUDIO_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
132
+ export const UPLOADS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
133
+
134
+ // Limits
135
+ export const MAX_ACTIVITY_ITEMS = 100;
136
+ export const MAX_SYNC_MESSAGES = 50;
137
+
138
+ // Input length validation limits (security)
139
+ export const MAX_INPUT_LENGTHS = {
140
+ MESSAGE: 50000, // Chat messages, webhook messages
141
+ CAPTION: 500, // Image/video captions
142
+ SATELLITE_ID: 100, // Satellite identifier
143
+ ACTION: 100, // Trigger action names
144
+ TITLE: 200, // Notification titles
145
+ BODY: 5000, // Notification bodies
146
+ TYPE: 32, // Activity type
147
+ SUMMARY: 500, // Activity summary
148
+ DETAILS: 5000, // Activity details
149
+ SOURCE: 50, // Webhook source name
150
+ METADATA_KEY: 100, // Metadata object keys
151
+ };
152
+
153
+ // Warnings
154
+ if (!GATEWAY_TOKEN) {
155
+ log.error('⚠️ GATEWAY_TOKEN not set. Create a .env file with your gateway token.');
156
+ }
157
+ if (!process.env.TRANSCRIBE_SCRIPT) {
158
+ log.warn('⚠️ TRANSCRIBE_SCRIPT not set. Using default:', TRANSCRIBE_SCRIPT);
159
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Context Module - Standardized request context for route handlers
3
+ *
4
+ * Provides a clean, consistent API for route modules to access shared
5
+ * functionality without tight coupling to implementation details.
6
+ */
7
+
8
+ import { log, fetchWithTimeout } from './utils.js';
9
+
10
+ /**
11
+ * Create a standardized request context object for route handlers
12
+ *
13
+ * This replaces the ad-hoc baseDeps pattern with a clean interface that
14
+ * explicitly documents what's available to route handlers.
15
+ *
16
+ * @param {Object} options - Context dependencies
17
+ * @param {Object} options.config - Configuration constants
18
+ * @param {Function} options.log - Logging utility
19
+ * @param {Function} options.fetchWithTimeout - HTTP fetch with timeout
20
+ * @param {Function} options.transcribe - Speech-to-text
21
+ * @param {Function} options.chat - Chat function
22
+ * @param {Function} options.chatWithParallelTTS - Chat with parallel TTS
23
+ * @param {Function} options.generateTTS - Text-to-speech generation
24
+ * @param {Function} options.sendMessage - Unified message sending
25
+ * @param {Function} options.saveMessageToSync - Save message to sync file
26
+ * @param {Function} options.broadcastOpenClawPush - Broadcast OpenClaw push messages
27
+ * @param {Function} options.broadcastToAll - Broadcast to all WebSocket clients
28
+ * @param {Map} options.wsClients - WebSocket clients map
29
+ * @param {Function} options.verifyWebhookToken - Webhook auth middleware
30
+ * @param {Function} options.strictLimiter - Rate limiting middleware
31
+ * @param {Function} options.isPrivateIP - SSRF prevention
32
+ * @param {Function} options.loadConfig - Load runtime config
33
+ * @param {Function} options.getClientConfig - Get client-safe config
34
+ * @param {Function} options.saveConfig - Save runtime config
35
+ * @param {Function} options.needsOnboarding - Check if onboarding needed
36
+ * @param {Object} options.audioUpload - Multer audio upload handler
37
+ * @param {Object} options.imageUpload - Multer image upload handler
38
+ * @param {Object} options.videoUpload - Multer video upload handler
39
+ * @param {Object} options.fileUpload - Multer file upload handler
40
+ * @param {string} options.uploadsDir - Uploads directory path
41
+ * @param {string} options.tempDir - Temp directory path
42
+ * @param {Object} options.requestHelpers - Request tracking helpers
43
+ * @returns {Object} Standardized context object
44
+ */
45
+ export function createRequestContext({
46
+ config,
47
+ log: logger,
48
+ fetchWithTimeout: fetch,
49
+ transcribe,
50
+ chat,
51
+ chatWithParallelTTS,
52
+ generateTTS,
53
+ sendMessage,
54
+ saveMessageToSync,
55
+ broadcastOpenClawPush,
56
+ broadcastToAll,
57
+ wsClients,
58
+ verifyWebhookToken,
59
+ strictLimiter,
60
+ isPrivateIP,
61
+ loadConfig,
62
+ getClientConfig,
63
+ saveConfig,
64
+ needsOnboarding,
65
+ audioUpload,
66
+ imageUpload,
67
+ videoUpload,
68
+ fileUpload,
69
+ uploadsDir,
70
+ tempDir,
71
+ requestHelpers,
72
+ }) {
73
+ return {
74
+ // Configuration
75
+ config,
76
+
77
+ // Core utilities
78
+ log: logger || log,
79
+ fetch: fetch || fetchWithTimeout,
80
+
81
+ // Chat functions
82
+ transcribe,
83
+ chat,
84
+ chatWithParallelTTS,
85
+ generateTTS,
86
+ sendMessage,
87
+ saveMessageToSync,
88
+
89
+ // WebSocket broadcasting
90
+ broadcastOpenClawPush,
91
+ broadcastToAll,
92
+ wsClients,
93
+
94
+ // Middleware
95
+ verifyWebhookToken,
96
+ strictLimiter,
97
+ isPrivateIP,
98
+
99
+ // Config management
100
+ loadConfig,
101
+ getClientConfig,
102
+ saveConfig,
103
+ needsOnboarding,
104
+
105
+ // File uploads
106
+ audioUpload,
107
+ imageUpload,
108
+ videoUpload,
109
+ fileUpload,
110
+
111
+ // Directories
112
+ uploadsDir,
113
+ tempDir,
114
+
115
+ // Request tracking
116
+ requestHelpers,
117
+
118
+ // Backwards compatibility aliases (can be removed in future cleanup)
119
+ // These ensure existing route code doesn't break during migration
120
+ // Remove these once all routes are updated to use the context object directly
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Extract request helpers from context for backwards compatibility
126
+ * @param {Object} context - Request context
127
+ * @returns {Object} Request helpers
128
+ */
129
+ export function getRequestHelpers(context) {
130
+ return context.requestHelpers;
131
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Gateway Commands - Route slash commands through the gateway WebSocket RPC
3
+ *
4
+ * The /v1/chat/completions HTTP endpoint doesn't process slash commands.
5
+ * Commands like /compact need the gateway's reply pipeline which only
6
+ * runs through WebSocket RPC (chat.send).
7
+ *
8
+ * Protocol: JSON-RPC over WebSocket
9
+ * 1. Connect to ws://localhost:18789/ws
10
+ * 2. Send connect request with auth token
11
+ * 3. Send chat.send with message and sessionKey
12
+ * 4. Collect response events until lifecycle.end
13
+ */
14
+
15
+ import WebSocket from 'ws';
16
+ import { randomUUID } from 'crypto';
17
+ import { log } from './utils.js';
18
+ import { loadConfig } from './runtime-config.js';
19
+
20
+ const STATIC_GATEWAY_WS_URL = process.env.GATEWAY_WS_URL || 'ws://localhost:18789/ws';
21
+ const STATIC_GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || '';
22
+
23
+ /**
24
+ * Get gateway WS URL and token dynamically (includes auto-discovered values)
25
+ */
26
+ async function getGatewayWsConfig() {
27
+ try {
28
+ const config = await loadConfig();
29
+ const gatewayUrl = config.gatewayUrl || 'http://127.0.0.1:18789';
30
+ // Convert HTTP URL to WS URL
31
+ const wsUrl = gatewayUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + '/ws';
32
+ return {
33
+ wsUrl: process.env.GATEWAY_WS_URL || wsUrl,
34
+ token: config.gatewayToken || STATIC_GATEWAY_TOKEN,
35
+ };
36
+ } catch {
37
+ return { wsUrl: STATIC_GATEWAY_WS_URL, token: STATIC_GATEWAY_TOKEN };
38
+ }
39
+ }
40
+
41
+ // Gateway slash commands that should be routed via RPC
42
+ const GATEWAY_COMMANDS = new Set([
43
+ 'help', 'commands', 'skill', 'status', 'allowlist', 'approve',
44
+ 'context', 'tts', 'whoami', 'subagents', 'config', 'debug',
45
+ 'usage', 'stop', 'restart', 'activation', 'send', 'reset',
46
+ 'new', 'compact', 'think', 'verbose', 'reasoning', 'elevated',
47
+ 'exec', 'model', 'models', 'queue', 'bash',
48
+ ]);
49
+
50
+ /**
51
+ * Check if a message is a gateway slash command
52
+ */
53
+ export function isGatewayCommand(message) {
54
+ if (!message || !message.startsWith('/')) return false;
55
+ const cmd = message.slice(1).split(/\s/)[0].toLowerCase();
56
+ return GATEWAY_COMMANDS.has(cmd);
57
+ }
58
+
59
+ /**
60
+ * Send a JSON-RPC request over a WebSocket
61
+ */
62
+ function sendRequest(ws, method, params) {
63
+ const id = randomUUID();
64
+ const frame = { type: 'req', id, method, params };
65
+ ws.send(JSON.stringify(frame));
66
+ return id;
67
+ }
68
+
69
+ /**
70
+ * Send a gateway command via WebSocket RPC (chat.send)
71
+ *
72
+ * @param {string} command - The slash command (e.g. "/compact")
73
+ * @param {string} sessionKey - Session key
74
+ * @param {number} timeoutMs - Timeout (default 60s)
75
+ * @returns {Promise<{response: string}>}
76
+ */
77
+ export async function sendGatewayCommand(command, sessionKey, timeoutMs = 60000) {
78
+ const gw = await getGatewayWsConfig();
79
+
80
+ return new Promise((resolve, reject) => {
81
+ let ws;
82
+ let response = '';
83
+ let settled = false;
84
+ let connectId = null;
85
+ let chatId = null;
86
+ let timer;
87
+
88
+ const finish = (result) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ try { ws?.close(); } catch (e) {}
93
+ resolve(result);
94
+ };
95
+
96
+ const fail = (err) => {
97
+ if (settled) return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ try { ws?.close(); } catch (e) {}
101
+ reject(err);
102
+ };
103
+
104
+ timer = setTimeout(() => {
105
+ finish({ response: response || 'Command sent (timeout).' });
106
+ }, timeoutMs);
107
+
108
+ try {
109
+ ws = new WebSocket(gw.wsUrl);
110
+
111
+ ws.on('open', () => {
112
+ log('debug', `[GatewayCmd] Connected to ${gw.wsUrl}, sending connect...`);
113
+ // Step 1: Send connect with auth
114
+ // Client ID must be a valid GATEWAY_CLIENT_ID constant
115
+ connectId = sendRequest(ws, 'connect', {
116
+ minProtocol: 3,
117
+ maxProtocol: 3,
118
+ client: {
119
+ id: 'gateway-client',
120
+ displayName: 'Uplink',
121
+ version: '1.0.0',
122
+ platform: process.platform,
123
+ mode: 'backend',
124
+ },
125
+ caps: [],
126
+ auth: gw.token ? { token: gw.token } : undefined,
127
+ role: 'operator',
128
+ scopes: ['operator.admin'],
129
+ });
130
+ });
131
+
132
+ ws.on('message', (raw) => {
133
+ try {
134
+ const msg = JSON.parse(raw.toString());
135
+
136
+ // Handle RPC responses
137
+ if (msg.type === 'res') {
138
+ if (msg.id === connectId) {
139
+ if (msg.ok) {
140
+ log('debug', '[GatewayCmd] Connected OK, sending chat.send...');
141
+ // Step 2: Send the command via chat.send
142
+ chatId = sendRequest(ws, 'chat.send', {
143
+ sessionKey,
144
+ message: command,
145
+ idempotencyKey: `uplink-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
146
+ });
147
+ } else {
148
+ fail(new Error(`Gateway connect failed: ${msg.error?.message || 'unknown'}`));
149
+ }
150
+ } else if (msg.id === chatId) {
151
+ // chat.send response — the command has been processed
152
+ log('debug', `[GatewayCmd] chat.send response: ok=${msg.ok}`);
153
+ if (!msg.ok) {
154
+ finish({ response: `Error: ${msg.error?.message || 'Command failed'}` });
155
+ }
156
+ // Don't finish yet — wait for the agent events with the actual response
157
+ // But set a shorter timeout since the command was accepted
158
+ clearTimeout(timer);
159
+ timer = setTimeout(() => {
160
+ finish({ response: response || 'Command executed.' });
161
+ }, 10000);
162
+ }
163
+ }
164
+
165
+ // Handle events pushed by the gateway
166
+ if (msg.type === 'event') {
167
+ const event = msg.event;
168
+ const payload = msg.payload;
169
+
170
+ // Chat events contain the response
171
+ if (event === 'chat') {
172
+ const state = payload?.state;
173
+
174
+ // Delta events — accumulate text
175
+ if (state === 'delta') {
176
+ const text = payload?.message?.content?.[0]?.text || payload?.delta || '';
177
+ if (text) response += text;
178
+ }
179
+
180
+ // Final event — complete response
181
+ if (state === 'final') {
182
+ const content = payload?.message?.content;
183
+ if (Array.isArray(content)) {
184
+ const text = content
185
+ .filter(c => c.type === 'text')
186
+ .map(c => c.text)
187
+ .join('\n');
188
+ if (text) response = text;
189
+ }
190
+ finish({ response: response || 'Command executed.' });
191
+ }
192
+ }
193
+ }
194
+ } catch (e) {
195
+ // Non-JSON, skip
196
+ }
197
+ });
198
+
199
+ ws.on('error', (err) => {
200
+ fail(new Error(`Gateway WebSocket error: ${err.message}`));
201
+ });
202
+
203
+ ws.on('close', () => {
204
+ finish({ response: response || 'Command executed.' });
205
+ });
206
+
207
+ } catch (err) {
208
+ fail(err);
209
+ }
210
+ });
211
+ }