@alfe.ai/openclaw-chat 0.0.1 → 0.0.3

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/dist/plugin.d.ts CHANGED
@@ -149,6 +149,12 @@ interface AlfePluginConfig {
149
149
  daemonSocket?: string;
150
150
  /** Agent ID this plugin is associated with. */
151
151
  agentId?: string;
152
+ /** Chat service WebSocket URL (e.g. wss://chat.dev.alfe.ai/ws) */
153
+ chatWsUrl?: string;
154
+ /** API key for chat service auth */
155
+ apiKey?: string;
156
+ /** OpenClaw local gateway URL override (default: ws://127.0.0.1:18789) */
157
+ openclawGatewayUrl?: string;
152
158
  }
153
159
  interface Logger {
154
160
  info(msg: string, ...args: unknown[]): void;
package/dist/plugin2.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { homedir } from "node:os";
3
+ import { ChatServiceClient } from "@alfe.ai/chat";
4
+ import WebSocket from "ws";
5
+ import { randomUUID } from "node:crypto";
3
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
7
  import { existsSync } from "node:fs";
5
8
  //#region src/alfe-channel.ts
@@ -134,6 +137,239 @@ function createAlfeChannelPlugin() {
134
137
  };
135
138
  }
136
139
  //#endregion
140
+ //#region src/runtime-relay.ts
141
+ /**
142
+ * RuntimeRelay — bridges the chat service to the local OpenClaw runtime.
143
+ *
144
+ * Connects to OpenClaw's local WebSocket server (typically ws://127.0.0.1:18789),
145
+ * performs the protocol handshake, and forwards RPC requests from the chat
146
+ * service to the runtime. Responses and streaming events are routed back
147
+ * via callbacks.
148
+ *
149
+ * Modeled after the voice relay's LocalGatewayClient pattern.
150
+ */
151
+ const RECONNECT_DELAYS = [
152
+ 500,
153
+ 1e3,
154
+ 2e3,
155
+ 4e3,
156
+ 8e3,
157
+ 15e3,
158
+ 3e4
159
+ ];
160
+ var RuntimeRelay = class {
161
+ ws = null;
162
+ pending = /* @__PURE__ */ new Map();
163
+ connected = false;
164
+ stopped = false;
165
+ retryCount = 0;
166
+ retryTimer = null;
167
+ connectSent = false;
168
+ wsUrl;
169
+ token;
170
+ log;
171
+ constructor(options) {
172
+ this.options = options;
173
+ this.wsUrl = options.wsUrl ?? "ws://127.0.0.1:18789";
174
+ this.token = options.token ?? "";
175
+ this.log = options.logger;
176
+ }
177
+ get isConnected() {
178
+ return this.connected && this.ws?.readyState === WebSocket.OPEN;
179
+ }
180
+ start() {
181
+ this.log.debug("RuntimeRelay starting...");
182
+ this.stopped = false;
183
+ this.doConnect();
184
+ }
185
+ stop() {
186
+ this.log.debug("RuntimeRelay stopping...");
187
+ this.stopped = true;
188
+ if (this.retryTimer) {
189
+ clearTimeout(this.retryTimer);
190
+ this.retryTimer = null;
191
+ }
192
+ if (this.ws) {
193
+ try {
194
+ this.ws.close(1e3);
195
+ } catch {}
196
+ this.ws = null;
197
+ }
198
+ this.connected = false;
199
+ this.flushPending(/* @__PURE__ */ new Error("RuntimeRelay stopped"));
200
+ }
201
+ /**
202
+ * Forward an RPC request from the chat service to the runtime.
203
+ * The response routes back via the callback.
204
+ */
205
+ forwardRequest(msg, onResponse) {
206
+ if (this.ws?.readyState !== WebSocket.OPEN) {
207
+ onResponse({
208
+ id: msg.id,
209
+ ok: false,
210
+ error: { message: "Runtime not connected" }
211
+ });
212
+ return;
213
+ }
214
+ const timeoutMs = msg.method === "agent" ? 13e4 : 3e4;
215
+ const timer = setTimeout(() => {
216
+ this.pending.delete(msg.id);
217
+ onResponse({
218
+ id: msg.id,
219
+ ok: false,
220
+ error: { message: `'${msg.method}' timed out` }
221
+ });
222
+ }, timeoutMs);
223
+ this.pending.set(msg.id, {
224
+ resolve: (payload) => {
225
+ clearTimeout(timer);
226
+ onResponse({
227
+ id: msg.id,
228
+ ok: true,
229
+ payload
230
+ });
231
+ },
232
+ reject: (err) => {
233
+ clearTimeout(timer);
234
+ onResponse({
235
+ id: msg.id,
236
+ ok: false,
237
+ error: { message: err.message }
238
+ });
239
+ },
240
+ expectFinal: msg.method === "agent"
241
+ });
242
+ this.ws.send(JSON.stringify({
243
+ type: "req",
244
+ id: msg.id,
245
+ method: msg.method,
246
+ params: msg.params
247
+ }));
248
+ }
249
+ doConnect() {
250
+ if (this.stopped) return;
251
+ this.log.info(`Connecting to runtime at ${this.wsUrl}...`);
252
+ this.ws = new WebSocket(this.wsUrl, { maxPayload: 25 * 1024 * 1024 });
253
+ this.ws.on("open", () => {
254
+ this.log.info("Connected to runtime — authenticating...");
255
+ this.retryCount = 0;
256
+ setTimeout(() => {
257
+ this.sendConnect();
258
+ }, 750);
259
+ });
260
+ this.ws.on("message", (data) => {
261
+ const text = Buffer.isBuffer(data) ? data.toString("utf-8") : Buffer.from(data).toString("utf-8");
262
+ this.handleMessage(text);
263
+ });
264
+ this.ws.on("close", (code) => {
265
+ this.log.warn(`Runtime disconnected (${String(code)})`);
266
+ this.connected = false;
267
+ this.flushPending(/* @__PURE__ */ new Error(`Runtime closed (${String(code)})`));
268
+ this.scheduleReconnect();
269
+ });
270
+ this.ws.on("error", (err) => {
271
+ this.log.error(`Runtime WS error: ${err.message}`);
272
+ });
273
+ }
274
+ async sendConnect() {
275
+ if (this.connectSent) return;
276
+ this.connectSent = true;
277
+ const id = randomUUID();
278
+ const timeoutMs = 1e4;
279
+ try {
280
+ await new Promise((resolve, reject) => {
281
+ const timer = setTimeout(() => {
282
+ this.pending.delete(id);
283
+ reject(/* @__PURE__ */ new Error("Connect handshake timed out"));
284
+ }, timeoutMs);
285
+ this.pending.set(id, {
286
+ resolve: () => {
287
+ clearTimeout(timer);
288
+ resolve();
289
+ },
290
+ reject: (err) => {
291
+ clearTimeout(timer);
292
+ reject(err);
293
+ },
294
+ expectFinal: false
295
+ });
296
+ this.ws?.send(JSON.stringify({
297
+ type: "req",
298
+ id,
299
+ method: "connect",
300
+ params: {
301
+ minProtocol: 3,
302
+ maxProtocol: 3,
303
+ client: {
304
+ id: "chat-relay",
305
+ displayName: "Alfe Chat Relay",
306
+ version: "1.0.0",
307
+ platform: process.platform,
308
+ mode: "backend",
309
+ instanceId: `chat-${Date.now().toString(36)}`
310
+ },
311
+ caps: [],
312
+ role: "operator",
313
+ scopes: ["operator.admin"],
314
+ auth: this.token ? { token: this.token } : void 0
315
+ }
316
+ }));
317
+ });
318
+ this.connected = true;
319
+ this.log.info("Runtime authenticated — relay active");
320
+ } catch (err) {
321
+ const errMsg = err instanceof Error ? err.message : String(err);
322
+ this.log.error(`Runtime connect failed: ${errMsg}`);
323
+ this.ws?.close(1008, "connect failed");
324
+ }
325
+ }
326
+ handleMessage(raw) {
327
+ try {
328
+ const parsed = JSON.parse(raw);
329
+ if (parsed.event) {
330
+ const payload = parsed.payload;
331
+ if (parsed.event === "connect.challenge" && payload?.nonce) {
332
+ this.connectSent = false;
333
+ this.sendConnect();
334
+ return;
335
+ }
336
+ if (payload) this.options.onEvent(parsed.event, payload);
337
+ return;
338
+ }
339
+ if ("id" in parsed && "ok" in parsed) {
340
+ const pending = this.pending.get(parsed.id);
341
+ if (!pending) return;
342
+ const payload = parsed.payload;
343
+ if (pending.expectFinal && payload?.status === "accepted") return;
344
+ this.pending.delete(parsed.id);
345
+ if (parsed.ok) pending.resolve(payload);
346
+ else {
347
+ const error = parsed.error;
348
+ pending.reject(new Error(error?.message ?? "unknown error"));
349
+ }
350
+ }
351
+ } catch (err) {
352
+ const errMsg = err instanceof Error ? err.message : String(err);
353
+ this.log.error(`Runtime message parse error: ${errMsg}`);
354
+ }
355
+ }
356
+ scheduleReconnect() {
357
+ if (this.stopped) return;
358
+ const delay = RECONNECT_DELAYS[Math.min(this.retryCount, RECONNECT_DELAYS.length - 1)];
359
+ this.retryCount++;
360
+ this.connectSent = false;
361
+ this.log.info(`Reconnecting to runtime in ${String(delay)}ms (attempt ${String(this.retryCount)})...`);
362
+ this.retryTimer = setTimeout(() => {
363
+ this.retryTimer = null;
364
+ this.doConnect();
365
+ }, delay);
366
+ }
367
+ flushPending(err) {
368
+ for (const [, p] of this.pending) p.reject(err);
369
+ this.pending.clear();
370
+ }
371
+ };
372
+ //#endregion
137
373
  //#region src/session-store.ts
