@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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
|
+
}
|