@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,529 @@
1
+ /**
2
+ * Channel Module - OpenClaw channel webhook integration
3
+ *
4
+ * Routes messages through the OpenClaw channel plugin for proper
5
+ * session isolation and unified message handling.
6
+ */
7
+
8
+ import { log, fetchWithTimeout } from './utils.js';
9
+ import { isGatewayCommand as isGatewayCommandUtil } from './gateway-commands.js';
10
+ import {
11
+ OPENCLAW_WEBHOOK_URL,
12
+ OPENCLAW_CALLBACK_SECRET,
13
+ USE_CHANNEL_WEBHOOK,
14
+ GATEWAY_URL as STATIC_GATEWAY_URL,
15
+ GATEWAY_TOKEN as STATIC_GATEWAY_TOKEN,
16
+ SESSION_USER,
17
+ CHANNEL_FETCH_TIMEOUT_MS,
18
+ STREAM_READ_TIMEOUT_MS
19
+ } from './config.js';
20
+ import { loadConfig } from './runtime-config.js';
21
+
22
+ /**
23
+ * Get gateway URL and token, preferring runtime config (which includes auto-discovery)
24
+ * over static config.js values. This is needed because config.js exports are set at
25
+ * module load time and don't reflect auto-discovered values.
26
+ */
27
+ async function getGatewayConfig() {
28
+ try {
29
+ const config = await loadConfig();
30
+ return {
31
+ url: config.gatewayUrl || STATIC_GATEWAY_URL,
32
+ token: config.gatewayToken || STATIC_GATEWAY_TOKEN,
33
+ };
34
+ } catch {
35
+ // Fallback to static config if runtime config fails
36
+ return { url: STATIC_GATEWAY_URL, token: STATIC_GATEWAY_TOKEN };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check if channel webhook routing is enabled and configured
42
+ */
43
+ export function isChannelEnabled() {
44
+ return USE_CHANNEL_WEBHOOK && OPENCLAW_WEBHOOK_URL && OPENCLAW_CALLBACK_SECRET;
45
+ }
46
+
47
+ /**
48
+ * Send a message through the OpenClaw channel webhook
49
+ * Returns an async generator that yields stream chunks
50
+ *
51
+ * @param {Object} params
52
+ * @param {string} params.message - The message to send
53
+ * @param {string} params.satelliteId - Satellite ID for session isolation
54
+ * @param {string} params.mode - 'text' or 'voice'
55
+ * @param {string} params.requestId - Optional request ID for tracking
56
+ * @param {string} params.userId - Optional user ID
57
+ */
58
+ export async function* sendViaChannel({ message, satelliteId = 'main', mode = 'text', requestId, userId = 'default' }) {
59
+ if (!isChannelEnabled()) {
60
+ throw new Error('Channel webhook not configured');
61
+ }
62
+
63
+ log('debug', `[Channel] Sending to webhook: satellite=${satelliteId}, mode=${mode}`);
64
+
65
+ const response = await fetchWithTimeout(OPENCLAW_WEBHOOK_URL, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'X-Uplink-Secret': OPENCLAW_CALLBACK_SECRET,
70
+ },
71
+ body: JSON.stringify({
72
+ message,
73
+ satelliteId,
74
+ mode,
75
+ requestId,
76
+ userId,
77
+ }),
78
+ }, CHANNEL_FETCH_TIMEOUT_MS);
79
+
80
+ if (!response.ok) {
81
+ const text = await response.text();
82
+ throw new Error(`Channel webhook error: ${response.status} - ${text}`);
83
+ }
84
+
85
+ // Parse SSE stream
86
+ const reader = response.body.getReader();
87
+ const decoder = new TextDecoder();
88
+ let buffer = '';
89
+
90
+ // Helper to read with timeout (prevents hanging on stalled streams)
91
+ const readWithTimeout = (timeoutMs = STREAM_READ_TIMEOUT_MS) => {
92
+ return Promise.race([
93
+ reader.read(),
94
+ new Promise((_, reject) =>
95
+ setTimeout(() => reject(new Error('Stream read timed out - gateway may be stalled')), timeoutMs)
96
+ ),
97
+ ]);
98
+ };
99
+
100
+ try {
101
+ while (true) {
102
+ const { done, value } = await readWithTimeout();
103
+ if (done) break;
104
+
105
+ buffer += decoder.decode(value, { stream: true });
106
+ const lines = buffer.split('\n');
107
+ buffer = lines.pop() || '';
108
+
109
+ for (const line of lines) {
110
+ if (line.startsWith('data: ')) {
111
+ const data = line.slice(6);
112
+ if (data === '[DONE]') {
113
+ return;
114
+ }
115
+
116
+ try {
117
+ const parsed = JSON.parse(data);
118
+ yield parsed; // { type: 'thinking' | 'chunk' | 'tool' | 'complete' | 'error', ... }
119
+ } catch {
120
+ // Skip unparseable chunks
121
+ }
122
+ }
123
+ }
124
+ }
125
+ } finally {
126
+ // Ensure reader is released on timeout or error
127
+ reader.releaseLock();
128
+ }
129
+ }
130
+
131
+ // ============================================================================
132
+ // sendMessage Helper Functions
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Create a reader with timeout capability
137
+ * Prevents hanging on stalled streams (uses STREAM_READ_TIMEOUT_MS for agent tool use)
138
+ *
139
+ * @param {ReadableStreamDefaultReader} reader - The stream reader
140
+ * @param {number} timeoutMs - Timeout in milliseconds (default from config)
141
+ * @returns {Promise} - Resolves with read result or rejects on timeout
142
+ */
143
+ function createReadWithTimeout(reader, timeoutMs = STREAM_READ_TIMEOUT_MS) {
144
+ return Promise.race([
145
+ reader.read(),
146
+ new Promise((_, reject) =>
147
+ setTimeout(() => reject(new Error('Stream read timed out - gateway may be stalled')), timeoutMs)
148
+ ),
149
+ ]);
150
+ }
151
+
152
+ /**
153
+ * Estimate token usage when not provided by the API
154
+ *
155
+ * @param {string} inputMessage - The input message
156
+ * @param {string} outputResponse - The output response
157
+ * @returns {Object} - Estimated token usage object
158
+ */
159
+ function estimateTokenUsage(inputMessage, outputResponse) {
160
+ const promptTokens = Math.ceil(inputMessage.length / 4);
161
+ const completionTokens = Math.ceil(outputResponse.length / 4);
162
+ return {
163
+ prompt_tokens: promptTokens,
164
+ completion_tokens: completionTokens,
165
+ total_tokens: promptTokens + completionTokens,
166
+ estimated: true,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Process events from the channel webhook stream
172
+ *
173
+ * @param {Object} params
174
+ * @param {AsyncIterable} params.eventStream - The channel event stream
175
+ * @param {AbortSignal} params.signal - Abort signal for cancellation
176
+ * @param {Object} params.callbacks - Event callbacks
177
+ * @returns {Object} - { fullResponse, tokenUsage, detectedTools }
178
+ */
179
+ async function processChannelStream({ eventStream, signal, callbacks }) {
180
+ const { onChunk, onTool, onThinking } = callbacks;
181
+ let fullResponse = '';
182
+ let tokenUsage = null;
183
+ const detectedTools = [];
184
+
185
+ for await (const event of eventStream) {
186
+ if (signal?.aborted) {
187
+ log('debug', '[Channel] Request aborted during channel streaming');
188
+ break;
189
+ }
190
+
191
+ switch (event.type) {
192
+ case 'thinking':
193
+ onThinking?.();
194
+ break;
195
+ case 'chunk':
196
+ if (event.content) {
197
+ fullResponse += event.content;
198
+ onChunk?.(event.content);
199
+ }
200
+ break;
201
+ case 'tool':
202
+ if (event.tool && !detectedTools.includes(event.tool)) {
203
+ detectedTools.push(event.tool);
204
+ onTool?.(event.tool);
205
+ }
206
+ break;
207
+ case 'complete':
208
+ if (event.response) {
209
+ fullResponse = event.response;
210
+ }
211
+ if (event.usage) {
212
+ tokenUsage = event.usage;
213
+ }
214
+ break;
215
+ case 'error':
216
+ throw new Error(event.error || 'Unknown channel error');
217
+ }
218
+ }
219
+
220
+ return { fullResponse, tokenUsage, detectedTools };
221
+ }
222
+
223
+ /**
224
+ * Build the gateway request configuration
225
+ *
226
+ * @param {Object} params
227
+ * @param {string} params.message - The message to send
228
+ * @param {string} params.satelliteId - Satellite ID
229
+ * @param {string} params.satelliteName - Satellite display name (for session label)
230
+ * @param {string} params.mode - 'text' or 'voice'
231
+ * @param {AbortSignal} params.signal - Abort signal
232
+ * @returns {Object} - { url, options, sessionKey }
233
+ */
234
+ // Re-use isGatewayCommand from gateway-commands.js (single source of truth - M-32 fix)
235
+ const isGatewayCommand = isGatewayCommandUtil;
236
+
237
+ async function buildGatewayRequest({ message, satelliteId, satelliteName, agentId, mode, signal }) {
238
+ // Get gateway config dynamically (includes auto-discovered values)
239
+ const gw = await getGatewayConfig();
240
+
241
+ // Don't prefix gateway slash commands — gateway needs to see them raw
242
+ const isCommand = isGatewayCommand(message);
243
+ const prefix = isCommand
244
+ ? ''
245
+ : mode === 'voice'
246
+ ? '[Voice chat - keep response brief and conversational, 1-2 sentences max] '
247
+ : '[Text chat via Uplink] ';
248
+
249
+ // Build canonical session key format (OpenClaw: agent:{agentId}:{provider}:{scope}:{identifier})
250
+ // Main satellite shares session with Dashboard: agent:main:main
251
+ // Other satellites get isolated: agent:{agentId}:uplink:satellite:<satelliteId>
252
+ // agentId determines which agent handles the session
253
+ const agent = agentId || 'main';
254
+ const sessionKey = satelliteId === 'main'
255
+ ? `agent:${agent}:main` // Share with Dashboard for session sync
256
+ : `agent:${agent}:uplink:satellite:${satelliteId}`;
257
+
258
+ if (!gw.url) {
259
+ throw new Error('Gateway URL not configured. Check OpenClaw gateway settings.');
260
+ }
261
+
262
+ log('debug', `[Channel] Direct gateway call: sessionKey=${sessionKey}, mode=${mode}, label=${satelliteName}`);
263
+ log('debug', `[Channel] Using gateway: ${gw.url}, hasToken: ${!!gw.token}`);
264
+
265
+ const url = `${gw.url}/v1/chat/completions`;
266
+ const headers = {
267
+ 'Content-Type': 'application/json',
268
+ 'Authorization': `Bearer ${gw.token}`,
269
+ 'x-openclaw-session-key': sessionKey,
270
+ };
271
+
272
+ // Add session label if satellite name provided
273
+ if (satelliteName) {
274
+ headers['x-openclaw-session-label'] = satelliteName;
275
+ }
276
+
277
+ const options = {
278
+ method: 'POST',
279
+ headers,
280
+ body: JSON.stringify({
281
+ model: 'openclaw',
282
+ user: `uplink-default`,
283
+ stream: true,
284
+ stream_options: { include_usage: true },
285
+ messages: [{ role: 'user', content: `${prefix}${message}` }],
286
+ }),
287
+ signal,
288
+ };
289
+
290
+ return { url, options, sessionKey };
291
+ }
292
+
293
+ /**
294
+ * Validate gateway response and return the reader
295
+ *
296
+ * @param {Response} response - The fetch response
297
+ * @returns {ReadableStreamDefaultReader} - The body reader
298
+ * @throws {Error} - If response is not ok or has no body
299
+ */
300
+ async function validateGatewayResponse(response) {
301
+ if (!response.ok) {
302
+ const text = await response.text();
303
+ throw new Error(`Gateway error: ${response.status} - ${text}`);
304
+ }
305
+
306
+ log('debug', `[Channel] Gateway response received: status=${response.status}, hasBody=${!!response.body}, bodyType=${response.body?.constructor?.name}`);
307
+
308
+ if (!response.body) {
309
+ throw new Error('Response has no body');
310
+ }
311
+
312
+ return response.body.getReader();
313
+ }
314
+
315
+ /**
316
+ * Parse SSE line and extract content/metadata
317
+ *
318
+ * @param {string} line - The SSE line to parse
319
+ * @returns {Object|null} - Parsed data or null if not parseable
320
+ */
321
+ function parseSSELine(line) {
322
+ if (!line.startsWith('data: ')) {
323
+ return null;
324
+ }
325
+
326
+ const data = line.slice(6);
327
+ if (data === '[DONE]') {
328
+ log('debug', '[Channel] Got [DONE] marker');
329
+ return { done: true };
330
+ }
331
+
332
+ try {
333
+ const parsed = JSON.parse(data);
334
+ const delta = parsed.choices?.[0]?.delta;
335
+
336
+ // Extract tool call names from OpenAI-compatible streaming format
337
+ const toolCalls = delta?.tool_calls;
338
+ let toolName = null;
339
+ if (toolCalls && toolCalls.length > 0) {
340
+ // tool_calls[].function.name appears in the first chunk for each tool call
341
+ toolName = toolCalls[0]?.function?.name || null;
342
+ }
343
+
344
+ return {
345
+ content: delta?.content || '',
346
+ usage: parsed.usage || null,
347
+ toolName,
348
+ };
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Process the gateway SSE stream
356
+ *
357
+ * @param {Object} params
358
+ * @param {ReadableStreamDefaultReader} params.reader - Stream reader
359
+ * @param {AbortSignal} params.signal - Abort signal
360
+ * @param {Object} params.callbacks - Event callbacks
361
+ * @returns {Object} - { fullResponse, tokenUsage, detectedTools }
362
+ */
363
+ async function processGatewayStream({ reader, signal, callbacks }) {
364
+ const { onChunk, onTool, onThinking } = callbacks;
365
+ const decoder = new TextDecoder();
366
+
367
+ let buffer = '';
368
+ let fullResponse = '';
369
+ let tokenUsage = null;
370
+ const detectedTools = [];
371
+ let chunkCount = 0;
372
+
373
+ onThinking?.();
374
+
375
+ try {
376
+ while (true) {
377
+ if (signal?.aborted) {
378
+ log('debug', '[Channel] Request aborted, stopping stream read');
379
+ break;
380
+ }
381
+
382
+ log('debug', `[Channel] About to read chunk ${chunkCount + 1}...`);
383
+ const { done, value } = await createReadWithTimeout(reader);
384
+ log('debug', `[Channel] Read returned: done=${done}, valueLen=${value?.length || 0}`);
385
+
386
+ if (done) {
387
+ log('debug', `[Channel] Stream complete after ${chunkCount} chunks, response: ${fullResponse.length} chars`);
388
+ break;
389
+ }
390
+
391
+ chunkCount++;
392
+ const rawChunk = decoder.decode(value, { stream: true });
393
+ buffer += rawChunk;
394
+
395
+ if (chunkCount <= 3) {
396
+ log('debug', `[Channel] Chunk ${chunkCount}: ${rawChunk.substring(0, 200)}`);
397
+ }
398
+
399
+ const lines = buffer.split('\n');
400
+ buffer = lines.pop() || '';
401
+
402
+ for (const line of lines) {
403
+ const parsed = parseSSELine(line);
404
+ if (!parsed || parsed.done) continue;
405
+
406
+ if (parsed.usage) {
407
+ tokenUsage = parsed.usage;
408
+ }
409
+
410
+ // Detect tool usage from structured tool_calls field
411
+ if (parsed.toolName) {
412
+ log('debug', `[Channel] Tool detected from stream: ${parsed.toolName}`);
413
+ }
414
+ if (parsed.toolName && !detectedTools.includes(parsed.toolName)) {
415
+ detectedTools.push(parsed.toolName);
416
+ onTool?.(parsed.toolName);
417
+ }
418
+
419
+ if (parsed.content) {
420
+ fullResponse += parsed.content;
421
+ onChunk?.(parsed.content);
422
+
423
+ // Fallback: detect tool usage in content via XML pattern
424
+ const toolMatch = parsed.content.match(/<(?:antml:)?invoke name="(\w+)"/);
425
+ if (toolMatch && !detectedTools.includes(toolMatch[1])) {
426
+ detectedTools.push(toolMatch[1]);
427
+ onTool?.(toolMatch[1]);
428
+ }
429
+ }
430
+ }
431
+ }
432
+ } finally {
433
+ reader.releaseLock();
434
+ }
435
+
436
+ return { fullResponse, tokenUsage, detectedTools };
437
+ }
438
+
439
+ // ============================================================================
440
+ // Main sendMessage Function
441
+ // ============================================================================
442
+
443
+ /**
444
+ * Send a message - routes through channel if enabled, otherwise direct to gateway
445
+ * Returns the full response and optional usage stats
446
+ *
447
+ * @param {Object} params
448
+ * @param {string} params.message - The message to send
449
+ * @param {string} params.satelliteId - Satellite ID
450
+ * @param {string} params.satelliteName - Satellite display name (for session label)
451
+ * @param {string} params.mode - 'text' or 'voice'
452
+ * @param {AbortSignal} params.signal - Optional abort signal for cancellation
453
+ * @param {function} params.onChunk - Callback for stream chunks
454
+ * @param {function} params.onTool - Callback for tool usage
455
+ * @param {function} params.onThinking - Callback for thinking indicator
456
+ */
457
+ export async function sendMessage({
458
+ message,
459
+ satelliteId = 'main',
460
+ satelliteName,
461
+ agentId,
462
+ mode = 'text',
463
+ requestId,
464
+ signal,
465
+ onChunk,
466
+ onTool,
467
+ onThinking,
468
+ }) {
469
+ const callbacks = { onChunk, onTool, onThinking };
470
+ let result;
471
+
472
+ if (isChannelEnabled()) {
473
+ // Route through channel webhook
474
+ const eventStream = sendViaChannel({ message, satelliteId, mode, requestId });
475
+ result = await processChannelStream({ eventStream, signal, callbacks });
476
+ } else {
477
+ // Direct gateway call
478
+ const { url, options } = await buildGatewayRequest({ message, satelliteId, satelliteName, agentId, mode, signal });
479
+ const response = await fetch(url, options);
480
+ const reader = await validateGatewayResponse(response);
481
+ result = await processGatewayStream({ reader, signal, callbacks });
482
+ }
483
+
484
+ const { fullResponse, tokenUsage, detectedTools } = result;
485
+
486
+ // Parse MEDIA: references from agent responses
487
+ const mediaPattern = /MEDIA:(.+?)(?:\n|$)/g;
488
+ const mediaRefs = [];
489
+ let match;
490
+ while ((match = mediaPattern.exec(fullResponse)) !== null) {
491
+ mediaRefs.push(match[1].trim());
492
+ }
493
+
494
+ // Strip MEDIA: lines from displayed text
495
+ let cleanResponse = mediaRefs.length > 0
496
+ ? fullResponse.replace(/MEDIA:.+?(?:\n|$)/g, '').trim()
497
+ : fullResponse;
498
+
499
+ // Rewrite local file paths in markdown images to proxy URLs (if registerAgentMedia is available)
500
+ // This handles cases where agents embed ![alt](local/path.png) in markdown
501
+ const registerFn = global.registerAgentMedia || globalThis.registerAgentMedia;
502
+ if (registerFn && typeof registerFn === 'function') {
503
+ cleanResponse = cleanResponse.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
504
+ const trimmedUrl = url.trim();
505
+ // Skip URLs that are already http(s) or proxy URLs
506
+ if (/^(https?:|\/api\/)/i.test(trimmedUrl)) {
507
+ return match;
508
+ }
509
+ // Try to register the local path as agent media
510
+ const proxyUrl = registerFn(trimmedUrl);
511
+ if (proxyUrl) {
512
+ log('debug', `[Channel] Rewrote markdown image: ${trimmedUrl} -> ${proxyUrl}`);
513
+ return `![${alt}](${proxyUrl})`;
514
+ }
515
+ // If registration failed, leave as-is (client will handle gracefully)
516
+ return match;
517
+ });
518
+ }
519
+
520
+ // Use estimated tokens if not provided by API
521
+ const finalUsage = tokenUsage || estimateTokenUsage(message, cleanResponse);
522
+
523
+ return {
524
+ response: cleanResponse,
525
+ usage: finalUsage,
526
+ tools: detectedTools.length > 0 ? detectedTools : undefined,
527
+ media: mediaRefs.length > 0 ? mediaRefs : undefined,
528
+ };
529
+ }