138
374
  /**
139
375
  * Session Store — persists chat sessions to the local filesystem.
@@ -246,6 +482,8 @@ const CHAT_CAPABILITIES = [
246
482
  "chat.sessions"
247
483
  ];
248
484
  let daemonIpcClient = null;
485
+ let chatClient = null;
486
+ let runtimeRelay = null;
249
487
  /**
250
488
  * Attempt to connect to the Alfe daemon IPC socket.
251
489
  * Returns null if @alfe.ai/openclaw is not available or daemon is unreachable.
@@ -294,6 +532,41 @@ const plugin = {
294
532
  api.registerChannel(alfeChannel);
295
533
  log.info(`Registered channel: ${alfeChannel.id} (${alfeChannel.meta.label})`);
296
534
  daemonIpcClient = await connectToDaemon(pluginConfig.daemonSocket ?? process.env.ALFE_GATEWAY_SOCKET ?? DEFAULT_SOCKET_PATH, log);
535
+ const chatWsUrl = pluginConfig.chatWsUrl ?? process.env.ALFE_CHAT_WS_URL;
536
+ const apiKey = pluginConfig.apiKey ?? process.env.ALFE_API_KEY;
537
+ const runtimeUrl = pluginConfig.openclawGatewayUrl ?? process.env.OPENCLAW_GATEWAY_URL ?? "ws://127.0.0.1:18789";
538
+ const runtimeToken = process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
539
+ if (chatWsUrl && apiKey) {
540
+ log.info(`Connecting to chat service: ${chatWsUrl}`);
541
+ runtimeRelay = new RuntimeRelay({
542
+ wsUrl: runtimeUrl,
543
+ token: runtimeToken,
544
+ onEvent: (event, payload) => {
545
+ chatClient?.sendEvent(event, payload);
546
+ },
547
+ logger: log
548
+ });
549
+ chatClient = new ChatServiceClient({
550
+ wsUrl: chatWsUrl,
551
+ apiKey,
552
+ onRequest: (request) => {
553
+ if (!runtimeRelay?.isConnected) {
554
+ chatClient?.sendResponse(request.id, false, { message: "Runtime not connected" });
555
+ return;
556
+ }
557
+ runtimeRelay.forwardRequest(request, (response) => {
558
+ chatClient?.send(response);
559
+ });
560
+ },
561
+ onConnectionChange: (connected) => {
562
+ log.info(`Chat service connection: ${connected ? "connected" : "disconnected"}`);
563
+ },
564
+ logger: log
565
+ });
566
+ runtimeRelay.start();
567
+ chatClient.start();
568
+ log.info("Chat service relay started");
569
+ } else log.info("Chat service URL not configured — running without chat service relay");
297
570
  if (typeof api.registerGatewayMethod === "function") {
298
571
  api.registerGatewayMethod("chat.send", (...args) => {
299
572
  const { sessionId, content, clientType } = args[0];
@@ -365,6 +638,16 @@ const plugin = {
365
638
  globalThis.__alfeChatPluginActivated = false;
366
639
  const log = api.logger;
367
640
  log.info("Alfe Chat plugin deactivating...");
641
+ if (chatClient) {
642
+ chatClient.stop();
643
+ chatClient = null;
644
+ log.info("Chat service client stopped");
645
+ }
646
+ if (runtimeRelay) {
647
+ runtimeRelay.stop();
648
+ runtimeRelay = null;
649
+ log.info("Runtime relay stopped");
650
+ }
368
651
  if (daemonIpcClient) {
369
652
  try {
370
653
  daemonIpcClient.stop();
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "@alfe.ai/openclaw-chat",
3
+ "channels": ["alfe"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -21,13 +21,21 @@
21
21
  ]
22
22
  },
23
23
  "files": [
24
- "dist"
24
+ "dist",
25
+ "openclaw.plugin.json"
25
26
  ],
27
+ "dependencies": {
28
+ "ws": "^8.18.0",
29
+ "@alfe.ai/chat": "^0.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/ws": "^8.5.13"
33
+ },
26
34
  "license": "UNLICENSED",
27
35
  "scripts": {
28
36
  "build": "tsdown",
29
37
  "dev": "tsdown --watch",
30
- "test": "vitest run",
38
+ "test": "vitest run --passWithNoTests",
31
39
  "test:watch": "vitest",
32
40
  "typecheck": "tsc --noEmit",
33
41
  "lint": "eslint ."