@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,156 @@
1
+ /**
2
+ * Push Routes - Web Push notification endpoints
3
+ * Uses synchronous file I/O for simplicity and reliability
4
+ */
5
+
6
+ import webpush from 'web-push';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { createLogger } from '../logger.js';
10
+
11
+ const log = createLogger('Push');
12
+
13
+ const SUBSCRIPTIONS_FILE = path.join(process.cwd(), 'push-subscriptions.json');
14
+
15
+ // VAPID configuration
16
+ const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
17
+ const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
18
+ const VAPID_EMAIL = process.env.VAPID_EMAIL || 'mailto:nate@moonco.pro';
19
+
20
+ // Configure web-push
21
+ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
22
+ webpush.setVapidDetails(VAPID_EMAIL, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
23
+ log.info('VAPID configured');
24
+ } else {
25
+ log.info('VAPID keys not configured - push disabled');
26
+ }
27
+
28
+ // Load subscriptions from file (sync)
29
+ function loadSubscriptions() {
30
+ try {
31
+ if (fs.existsSync(SUBSCRIPTIONS_FILE)) {
32
+ const data = fs.readFileSync(SUBSCRIPTIONS_FILE, 'utf8');
33
+ const parsed = JSON.parse(data);
34
+ log.info(`Loaded ${Object.keys(parsed).length} subscriptions`);
35
+ return parsed;
36
+ }
37
+ } catch (err) {
38
+ log.debug('No subscriptions file found, starting fresh');
39
+ }
40
+ return {};
41
+ }
42
+
43
+ // Save subscriptions to file (sync)
44
+ function saveSubscriptions(subs) {
45
+ try {
46
+ fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(subs, null, 2));
47
+ } catch (err) {
48
+ log.error('Error saving subscriptions:', err.message);
49
+ }
50
+ }
51
+
52
+ // In-memory store, loaded from file on startup
53
+ let subscriptions = loadSubscriptions();
54
+
55
+ /**
56
+ * Send a push notification
57
+ */
58
+ export async function sendPushNotification(userId, payload) {
59
+ const sub = subscriptions[userId || 'default'];
60
+ if (!sub) {
61
+ log.debug(`No subscription for user: ${userId}`);
62
+ return { ok: false, error: 'No subscription' };
63
+ }
64
+
65
+ try {
66
+ log.debug(`Sending to endpoint: ${sub.endpoint.substring(0, 50)}...`);
67
+ const result = await webpush.sendNotification(sub, JSON.stringify(payload));
68
+ log.debug(`Sent notification to ${userId}`, result);
69
+ return { ok: true };
70
+ } catch (err) {
71
+ log.error('Send failed:', err.statusCode, err.message, err.body);
72
+ if (err.statusCode === 410) {
73
+ // Subscription expired, remove it
74
+ delete subscriptions[userId || 'default'];
75
+ saveSubscriptions(subscriptions);
76
+ log.info(`Removed expired subscription for ${userId}`);
77
+ }
78
+ return { ok: false, error: err.message };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Setup push routes
84
+ */
85
+ export function setupPushRoutes(app, context) {
86
+ const { log, strictLimiter } = context;
87
+
88
+ // GET /api/push/vapid-public
89
+ app.get('/api/push/vapid-public', (req, res) => {
90
+ if (!VAPID_PUBLIC_KEY) {
91
+ return res.status(503).json({ error: 'Push not configured' });
92
+ }
93
+ res.json({ publicKey: VAPID_PUBLIC_KEY });
94
+ });
95
+
96
+ // POST /api/push/subscribe
97
+ app.post('/api/push/subscribe', strictLimiter, (req, res) => {
98
+ const { subscription, userId } = req.body;
99
+ if (!subscription || typeof subscription.endpoint !== 'string' || !subscription.keys?.p256dh || !subscription.keys?.auth) {
100
+ return res.status(400).json({ error: 'Invalid subscription object — endpoint, keys.p256dh, and keys.auth required' });
101
+ }
102
+
103
+ const id = userId || 'default';
104
+ subscriptions[id] = subscription;
105
+ saveSubscriptions(subscriptions);
106
+
107
+ log('info', `[Push] Subscription saved for: ${id}`);
108
+ res.json({ ok: true });
109
+ });
110
+
111
+ // POST /api/push/send
112
+ app.post('/api/push/send', strictLimiter, async (req, res) => {
113
+ const { userId, title, body, url, satelliteId, tag } = req.body;
114
+
115
+ if (!VAPID_PUBLIC_KEY) {
116
+ return res.status(503).json({ error: 'Push not configured' });
117
+ }
118
+
119
+ const result = await sendPushNotification(userId || 'default', {
120
+ title: title || 'Uplink',
121
+ body: body || 'New message',
122
+ url: url || '/',
123
+ satelliteId,
124
+ tag: tag || 'uplink-message'
125
+ });
126
+
127
+ if (result.ok) {
128
+ res.json({ ok: true });
129
+ } else {
130
+ res.status(result.error === 'No subscription' ? 404 : 500).json(result);
131
+ }
132
+ });
133
+
134
+ // DELETE /api/push/unsubscribe
135
+ app.delete('/api/push/unsubscribe', strictLimiter, (req, res) => {
136
+ const { userId } = req.body;
137
+ const id = userId || 'default';
138
+
139
+ delete subscriptions[id];
140
+ saveSubscriptions(subscriptions);
141
+
142
+ log('info', `[Push] Subscription removed for: ${id}`);
143
+ res.json({ ok: true });
144
+ });
145
+
146
+ // GET /api/push/status
147
+ app.get('/api/push/status', (req, res) => {
148
+ res.json({
149
+ configured: !!(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY),
150
+ subscriptions: Object.keys(subscriptions).length,
151
+ publicKey: VAPID_PUBLIC_KEY || null
152
+ });
153
+ });
154
+
155
+ log('info', '[Push] Routes initialized');
156
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Satellite Routes - Satellite management including session retirement and history
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
8
+ import { sanitizeSatelliteId, isValidId } from '../../utils/id-sanitize.js';
9
+ import { getOpenClawStateDir } from '../openclaw-discover.js';
10
+
11
+ const AGENT_ID = process.env.OPENCLAW_AGENT_ID || 'main';
12
+
13
+ /**
14
+ * Get the sessions store path for a given agent.
15
+ * Defaults to AGENT_ID (usually 'main') when no agentId is provided.
16
+ * @param {string} [agentId] - Agent whose store to read
17
+ */
18
+ async function getSessionsStorePath(agentId) {
19
+ const stateDir = await getOpenClawStateDir();
20
+ const id = sanitizeAgentId(agentId) || AGENT_ID;
21
+ return path.join(stateDir, 'agents', id, 'sessions', 'sessions.json');
22
+ }
23
+
24
+ /**
25
+ * Get the sessions directory for a given agent's transcripts.
26
+ * @param {string} [agentId] - Agent whose sessions dir to read
27
+ */
28
+ async function getSessionsDir(agentId) {
29
+ const stateDir = await getOpenClawStateDir();
30
+ const id = sanitizeAgentId(agentId) || AGENT_ID;
31
+ return path.join(stateDir, 'agents', id, 'sessions');
32
+ }
33
+
34
+ /**
35
+ * Sanitize an agentId to prevent path traversal.
36
+ * Only allows lowercase alphanumeric + hyphens, max 64 chars.
37
+ */
38
+ function sanitizeAgentId(agentId) {
39
+ if (!agentId || typeof agentId !== 'string') return '';
40
+ return agentId.replace(/[^a-z0-9-]/g, '').substring(0, 64);
41
+ }
42
+
43
+ /**
44
+ * Setup satellite routes
45
+ * @param {Express} app - Express app instance
46
+ * @param {Object} context - Request context
47
+ */
48
+ export function setupSatelliteRoutes(app, context) {
49
+ const { log } = context;
50
+ // Note: Session key uses 'default' as userId to match channel.js
51
+
52
+ /**
53
+ * Retire a satellite - removes both local session and OpenClaw session
54
+ *
55
+ * POST /api/satellite/retire
56
+ * Body: { satelliteId: string }
57
+ *
58
+ * Returns: { ok: boolean, sessionDeleted: boolean, transcriptDeleted: boolean, message: string }
59
+ */
60
+ app.post('/api/satellite/retire', async (req, res) => {
61
+ const { satelliteId, agentId: rawAgentId } = req.body;
62
+
63
+ // Validate satellite ID
64
+ if (!satelliteId || typeof satelliteId !== 'string') {
65
+ return badRequest(res, 'satelliteId is required', ErrorCodes.MISSING_FIELD);
66
+ }
67
+
68
+ if (satelliteId.length > 100) {
69
+ return badRequest(res, 'satelliteId too long', ErrorCodes.VALIDATION_ERROR);
70
+ }
71
+
72
+ // Cannot retire the primary satellite via API
73
+ if (satelliteId === 'main') {
74
+ return badRequest(res, 'Cannot retire the primary satellite', ErrorCodes.VALIDATION_ERROR);
75
+ }
76
+
77
+ // Sanitize the satellite ID
78
+ const cleanId = sanitizeSatelliteId(satelliteId);
79
+ if (cleanId !== satelliteId) {
80
+ return badRequest(res, 'satelliteId contains invalid characters', ErrorCodes.VALIDATION_ERROR);
81
+ }
82
+
83
+ const agentId = rawAgentId ? String(rawAgentId).replace(/[^a-z0-9-]/g, '').substring(0, 64) || 'main' : 'main';
84
+
85
+ // Session keys to try - current + legacy formats for backward compatibility
86
+ // Current format (channel.js): agent:{agentId}:uplink:satellite:<satelliteId>
87
+ // Legacy formats from previous versions (always used agent:main):
88
+ const sessionKeysToTry = [
89
+ `agent:${agentId}:uplink:satellite:${cleanId}`,
90
+ `agent:main:uplink:satellite:${cleanId}`,
91
+ `agent:main:uplink-default:${cleanId}`,
92
+ `agent:main:uplink-uplink-user:${cleanId}`
93
+ ];
94
+
95
+ log('info', `[Satellite] Retiring satellite ${cleanId}, trying keys: ${sessionKeysToTry.join(', ')}`);
96
+
97
+ const result = {
98
+ ok: true,
99
+ satelliteId: cleanId,
100
+ sessionKeysDeleted: [],
101
+ transcriptsDeleted: [],
102
+ message: ''
103
+ };
104
+
105
+ try {
106
+ const storePath = await getSessionsStorePath(agentId);
107
+ const sessionsDir = await getSessionsDir(agentId);
108
+ let store = {};
109
+ let storeModified = false;
110
+
111
+ // Read the sessions store
112
+ try {
113
+ const storeContent = await fs.readFile(storePath, 'utf8');
114
+ store = JSON.parse(storeContent);
115
+ } catch (readErr) {
116
+ if (readErr.code === 'ENOENT') {
117
+ log('warn', `[Satellite] Sessions store not found at ${storePath}`);
118
+ } else {
119
+ throw readErr;
120
+ }
121
+ }
122
+
123
+ // Try to delete each possible session key format
124
+ for (const sessionKey of sessionKeysToTry) {
125
+ if (store[sessionKey]) {
126
+ const sessionEntry = store[sessionKey];
127
+
128
+ // Delete session entry
129
+ delete store[sessionKey];
130
+ storeModified = true;
131
+ result.sessionKeysDeleted.push(sessionKey);
132
+ log('info', `[Satellite] Removed session entry: ${sessionKey}`);
133
+
134
+ // Delete transcript file (validate sessionId to prevent path traversal)
135
+ if (sessionEntry.sessionId && /^[a-f0-9-]+$/i.test(sessionEntry.sessionId)) {
136
+ const transcriptPath = path.join(sessionsDir, `${sessionEntry.sessionId}.jsonl`);
137
+ // Verify the resolved path stays within sessionsDir
138
+ const resolvedPath = path.resolve(transcriptPath);
139
+ if (!resolvedPath.startsWith(path.resolve(sessionsDir))) {
140
+ log('warn', `[Satellite] Path traversal blocked: ${resolvedPath}`);
141
+ continue;
142
+ }
143
+ try {
144
+ await fs.unlink(transcriptPath);
145
+ result.transcriptsDeleted.push(sessionEntry.sessionId);
146
+ log('info', `[Satellite] Deleted transcript: ${transcriptPath}`);
147
+ } catch (unlinkErr) {
148
+ if (unlinkErr.code !== 'ENOENT') {
149
+ log('error', `[Satellite] Error deleting transcript: ${unlinkErr.message}`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // Write back the updated store if modified
157
+ if (storeModified) {
158
+ await fs.writeFile(storePath, JSON.stringify(store, null, 2));
159
+ }
160
+
161
+ // Build result message
162
+ const parts = [];
163
+ if (result.sessionKeysDeleted.length > 0) {
164
+ parts.push(`${result.sessionKeysDeleted.length} session(s) removed`);
165
+ }
166
+ if (result.transcriptsDeleted.length > 0) {
167
+ parts.push(`${result.transcriptsDeleted.length} transcript(s) deleted`);
168
+ }
169
+ if (parts.length === 0) {
170
+ parts.push('no OpenClaw data found');
171
+ }
172
+
173
+ result.message = `Satellite retired: ${parts.join(', ')}`;
174
+ result.sessionDeleted = result.sessionKeysDeleted.length > 0;
175
+ result.transcriptDeleted = result.transcriptsDeleted.length > 0;
176
+
177
+ res.json(result);
178
+
179
+ } catch (err) {
180
+ log('error', `[Satellite] Retire error: ${err.message}`);
181
+ // internalError now sanitizes automatically in production
182
+ internalError(res, 'Failed to retire satellite', ErrorCodes.INTERNAL_ERROR);
183
+ }
184
+ });
185
+
186
+ /**
187
+ * Get session history from Gateway by reading JSONL transcript files directly
188
+ *
189
+ * GET /api/gateway/history?satelliteId=main&limit=50
190
+ *
191
+ * Returns: { ok: boolean, messages: Array }
192
+ */
193
+ app.get('/api/gateway/history', async (req, res) => {
194
+ const satelliteId = sanitizeSatelliteId(req.query.satelliteId);
195
+ const agentId = req.query.agentId ? String(req.query.agentId).replace(/[^a-z0-9-]/g, '').substring(0, 64) || 'main' : 'main';
196
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
197
+
198
+ // Build session key to match channel.js logic
199
+ const sessionKey = satelliteId === 'main'
200
+ ? `agent:${agentId}:main` // Shared with Dashboard
201
+ : `agent:${agentId}:uplink:satellite:${satelliteId}`;
202
+
203
+ log('debug', `[Satellite] Fetching history for session: ${sessionKey}`);
204
+
205
+ try {
206
+ const storePath = await getSessionsStorePath(agentId);
207
+ const sessionsDir = await getSessionsDir(agentId);
208
+
209
+ // Read sessions store to find sessionId
210
+ let store = {};
211
+ try {
212
+ const storeContent = await fs.readFile(storePath, 'utf8');
213
+ store = JSON.parse(storeContent);
214
+ } catch (readErr) {
215
+ if (readErr.code === 'ENOENT') {
216
+ return res.json({ ok: true, sessionKey, messages: [] });
217
+ }
218
+ throw readErr;
219
+ }
220
+
221
+ const sessionEntry = store[sessionKey];
222
+ if (!sessionEntry || !sessionEntry.sessionId) {
223
+ return res.json({ ok: true, sessionKey, messages: [] });
224
+ }
225
+
226
+ // Validate sessionId format to prevent path traversal
227
+ if (!/^[a-f0-9-]+$/i.test(sessionEntry.sessionId)) {
228
+ log('warn', `[Satellite] Invalid sessionId format: ${sessionEntry.sessionId}`);
229
+ return res.json({ ok: true, sessionKey, messages: [] });
230
+ }
231
+
232
+ // Read transcript JSONL file
233
+ const transcriptPath = path.join(sessionsDir, `${sessionEntry.sessionId}.jsonl`);
234
+ // Verify the resolved path stays within sessionsDir
235
+ const resolvedTranscriptPath = path.resolve(transcriptPath);
236
+ if (!resolvedTranscriptPath.startsWith(path.resolve(sessionsDir))) {
237
+ log('warn', `[Satellite] Path traversal blocked in history: ${resolvedTranscriptPath}`);
238
+ return res.json({ ok: true, sessionKey, messages: [] });
239
+ }
240
+ let transcriptContent = '';
241
+ try {
242
+ transcriptContent = await fs.readFile(transcriptPath, 'utf8');
243
+ } catch (readErr) {
244
+ if (readErr.code === 'ENOENT') {
245
+ return res.json({ ok: true, sessionKey, messages: [] });
246
+ }
247
+ throw readErr;
248
+ }
249
+
250
+ // Parse JSONL and format messages
251
+ const messages = parseTranscriptForUplink(transcriptContent, limit);
252
+
253
+ res.json({
254
+ ok: true,
255
+ sessionKey,
256
+ messages,
257
+ sessionId: sessionEntry.sessionId
258
+ });
259
+
260
+ } catch (err) {
261
+ log('error', `[Satellite] History fetch error: ${err.message}`);
262
+ // internalError now sanitizes automatically in production
263
+ internalError(res, 'Failed to fetch history', ErrorCodes.INTERNAL_ERROR);
264
+ }
265
+ });
266
+
267
+ /**
268
+ * Parse JSONL transcript and format for Uplink
269
+ * JSONL format: {"type":"message","message":{"role":"user|assistant","content":...}}
270
+ */
271
+ function parseTranscriptForUplink(content, limit) {
272
+ const lines = content.trim().split('\n').filter(line => line.trim());
273
+ const messages = [];
274
+
275
+ for (const line of lines) {
276
+ try {
277
+ const entry = JSON.parse(line);
278
+
279
+ // OpenClaw JSONL wraps messages: {"type":"message","message":{...}}
280
+ const msg = entry.message || entry;
281
+
282
+ // Skip tool results and other non-chat messages
283
+ if (msg.role === 'toolResult' || msg.role === 'tool') {
284
+ continue;
285
+ }
286
+
287
+ // Extract text content from various message formats
288
+ let text = '';
289
+ if (typeof msg.content === 'string') {
290
+ text = msg.content;
291
+ } else if (Array.isArray(msg.content)) {
292
+ // Handle content blocks (text, thinking, toolCall)
293
+ for (const block of msg.content) {
294
+ if (block.type === 'text' && block.text) {
295
+ text += block.text + '\n';
296
+ }
297
+ }
298
+ text = text.trim();
299
+ }
300
+
301
+ // Skip empty messages, heartbeat prompts/acks
302
+ if (!text) continue;
303
+
304
+ // Filter heartbeat-related messages
305
+ const isHeartbeat =
306
+ text === 'HEARTBEAT_OK' ||
307
+ text.includes('HEARTBEAT_OK') ||
308
+ text.includes('Read HEARTBEAT.md') ||
309
+ text.includes('If nothing needs attention, reply HEARTBEAT_OK');
310
+ if (isHeartbeat) continue;
311
+
312
+ if (msg.role) {
313
+ messages.push({
314
+ text,
315
+ type: msg.role === 'user' ? 'user' : 'assistant',
316
+ timestamp: msg.timestamp ? new Date(msg.timestamp).getTime() :
317
+ entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
318
+ fromGateway: true
319
+ });
320
+ }
321
+ } catch {
322
+ // Skip unparseable lines
323
+ }
324
+ }
325
+
326
+ // Return last N messages (most recent)
327
+ return messages.slice(-limit);
328
+ }
329
+
330
+ /**
331
+ * List all Uplink sessions across all agents.
332
+ * Scans every agent's sessions.json for keys containing `:uplink:satellite:`.
333
+ * Returns rich metadata so clients can render satellites without local state.
334
+ *
335
+ * GET /api/satellite/sessions
336
+ *
337
+ * Returns: { ok: boolean, sessions: Array<{
338
+ * sessionKey, satelliteId, agentId, agentName, updatedAt, model, totalTokens
339
+ * }> }
340
+ */
341
+ app.get('/api/satellite/sessions', async (req, res) => {
342
+ try {
343
+ const stateDir = await getOpenClawStateDir();
344
+ const agentsDir = path.join(stateDir, 'agents');
345
+
346
+ let agentDirs = [];
347
+ try {
348
+ agentDirs = await fs.readdir(agentsDir, { withFileTypes: true });
349
+ } catch (readErr) {
350
+ if (readErr.code === 'ENOENT') {
351
+ return res.json({ ok: true, sessions: [] });
352
+ }
353
+ throw readErr;
354
+ }
355
+
356
+ const sessions = [];
357
+
358
+ for (const entry of agentDirs) {
359
+ if (!entry.isDirectory()) continue;
360
+
361
+ const agentId = entry.name;
362
+
363
+ // Skip test agents
364
+ if (agentId.startsWith('test-')) continue;
365
+ const storePath = path.join(agentsDir, agentId, 'sessions', 'sessions.json');
366
+
367
+ let store;
368
+ try {
369
+ const content = await fs.readFile(storePath, 'utf8');
370
+ store = JSON.parse(content);
371
+ } catch {
372
+ continue; // No sessions file for this agent
373
+ }
374
+
375
+ for (const [key, sessionEntry] of Object.entries(store)) {
376
+ // Match keys like agent:{agentId}:uplink:satellite:{satId}
377
+ if (!key.includes(':uplink:satellite:')) continue;
378
+
379
+ // Parse the key to extract satelliteId
380
+ const satMatch = key.match(/:uplink:satellite:(.+)$/);
381
+ if (!satMatch) continue;
382
+
383
+ const satelliteId = satMatch[1];
384
+
385
+ sessions.push({
386
+ sessionKey: key,
387
+ satelliteId,
388
+ agentId,
389
+ updatedAt: sessionEntry.updatedAt || 0,
390
+ model: sessionEntry.model || 'unknown',
391
+ totalTokens: sessionEntry.totalTokens || 0
392
+ });
393
+ }
394
+ }
395
+
396
+ // Sort by most recently active first
397
+ sessions.sort((a, b) => b.updatedAt - a.updatedAt);
398
+
399
+ res.json({ ok: true, sessions });
400
+
401
+ } catch (err) {
402
+ log('error', `[Satellite] List sessions error: ${err.message}`);
403
+ internalError(res, 'Failed to list sessions', ErrorCodes.INTERNAL_ERROR);
404
+ }
405
+ });
406
+ }