clawconnect-agent 0.1.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.
- package/.env.example +41 -0
- package/README.md +259 -0
- package/README.zh-CN.md +329 -0
- package/dist/commands/backup-manager.d.ts +21 -0
- package/dist/commands/backup-manager.js +346 -0
- package/dist/commands/backup-manager.js.map +1 -0
- package/dist/commands/install.d.ts +6 -0
- package/dist/commands/install.js +107 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/local-handlers.d.ts +2 -0
- package/dist/commands/local-handlers.js +375 -0
- package/dist/commands/local-handlers.js.map +1 -0
- package/dist/commands/local-runtime.d.ts +36 -0
- package/dist/commands/local-runtime.js +445 -0
- package/dist/commands/local-runtime.js.map +1 -0
- package/dist/commands/pair.d.ts +7 -0
- package/dist/commands/pair.js +100 -0
- package/dist/commands/pair.js.map +1 -0
- package/dist/commands/provider-config.d.ts +35 -0
- package/dist/commands/provider-config.js +291 -0
- package/dist/commands/provider-config.js.map +1 -0
- package/dist/commands/provider-handlers.d.ts +2 -0
- package/dist/commands/provider-handlers.js +216 -0
- package/dist/commands/provider-handlers.js.map +1 -0
- package/dist/commands/provider-registry.d.ts +19 -0
- package/dist/commands/provider-registry.js +120 -0
- package/dist/commands/provider-registry.js.map +1 -0
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +35 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/send-file-utils.d.ts +6 -0
- package/dist/commands/send-file-utils.js +94 -0
- package/dist/commands/send-file-utils.js.map +1 -0
- package/dist/commands/send-file.d.ts +41 -0
- package/dist/commands/send-file.js +492 -0
- package/dist/commands/send-file.js.map +1 -0
- package/dist/commands/set-token.d.ts +1 -0
- package/dist/commands/set-token.js +43 -0
- package/dist/commands/set-token.js.map +1 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +135 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/voice-reply.d.ts +39 -0
- package/dist/commands/voice-reply.js +329 -0
- package/dist/commands/voice-reply.js.map +1 -0
- package/dist/config/config.d.ts +32 -0
- package/dist/config/config.js +103 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/env.d.ts +16 -0
- package/dist/config/env.js +138 -0
- package/dist/config/env.js.map +1 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +147 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/service-manager-common.d.ts +26 -0
- package/dist/platform/service-manager-common.js +51 -0
- package/dist/platform/service-manager-common.js.map +1 -0
- package/dist/platform/service-manager-linux.d.ts +6 -0
- package/dist/platform/service-manager-linux.js +229 -0
- package/dist/platform/service-manager-linux.js.map +1 -0
- package/dist/platform/service-manager.d.ts +16 -0
- package/dist/platform/service-manager.js +165 -0
- package/dist/platform/service-manager.js.map +1 -0
- package/dist/relay/attachment-staging.d.ts +12 -0
- package/dist/relay/attachment-staging.js +116 -0
- package/dist/relay/attachment-staging.js.map +1 -0
- package/dist/relay/chat-history.d.ts +29 -0
- package/dist/relay/chat-history.js +69 -0
- package/dist/relay/chat-history.js.map +1 -0
- package/dist/relay/chat-payload.d.ts +6 -0
- package/dist/relay/chat-payload.js +170 -0
- package/dist/relay/chat-payload.js.map +1 -0
- package/dist/relay/chat-send-attachments.d.ts +4 -0
- package/dist/relay/chat-send-attachments.js +47 -0
- package/dist/relay/chat-send-attachments.js.map +1 -0
- package/dist/relay/gateway-client.d.ts +41 -0
- package/dist/relay/gateway-client.js +288 -0
- package/dist/relay/gateway-client.js.map +1 -0
- package/dist/relay/office-payload.d.ts +19 -0
- package/dist/relay/office-payload.js +205 -0
- package/dist/relay/office-payload.js.map +1 -0
- package/dist/relay/reconnect.d.ts +14 -0
- package/dist/relay/reconnect.js +27 -0
- package/dist/relay/reconnect.js.map +1 -0
- package/dist/relay/relay-manager.d.ts +36 -0
- package/dist/relay/relay-manager.js +619 -0
- package/dist/relay/relay-manager.js.map +1 -0
- package/dist/relay/session-context.d.ts +19 -0
- package/dist/relay/session-context.js +323 -0
- package/dist/relay/session-context.js.map +1 -0
- package/dist/relay/slash-command-catalog.d.ts +8 -0
- package/dist/relay/slash-command-catalog.generated.d.ts +226 -0
- package/dist/relay/slash-command-catalog.generated.js +275 -0
- package/dist/relay/slash-command-catalog.generated.js.map +1 -0
- package/dist/relay/slash-command-catalog.js +3 -0
- package/dist/relay/slash-command-catalog.js.map +1 -0
- package/dist/relay/voice-reply-preference.d.ts +25 -0
- package/dist/relay/voice-reply-preference.js +103 -0
- package/dist/relay/voice-reply-preference.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { OpenClawGatewayClient } from "./gateway-client.js";
|
|
3
|
+
import { handleLocalCommand } from "../commands/local-handlers.js";
|
|
4
|
+
import { handleProviderCommand } from "../commands/provider-handlers.js";
|
|
5
|
+
import { readConfig, updateVoiceReplyConfig } from "../config/config.js";
|
|
6
|
+
import { DEFAULT_GATEWAY_SESSION_DEFAULTS, buildContextUsageFingerprint, readContextUsageSnapshot, canonicalizeRelayParams, canonicalizeSessionKey, extractGatewaySessionDefaults, } from "./session-context.js";
|
|
7
|
+
import { VoiceReplyPreferenceStore } from "./voice-reply-preference.js";
|
|
8
|
+
import { appendUniqueSuffix, extractChatRole, extractChatText, normalizeChatEventPayload, normalizeChatState, withMessageText, } from "./chat-payload.js";
|
|
9
|
+
import { extractHistoryOutcome, withTimeout } from "./chat-history.js";
|
|
10
|
+
import { buildOfficeEventPayload } from "./office-payload.js";
|
|
11
|
+
import { prepareChatSendParams } from "./chat-send-attachments.js";
|
|
12
|
+
import { sendVoiceReplyCommand } from "../commands/voice-reply.js";
|
|
13
|
+
import { OPENCLAW_SLASH_COMMAND_CATALOG, } from "./slash-command-catalog.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Main entry point
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Connects to the cloud relay server and the local OpenClaw Gateway,
|
|
19
|
+
* then bridges messages between them indefinitely.
|
|
20
|
+
*
|
|
21
|
+
* The gateway client runs for as long as this relay connection is alive.
|
|
22
|
+
* Returns a Promise that resolves `true` (retry) when the relay server
|
|
23
|
+
* connection closes.
|
|
24
|
+
*/
|
|
25
|
+
export async function runRelayManager(opts) {
|
|
26
|
+
const wsUrl = buildRelayUrl(opts.relayServerUrl, opts.gatewayId, opts.relaySecret);
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let relayWs;
|
|
29
|
+
try {
|
|
30
|
+
relayWs = new WebSocket(wsUrl);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error("Failed to create relay WebSocket:", err);
|
|
34
|
+
resolve(true);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let gatewayClient = null;
|
|
38
|
+
let sessionDefaults = { ...DEFAULT_GATEWAY_SESSION_DEFAULTS };
|
|
39
|
+
const chatBuffers = new Map();
|
|
40
|
+
const chatFallbacks = new Map();
|
|
41
|
+
const chatRunContexts = new Map();
|
|
42
|
+
const contextUsageRefreshes = new Map();
|
|
43
|
+
const contextUsageFingerprints = new Map();
|
|
44
|
+
const voiceReplyPreferences = new VoiceReplyPreferenceStore(opts.defaultVoiceReplyEnabled ?? false, sessionDefaults);
|
|
45
|
+
voiceReplyPreferences.setDefaultVoiceReplySettings(opts.defaultVoiceReplyConfig);
|
|
46
|
+
const clearChatFallback = (runId) => {
|
|
47
|
+
const timer = chatFallbacks.get(runId);
|
|
48
|
+
if (timer) {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
chatFallbacks.delete(runId);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const publishContextUsageSnapshot = async (sessionKey, force = false) => {
|
|
54
|
+
const normalizedSessionKey = canonicalizeSessionKey(sessionKey, sessionDefaults);
|
|
55
|
+
if (typeof normalizedSessionKey !== "string" || normalizedSessionKey.trim().length === 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const snapshot = await readContextUsageSnapshot(normalizedSessionKey.trim(), sessionDefaults);
|
|
59
|
+
if (!snapshot) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const fingerprint = buildContextUsageFingerprint(snapshot);
|
|
63
|
+
if (!force && contextUsageFingerprints.get(snapshot.sessionKey) === fingerprint) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
contextUsageFingerprints.set(snapshot.sessionKey, fingerprint);
|
|
67
|
+
send({
|
|
68
|
+
type: "event",
|
|
69
|
+
event: "context_usage",
|
|
70
|
+
payload: {
|
|
71
|
+
sessionKey: snapshot.sessionKey,
|
|
72
|
+
currentModel: snapshot.currentModel,
|
|
73
|
+
contextUsage: snapshot.promptTokens ?? snapshot.contextUsage,
|
|
74
|
+
contextLimit: snapshot.contextLimit,
|
|
75
|
+
promptTokens: snapshot.promptTokens,
|
|
76
|
+
maxInputTokens: snapshot.contextLimit,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const scheduleContextUsageRefresh = (sessionKey, delayMs = 250, force = false) => {
|
|
81
|
+
if (!sessionKey) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const normalizedSessionKey = canonicalizeSessionKey(sessionKey, sessionDefaults);
|
|
85
|
+
if (typeof normalizedSessionKey !== "string" || normalizedSessionKey.trim().length === 0) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const key = normalizedSessionKey.trim();
|
|
89
|
+
const existing = contextUsageRefreshes.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
clearTimeout(existing);
|
|
92
|
+
}
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
contextUsageRefreshes.delete(key);
|
|
95
|
+
void publishContextUsageSnapshot(key, force).catch((error) => {
|
|
96
|
+
console.warn(`[relay] failed to publish context usage for session ${key}: ${String(error)}`);
|
|
97
|
+
});
|
|
98
|
+
}, delayMs);
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
contextUsageRefreshes.set(key, timer);
|
|
101
|
+
};
|
|
102
|
+
const scheduleChatHistoryFallback = (runId, context, attempt = 0) => {
|
|
103
|
+
if (!runId || !context.sessionKey) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
clearChatFallback(runId);
|
|
107
|
+
chatRunContexts.set(runId, context);
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
if (!gatewayClient) {
|
|
110
|
+
chatFallbacks.delete(runId);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const fetchHistory = () => gatewayClient.request("chat.history", { sessionKey: context.sessionKey, limit: 10 });
|
|
114
|
+
withTimeout(fetchHistory(), 800, "chat.history fallback")
|
|
115
|
+
.then(async (history) => {
|
|
116
|
+
const outcome = extractHistoryOutcome(history, context);
|
|
117
|
+
if (!outcome && attempt < 4) {
|
|
118
|
+
scheduleChatHistoryFallback(runId, context, attempt + 1);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!outcome) {
|
|
122
|
+
chatFallbacks.delete(runId);
|
|
123
|
+
chatRunContexts.delete(runId);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
clearChatFallback(runId);
|
|
127
|
+
chatRunContexts.delete(runId);
|
|
128
|
+
if (outcome.kind === "final") {
|
|
129
|
+
if (shouldUseVoiceReply(runId, context.sessionKey)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
send({
|
|
133
|
+
type: "event",
|
|
134
|
+
event: "chat",
|
|
135
|
+
payload: {
|
|
136
|
+
runId,
|
|
137
|
+
sessionKey: context.sessionKey,
|
|
138
|
+
state: "final",
|
|
139
|
+
role: "assistant",
|
|
140
|
+
message: {
|
|
141
|
+
role: "assistant",
|
|
142
|
+
content: [{ type: "text", text: outcome.text }],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
send({
|
|
149
|
+
type: "event",
|
|
150
|
+
event: "chat",
|
|
151
|
+
payload: {
|
|
152
|
+
runId,
|
|
153
|
+
sessionKey: context.sessionKey,
|
|
154
|
+
state: "error",
|
|
155
|
+
role: "assistant",
|
|
156
|
+
errorMessage: outcome.errorMessage,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
})
|
|
160
|
+
.catch((err) => {
|
|
161
|
+
if (attempt < 4) {
|
|
162
|
+
scheduleChatHistoryFallback(runId, context, attempt + 1);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
console.warn(`[relay] chat history fallback failed runId=${runId}: ${String(err)}`);
|
|
166
|
+
chatFallbacks.delete(runId);
|
|
167
|
+
chatRunContexts.delete(runId);
|
|
168
|
+
});
|
|
169
|
+
}, attempt === 0 ? 1500 : 2000);
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
chatFallbacks.set(runId, timer);
|
|
172
|
+
};
|
|
173
|
+
const refreshSessionDefaults = async () => {
|
|
174
|
+
if (!gatewayClient) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const payload = await gatewayClient.request("config.get", {});
|
|
179
|
+
const nextDefaults = extractGatewaySessionDefaults(payload);
|
|
180
|
+
if (nextDefaults) {
|
|
181
|
+
sessionDefaults = nextDefaults;
|
|
182
|
+
voiceReplyPreferences.setSessionDefaults(sessionDefaults);
|
|
183
|
+
}
|
|
184
|
+
scheduleContextUsageRefresh(sessionDefaults.mainSessionKey, 50, true);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.warn(`[relay] failed to load session defaults: ${String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
function send(msg) {
|
|
191
|
+
if (relayWs.readyState === WebSocket.OPEN) {
|
|
192
|
+
relayWs.send(JSON.stringify(msg));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function publishOfficeSnapshot(eventName, payload) {
|
|
196
|
+
const officePayload = buildOfficeEventPayload(eventName, payload, () => new Date().toISOString());
|
|
197
|
+
if (!officePayload) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
send({ type: "event", event: "office", payload: officePayload });
|
|
201
|
+
}
|
|
202
|
+
function queueVoiceReply(runId, sessionKey, text) {
|
|
203
|
+
const normalizedText = text.trim();
|
|
204
|
+
if (!normalizedText) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
console.log(`[relay] queueing voice reply sessionKey=${sessionKey} textLength=${normalizedText.length}`);
|
|
208
|
+
const resolvedSettings = voiceReplyPreferences.resolveSettings(runId, sessionKey);
|
|
209
|
+
const resolvedRate = typeof resolvedSettings?.ratePercent === "number"
|
|
210
|
+
? `${resolvedSettings.ratePercent > 0 ? "+" : ""}${resolvedSettings.ratePercent}%`
|
|
211
|
+
: undefined;
|
|
212
|
+
void sendVoiceReplyCommand({
|
|
213
|
+
text: normalizedText,
|
|
214
|
+
gateway: opts.gatewayId,
|
|
215
|
+
session: sessionKey,
|
|
216
|
+
voice: resolvedSettings?.voiceIdentifier ?? (process.env.OPENCLAW_TTS_VOICE?.trim() || undefined),
|
|
217
|
+
rate: resolvedRate ?? (process.env.OPENCLAW_TTS_RATE?.trim() || undefined),
|
|
218
|
+
speaker: process.env.OPENCLAW_TTS_ENGINE?.trim() || undefined,
|
|
219
|
+
}).catch((error) => {
|
|
220
|
+
console.warn(`[relay] voice reply generation failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function shouldUseVoiceReply(runId, sessionKey) {
|
|
224
|
+
return voiceReplyPreferences.shouldUse(runId, sessionKey);
|
|
225
|
+
}
|
|
226
|
+
function shouldSendTextReply(runId, sessionKey) {
|
|
227
|
+
return !shouldUseVoiceReply(runId, sessionKey);
|
|
228
|
+
}
|
|
229
|
+
relayWs.on("open", () => {
|
|
230
|
+
console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
|
|
231
|
+
opts.onConnected?.();
|
|
232
|
+
send(buildRelayHelloMessage({
|
|
233
|
+
platform: process.platform,
|
|
234
|
+
agentVersion: "1.0.0",
|
|
235
|
+
capabilities: ["chat", "skills", "schedules", "logs", "files"],
|
|
236
|
+
}));
|
|
237
|
+
// Start the persistent gateway connection as soon as we're connected
|
|
238
|
+
// to the relay server. Its lifetime is tied to this relay session.
|
|
239
|
+
gatewayClient = new OpenClawGatewayClient({
|
|
240
|
+
url: opts.gatewayUrl,
|
|
241
|
+
token: opts.gatewayToken,
|
|
242
|
+
password: opts.gatewayPassword,
|
|
243
|
+
onConnected: () => {
|
|
244
|
+
console.log("Gateway connected.");
|
|
245
|
+
send({ type: "gateway_connected" });
|
|
246
|
+
publishOfficeSnapshot("gateway_connected", {
|
|
247
|
+
sessionKey: sessionDefaults.mainSessionKey,
|
|
248
|
+
});
|
|
249
|
+
void refreshSessionDefaults();
|
|
250
|
+
},
|
|
251
|
+
onDisconnected: (reason) => {
|
|
252
|
+
console.log(`Gateway disconnected: ${reason}`);
|
|
253
|
+
send({ type: "gateway_disconnected", reason });
|
|
254
|
+
publishOfficeSnapshot("gateway_disconnected", {
|
|
255
|
+
reason,
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
onEvent: (event, payload) => {
|
|
259
|
+
const normalizedPayload = event === "chat" ? normalizeChatEventPayload(payload) : payload;
|
|
260
|
+
const shouldPublishOffice = event === "chat" || event === "agent" || event === "context_usage";
|
|
261
|
+
if (event === "chat") {
|
|
262
|
+
const p = normalizedPayload;
|
|
263
|
+
const state = normalizeChatState(normalizedPayload);
|
|
264
|
+
const runId = typeof p?.runId === "string" ? p.runId : "";
|
|
265
|
+
const sessionKey = typeof p?.sessionKey === "string" && p.sessionKey.trim().length > 0 ? p.sessionKey.trim() : undefined;
|
|
266
|
+
const runContext = runId ? chatRunContexts.get(runId) : undefined;
|
|
267
|
+
const resolvedSessionKey = sessionKey ?? runContext?.sessionKey;
|
|
268
|
+
const currentText = extractChatText(normalizedPayload);
|
|
269
|
+
const role = extractChatRole(normalizedPayload);
|
|
270
|
+
if (runId) {
|
|
271
|
+
if (role === "assistant" && (state === "delta" || state === "final" || state === "error" || state === "failed" || state === "fail")) {
|
|
272
|
+
clearChatFallback(runId);
|
|
273
|
+
}
|
|
274
|
+
if (state === "delta" || state === "streaming" || state === "in_progress") {
|
|
275
|
+
const previousText = chatBuffers.get(runId) ?? "";
|
|
276
|
+
chatBuffers.set(runId, appendUniqueSuffix(previousText, currentText));
|
|
277
|
+
}
|
|
278
|
+
else if (state === "error" || state === "failed" || state === "fail" || state === "aborted") {
|
|
279
|
+
chatBuffers.delete(runId);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (state === "final" && resolvedSessionKey) {
|
|
283
|
+
scheduleContextUsageRefresh(resolvedSessionKey, 450);
|
|
284
|
+
const bufferedText = runId ? chatBuffers.get(runId) ?? "" : "";
|
|
285
|
+
const resolvedText = currentText || bufferedText;
|
|
286
|
+
if (runId) {
|
|
287
|
+
chatBuffers.delete(runId);
|
|
288
|
+
}
|
|
289
|
+
if (resolvedText.trim()) {
|
|
290
|
+
if (runId) {
|
|
291
|
+
chatRunContexts.delete(runId);
|
|
292
|
+
}
|
|
293
|
+
const outgoingPayload = withMessageText(normalizedPayload, resolvedText);
|
|
294
|
+
if (shouldPublishOffice) {
|
|
295
|
+
publishOfficeSnapshot(event, outgoingPayload);
|
|
296
|
+
}
|
|
297
|
+
if (shouldSendTextReply(runId, resolvedSessionKey)) {
|
|
298
|
+
send({ type: "event", event, payload: outgoingPayload });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
queueVoiceReply(runId, resolvedSessionKey, resolvedText);
|
|
302
|
+
}
|
|
303
|
+
if (runId) {
|
|
304
|
+
voiceReplyPreferences.clearRun(runId);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const fetchHistory = () => gatewayClient.request("chat.history", { sessionKey: resolvedSessionKey, limit: 10 });
|
|
309
|
+
withTimeout(fetchHistory(), 500, "chat.history")
|
|
310
|
+
.then(async (history) => {
|
|
311
|
+
let outcome = runContext ? extractHistoryOutcome(history, runContext) : null;
|
|
312
|
+
// Retry once after a short delay if OpenClaw hasn't committed the message yet.
|
|
313
|
+
if (!outcome) {
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
315
|
+
const retryHistory = await withTimeout(fetchHistory(), 500, "chat.history retry");
|
|
316
|
+
outcome = runContext ? extractHistoryOutcome(retryHistory, runContext) : null;
|
|
317
|
+
}
|
|
318
|
+
if (runId) {
|
|
319
|
+
chatRunContexts.delete(runId);
|
|
320
|
+
}
|
|
321
|
+
if (outcome?.kind === "final") {
|
|
322
|
+
const outgoingPayload = withMessageText(normalizedPayload, outcome.text);
|
|
323
|
+
if (shouldPublishOffice) {
|
|
324
|
+
publishOfficeSnapshot(event, outgoingPayload);
|
|
325
|
+
}
|
|
326
|
+
if (shouldSendTextReply(runId, resolvedSessionKey)) {
|
|
327
|
+
send({ type: "event", event, payload: outgoingPayload });
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
queueVoiceReply(runId, resolvedSessionKey, outcome.text);
|
|
331
|
+
}
|
|
332
|
+
if (runId) {
|
|
333
|
+
voiceReplyPreferences.clearRun(runId);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (outcome?.kind === "error") {
|
|
338
|
+
const outgoingPayload = {
|
|
339
|
+
...normalizedPayload,
|
|
340
|
+
state: "error",
|
|
341
|
+
errorMessage: outcome.errorMessage,
|
|
342
|
+
};
|
|
343
|
+
if (shouldPublishOffice) {
|
|
344
|
+
publishOfficeSnapshot(event, outgoingPayload);
|
|
345
|
+
}
|
|
346
|
+
send({
|
|
347
|
+
type: "event",
|
|
348
|
+
event,
|
|
349
|
+
payload: outgoingPayload,
|
|
350
|
+
});
|
|
351
|
+
if (runId) {
|
|
352
|
+
voiceReplyPreferences.clearRun(runId);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (shouldPublishOffice) {
|
|
357
|
+
publishOfficeSnapshot(event, normalizedPayload);
|
|
358
|
+
}
|
|
359
|
+
send({ type: "event", event, payload: normalizedPayload });
|
|
360
|
+
if (runId) {
|
|
361
|
+
voiceReplyPreferences.clearRun(runId);
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
.catch((err) => {
|
|
365
|
+
console.error(`[relay] chat.history fetch failed: ${err}`);
|
|
366
|
+
if (runId) {
|
|
367
|
+
chatRunContexts.delete(runId);
|
|
368
|
+
voiceReplyPreferences.clearRun(runId);
|
|
369
|
+
}
|
|
370
|
+
if (shouldPublishOffice) {
|
|
371
|
+
publishOfficeSnapshot(event, normalizedPayload);
|
|
372
|
+
}
|
|
373
|
+
send({ type: "event", event, payload: normalizedPayload });
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (runId && (state === "error" || state === "failed" || state === "fail" || state === "aborted")) {
|
|
378
|
+
chatRunContexts.delete(runId);
|
|
379
|
+
scheduleContextUsageRefresh(p?.sessionKey, 450);
|
|
380
|
+
}
|
|
381
|
+
const shouldSuppressVoiceReplyText = role === "assistant"
|
|
382
|
+
&& shouldUseVoiceReply(runId, resolvedSessionKey)
|
|
383
|
+
&& state !== "error"
|
|
384
|
+
&& state !== "failed"
|
|
385
|
+
&& state !== "fail"
|
|
386
|
+
&& state !== "aborted";
|
|
387
|
+
if (shouldSuppressVoiceReplyText) {
|
|
388
|
+
if (shouldPublishOffice) {
|
|
389
|
+
publishOfficeSnapshot(event, normalizedPayload);
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (shouldPublishOffice) {
|
|
394
|
+
publishOfficeSnapshot(event, normalizedPayload);
|
|
395
|
+
}
|
|
396
|
+
send({ type: "event", event, payload: normalizedPayload });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
gatewayClient.start();
|
|
402
|
+
});
|
|
403
|
+
relayWs.on("message", async (raw) => {
|
|
404
|
+
let requestId;
|
|
405
|
+
let methodForLog;
|
|
406
|
+
try {
|
|
407
|
+
let msg;
|
|
408
|
+
try {
|
|
409
|
+
msg = JSON.parse(raw.toString());
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (msg.type === "heartbeat") {
|
|
415
|
+
send({ type: "heartbeat" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (msg.type === "hello") {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (msg.type !== "cmd" || !msg.method)
|
|
422
|
+
return;
|
|
423
|
+
requestId = msg.id;
|
|
424
|
+
methodForLog = msg.method;
|
|
425
|
+
// Handle clawpilot.provider.* commands locally (async)
|
|
426
|
+
const providerPromise = handleProviderCommand(msg.method, msg.params);
|
|
427
|
+
if (providerPromise !== null) {
|
|
428
|
+
const result = await providerPromise;
|
|
429
|
+
if (requestId) {
|
|
430
|
+
send({
|
|
431
|
+
type: "res",
|
|
432
|
+
id: requestId,
|
|
433
|
+
ok: result.ok,
|
|
434
|
+
...(result.ok
|
|
435
|
+
? { payload: result.payload }
|
|
436
|
+
: { error: { message: result.error } }),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// Handle local commands without forwarding to the gateway.
|
|
442
|
+
const localResult = await handleLocalCommand(msg.method, msg.params, {
|
|
443
|
+
requestId,
|
|
444
|
+
gatewayId: opts.gatewayId,
|
|
445
|
+
publishEvent: (event) => send(event),
|
|
446
|
+
});
|
|
447
|
+
if (localResult !== null) {
|
|
448
|
+
if (localResult.ok && isVoiceReplyConfigCommand(msg.method)) {
|
|
449
|
+
const payload = localResult.payload;
|
|
450
|
+
voiceReplyPreferences.setDefaultVoiceReplySettings({
|
|
451
|
+
voiceIdentifier: typeof payload?.assistantVoiceReplyVoiceIdentifier === "string"
|
|
452
|
+
? payload.assistantVoiceReplyVoiceIdentifier
|
|
453
|
+
: undefined,
|
|
454
|
+
ratePercent: typeof payload?.assistantVoiceReplyRatePercent === "number"
|
|
455
|
+
? payload.assistantVoiceReplyRatePercent
|
|
456
|
+
: undefined,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (requestId) {
|
|
460
|
+
if (localResult.ok) {
|
|
461
|
+
send({ type: "res", id: requestId, ok: true, payload: localResult.payload });
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
send({ type: "res", id: requestId, ok: false, error: { message: localResult.error } });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (msg.method === "chat.send") {
|
|
470
|
+
msg.params = await prepareChatSendParams(msg.params);
|
|
471
|
+
}
|
|
472
|
+
const params = canonicalizeRelayParams(msg.method, msg.params, sessionDefaults);
|
|
473
|
+
const paramsRecord = params && typeof params === "object" && !Array.isArray(params)
|
|
474
|
+
? params
|
|
475
|
+
: undefined;
|
|
476
|
+
const voiceReplyEnabled = typeof msg.voiceReplyEnabled === "boolean" ? msg.voiceReplyEnabled : undefined;
|
|
477
|
+
const voiceReplyVoiceIdentifier = typeof msg.voiceReplyVoiceIdentifier === "string" && msg.voiceReplyVoiceIdentifier.trim().length > 0
|
|
478
|
+
? msg.voiceReplyVoiceIdentifier.trim()
|
|
479
|
+
: undefined;
|
|
480
|
+
const voiceReplyRatePercent = typeof msg.voiceReplyRatePercent === "number" && Number.isFinite(msg.voiceReplyRatePercent)
|
|
481
|
+
? Math.max(-50, Math.min(50, Math.round(msg.voiceReplyRatePercent)))
|
|
482
|
+
: undefined;
|
|
483
|
+
const requestSessionKey = paramsRecord && typeof paramsRecord.sessionKey === "string" && paramsRecord.sessionKey.trim().length > 0
|
|
484
|
+
? paramsRecord.sessionKey.trim()
|
|
485
|
+
: undefined;
|
|
486
|
+
if (requestId && typeof voiceReplyEnabled === "boolean" && (msg.method === "chat.send" || msg.method === "agent")) {
|
|
487
|
+
voiceReplyPreferences.register({
|
|
488
|
+
runId: requestId,
|
|
489
|
+
sessionKey: requestSessionKey,
|
|
490
|
+
enabled: voiceReplyEnabled,
|
|
491
|
+
voiceIdentifier: voiceReplyVoiceIdentifier,
|
|
492
|
+
ratePercent: voiceReplyRatePercent,
|
|
493
|
+
});
|
|
494
|
+
if (voiceReplyVoiceIdentifier !== undefined || voiceReplyRatePercent !== undefined) {
|
|
495
|
+
try {
|
|
496
|
+
const config = readConfig();
|
|
497
|
+
const updatedConfig = updateVoiceReplyConfig(config, {
|
|
498
|
+
voiceIdentifier: voiceReplyVoiceIdentifier,
|
|
499
|
+
ratePercent: voiceReplyRatePercent,
|
|
500
|
+
});
|
|
501
|
+
voiceReplyPreferences.setDefaultVoiceReplySettings({
|
|
502
|
+
voiceIdentifier: updatedConfig.assistantVoiceReplyVoiceIdentifier,
|
|
503
|
+
ratePercent: updatedConfig.assistantVoiceReplyRatePercent,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
console.warn(`[relay] failed to persist voice reply settings: ${String(error)}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!gatewayClient) {
|
|
512
|
+
throw new Error("gateway not connected");
|
|
513
|
+
}
|
|
514
|
+
gatewayClient
|
|
515
|
+
.request(msg.method, params)
|
|
516
|
+
.then((result) => {
|
|
517
|
+
let resolvedRunId;
|
|
518
|
+
if ((msg.method === "chat.send" || msg.method === "agent") && params && typeof params === "object" && !Array.isArray(params)) {
|
|
519
|
+
const paramsRecord = params;
|
|
520
|
+
const sessionKey = typeof paramsRecord.sessionKey === "string" && paramsRecord.sessionKey.trim().length > 0
|
|
521
|
+
? paramsRecord.sessionKey.trim()
|
|
522
|
+
: sessionDefaults.mainSessionKey;
|
|
523
|
+
const resultRecord = result && typeof result === "object" && !Array.isArray(result)
|
|
524
|
+
? result
|
|
525
|
+
: undefined;
|
|
526
|
+
const runId = typeof resultRecord?.runId === "string" && resultRecord.runId.trim().length > 0
|
|
527
|
+
? resultRecord.runId.trim()
|
|
528
|
+
: requestId;
|
|
529
|
+
resolvedRunId = runId;
|
|
530
|
+
if (runId && typeof voiceReplyEnabled === "boolean") {
|
|
531
|
+
voiceReplyPreferences.register({
|
|
532
|
+
runId,
|
|
533
|
+
sessionKey,
|
|
534
|
+
enabled: voiceReplyEnabled,
|
|
535
|
+
voiceIdentifier: voiceReplyVoiceIdentifier,
|
|
536
|
+
ratePercent: voiceReplyRatePercent,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (runId) {
|
|
540
|
+
const promptText = typeof paramsRecord.message === "string" && paramsRecord.message.trim().length > 0
|
|
541
|
+
? paramsRecord.message.trim()
|
|
542
|
+
: undefined;
|
|
543
|
+
const runContext = {
|
|
544
|
+
sessionKey,
|
|
545
|
+
requestedAtMs: Date.now(),
|
|
546
|
+
promptText,
|
|
547
|
+
};
|
|
548
|
+
scheduleChatHistoryFallback(runId, runContext);
|
|
549
|
+
}
|
|
550
|
+
// Let a model switch settle before publishing context usage again.
|
|
551
|
+
// Forced refreshes can replay a stale model snapshot and overwrite the new selection.
|
|
552
|
+
scheduleContextUsageRefresh(sessionKey, 1200);
|
|
553
|
+
}
|
|
554
|
+
if (requestId) {
|
|
555
|
+
if (resolvedRunId && resolvedRunId !== requestId) {
|
|
556
|
+
voiceReplyPreferences.clearRun(requestId);
|
|
557
|
+
}
|
|
558
|
+
send({ type: "res", id: requestId, ok: true, payload: result });
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
.catch((err) => {
|
|
562
|
+
console.error(`[relay] cmd failed method=${msg.method} id=${requestId ?? "(no-id)"}: ${String(err)}`);
|
|
563
|
+
if (requestId) {
|
|
564
|
+
voiceReplyPreferences.clearRun(requestId);
|
|
565
|
+
send({ type: "res", id: requestId, ok: false, error: { message: String(err) } });
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
571
|
+
console.error(`[relay] cmd failed method=${methodForLog ?? "(unknown)"} id=${requestId ?? "(no-id)"}: ${message}`);
|
|
572
|
+
if (requestId) {
|
|
573
|
+
voiceReplyPreferences.clearRun(requestId);
|
|
574
|
+
send({ type: "res", id: requestId, ok: false, error: { message } });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
relayWs.on("close", (code, reason) => {
|
|
579
|
+
console.log(`Relay connection closed: ${code} ${reason.toString()}`);
|
|
580
|
+
opts.onDisconnected?.();
|
|
581
|
+
gatewayClient?.stop();
|
|
582
|
+
gatewayClient = null;
|
|
583
|
+
for (const timer of chatFallbacks.values()) {
|
|
584
|
+
clearTimeout(timer);
|
|
585
|
+
}
|
|
586
|
+
chatFallbacks.clear();
|
|
587
|
+
for (const timer of contextUsageRefreshes.values()) {
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
}
|
|
590
|
+
contextUsageRefreshes.clear();
|
|
591
|
+
// Code 4000 = server kicked us because another relay client took over.
|
|
592
|
+
// Stop retrying so the two instances don't bounce each other forever.
|
|
593
|
+
resolve(code !== 4000);
|
|
594
|
+
});
|
|
595
|
+
relayWs.on("error", (err) => {
|
|
596
|
+
console.error("Relay WebSocket error:", err.message);
|
|
597
|
+
// close event will follow
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function isVoiceReplyConfigCommand(method) {
|
|
602
|
+
return method === "clawconnect.voiceReply.setConfig"
|
|
603
|
+
|| method === "pocketclaw.voiceReply.setConfig"
|
|
604
|
+
|| method === "clawpilot.voiceReply.setConfig";
|
|
605
|
+
}
|
|
606
|
+
export function buildRelayHelloMessage(opts) {
|
|
607
|
+
return {
|
|
608
|
+
type: "hello",
|
|
609
|
+
platform: opts.platform,
|
|
610
|
+
agentVersion: opts.agentVersion,
|
|
611
|
+
capabilities: opts.capabilities,
|
|
612
|
+
slashCommands: OPENCLAW_SLASH_COMMAND_CATALOG,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function buildRelayUrl(serverUrl, gatewayId, relaySecret) {
|
|
616
|
+
const base = serverUrl.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
617
|
+
return `${base}/relay/${gatewayId}?secret=${encodeURIComponent(relaySecret)}`;
|
|
618
|
+
}
|
|
619
|
+
//# sourceMappingURL=relay-manager.js.map
|