echoclaw-relay-agent 0.19.0 → 0.19.2

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.
@@ -216,6 +216,12 @@ export declare class ChatHandler {
216
216
  * message.content is an array of content blocks; we concatenate text blocks.
217
217
  */
218
218
  private _extractText;
219
+ /**
220
+ * Extract media content blocks (images + files) from OpenClaw message format.
221
+ * Gateway returns content blocks with type "image" or "input_file"/"file".
222
+ */
223
+ private static readonly MAX_MEDIA_BASE64_LEN;
224
+ private _extractMedia;
219
225
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
220
226
  private _send;
221
227
  /** Sequential send queue to prevent message reordering between flush and real-time messages. */
@@ -586,6 +586,18 @@ export class ChatHandler {
586
586
  final: true,
587
587
  }).catch(() => { });
588
588
  }
589
+ // Send media blocks (images, files) if present in the final message
590
+ const mediaBlocks = this._extractMedia(eventPayload?.message);
591
+ for (const media of mediaBlocks) {
592
+ this._sendVia(sendBack, {
593
+ type: 'chat_file',
594
+ fileType: media.type,
595
+ data: media.data,
596
+ mimeType: media.mimeType,
597
+ fileName: media.fileName,
598
+ runId,
599
+ }).catch(() => { });
600
+ }
589
601
  // Clean up run tracking — move own runs to recent to prevent false external classification
590
602
  if (runId) {
591
603
  const completedRun = this._activeRuns.get(runId);
@@ -635,6 +647,18 @@ export class ChatHandler {
635
647
  final: true,
636
648
  }).catch(() => { });
637
649
  }
650
+ // Forward media blocks from side results
651
+ const mediaBlocks = this._extractMedia(payload?.message);
652
+ for (const media of mediaBlocks) {
653
+ this._sendVia(sendBack, {
654
+ type: 'chat_file',
655
+ fileType: media.type,
656
+ data: media.data,
657
+ mimeType: media.mimeType,
658
+ fileName: media.fileName,
659
+ runId: payload?.runId,
660
+ }).catch(() => { });
661
+ }
638
662
  }
639
663
  /**
640
664
  * Handle agent events — lifecycle, tool use, compaction, model fallback.
@@ -940,6 +964,39 @@ export class ChatHandler {
940
964
  .map((block) => block.text)
941
965
  .join('');
942
966
  }
967
+ _extractMedia(message) {
968
+ if (!message?.content || !Array.isArray(message.content))
969
+ return [];
970
+ const media = [];
971
+ const maxLen = ChatHandler.MAX_MEDIA_BASE64_LEN;
972
+ for (const block of message.content) {
973
+ if (!block || typeof block !== 'object')
974
+ continue;
975
+ // Image blocks: { type: "image", source: { type: "base64", media_type, data } }
976
+ if (block.type === 'image' && block.source?.data && block.source.data.length > 0) {
977
+ if (block.source.data.length <= maxLen) {
978
+ media.push({
979
+ type: 'image',
980
+ data: block.source.data,
981
+ mimeType: block.source.media_type || 'image/png',
982
+ fileName: block.fileName,
983
+ });
984
+ }
985
+ }
986
+ else if ((block.type === 'input_file' || block.type === 'file') && block.source?.data && block.source.data.length > 0) {
987
+ // File blocks: { type: "input_file" | "file", source: { type: "base64", media_type, data, filename } }
988
+ if (block.source.data.length <= maxLen) {
989
+ media.push({
990
+ type: 'file',
991
+ data: block.source.data,
992
+ mimeType: block.source.media_type || 'application/octet-stream',
993
+ fileName: block.source.filename || block.fileName,
994
+ });
995
+ }
996
+ }
997
+ }
998
+ return media;
999
+ }
943
1000
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
944
1001
  async _send(msg) {
945
1002
  if (this._sendBack) {
@@ -995,3 +1052,14 @@ export class ChatHandler {
995
1052
  await fn(msg);
996
1053
  }
997
1054
  }
1055
+ /**
1056
+ * Extract media content blocks (images + files) from OpenClaw message format.
1057
+ * Gateway returns content blocks with type "image" or "input_file"/"file".
1058
+ */
1059
+ // 10MB base64 limit (~7.5MB raw) — prevents OOM on huge files
1060
+ Object.defineProperty(ChatHandler, "MAX_MEDIA_BASE64_LEN", {
1061
+ enumerable: true,
1062
+ configurable: true,
1063
+ writable: true,
1064
+ value: 10 * 1024 * 1024
1065
+ });
package/dist/index.d.ts CHANGED
@@ -27,3 +27,5 @@ export { GatewayWatchdog } from './gateway/index.js';
27
27
  export { OpenClawWsClient } from './gateway/index.js';
28
28
  export type { GatewayConfig, BridgeEndpoint, BridgeStatus, DeviceInfo, OpenClawWsClientConfig, } from './gateway/index.js';
29
29
  export { DEFAULT_GATEWAY_CONFIG } from './gateway/index.js';
30
+ export { LocalAgent, type LocalAgentConfig, type LocalAgentStatus } from './local/index.js';
31
+ export { detectLocalOpenClaw, type LocalOpenClawInfo } from './local/index.js';
package/dist/index.js CHANGED
@@ -30,3 +30,6 @@ export { GatewayBridge } from './gateway/index.js';
30
30
  export { GatewayWatchdog } from './gateway/index.js';
31
31
  export { OpenClawWsClient } from './gateway/index.js';
32
32
  export { DEFAULT_GATEWAY_CONFIG } from './gateway/index.js';
33
+ // ── Local Connection ────────────────────────────────────────
34
+ export { LocalAgent } from './local/index.js';
35
+ export { detectLocalOpenClaw } from './local/index.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * LocalAgent — Direct local connection to OpenClaw Gateway.
3
+ *
4
+ * Used when desktop and OpenClaw are on the same machine.
5
+ * Provides the SAME interface as RelayAgent — desktop doesn't know the difference.
6
+ *
7
+ * Architecture:
8
+ * Desktop ← EventEmitter → LocalAgent → ChatHandler/InstallHandler → OpenClaw Gateway (localhost)
9
+ *
10
+ * No relay server, no encryption (localhost), no pairing needed.
11
+ * Reuses ChatHandler and InstallHandler via setSendBack() injection.
12
+ */
13
+ import { EventEmitter } from 'node:events';
14
+ export interface LocalAgentConfig {
15
+ /** OpenClaw Gateway port. Default: 18789 */
16
+ gatewayPort?: number;
17
+ /** OpenClaw Gateway host. Default: '127.0.0.1' */
18
+ gatewayHost?: string;
19
+ /** Gateway auth token (from ~/.openclaw/openclaw.json) */
20
+ gatewayToken: string;
21
+ /** Session key. Default: 'main' */
22
+ sessionKey?: string;
23
+ }
24
+ export type LocalAgentStatus = 'disconnected' | 'connecting' | 'connected';
25
+ export declare class LocalAgent extends EventEmitter {
26
+ private readonly config;
27
+ private chatHandler;
28
+ private installHandler;
29
+ private _status;
30
+ private _stopped;
31
+ private _reconnectTimer;
32
+ private _reconnectAttempt;
33
+ constructor(config: LocalAgentConfig);
34
+ get status(): LocalAgentStatus;
35
+ get connectionMode(): 'local';
36
+ /**
37
+ * Connect to local OpenClaw Gateway.
38
+ */
39
+ connect(): Promise<void>;
40
+ /**
41
+ * Send a message to OpenClaw (from desktop).
42
+ * Routes to appropriate handler based on message type.
43
+ */
44
+ send(payload: any): Promise<void>;
45
+ /**
46
+ * Gracefully disconnect.
47
+ */
48
+ disconnect(): Promise<void>;
49
+ private setStatus;
50
+ private _scheduleReconnect;
51
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * LocalAgent — Direct local connection to OpenClaw Gateway.
3
+ *
4
+ * Used when desktop and OpenClaw are on the same machine.
5
+ * Provides the SAME interface as RelayAgent — desktop doesn't know the difference.
6
+ *
7
+ * Architecture:
8
+ * Desktop ← EventEmitter → LocalAgent → ChatHandler/InstallHandler → OpenClaw Gateway (localhost)
9
+ *
10
+ * No relay server, no encryption (localhost), no pairing needed.
11
+ * Reuses ChatHandler and InstallHandler via setSendBack() injection.
12
+ */
13
+ import { EventEmitter } from 'node:events';
14
+ import { ChatHandler } from '../chat/ChatHandler.js';
15
+ import { InstallHandler } from '../install/InstallHandler.js';
16
+ export class LocalAgent extends EventEmitter {
17
+ constructor(config) {
18
+ super();
19
+ Object.defineProperty(this, "config", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: void 0
24
+ });
25
+ Object.defineProperty(this, "chatHandler", {
26
+ enumerable: true,
27
+ configurable: true,
28
+ writable: true,
29
+ value: null
30
+ });
31
+ Object.defineProperty(this, "installHandler", {
32
+ enumerable: true,
33
+ configurable: true,
34
+ writable: true,
35
+ value: null
36
+ });
37
+ Object.defineProperty(this, "_status", {
38
+ enumerable: true,
39
+ configurable: true,
40
+ writable: true,
41
+ value: 'disconnected'
42
+ });
43
+ Object.defineProperty(this, "_stopped", {
44
+ enumerable: true,
45
+ configurable: true,
46
+ writable: true,
47
+ value: false
48
+ });
49
+ Object.defineProperty(this, "_reconnectTimer", {
50
+ enumerable: true,
51
+ configurable: true,
52
+ writable: true,
53
+ value: null
54
+ });
55
+ Object.defineProperty(this, "_reconnectAttempt", {
56
+ enumerable: true,
57
+ configurable: true,
58
+ writable: true,
59
+ value: 0
60
+ });
61
+ this.config = config;
62
+ }
63
+ get status() {
64
+ return this._status;
65
+ }
66
+ get connectionMode() {
67
+ return 'local';
68
+ }
69
+ /**
70
+ * Connect to local OpenClaw Gateway.
71
+ */
72
+ async connect() {
73
+ this._stopped = false;
74
+ this.setStatus('connecting');
75
+ try {
76
+ const port = this.config.gatewayPort ?? 18789;
77
+ const host = this.config.gatewayHost ?? '127.0.0.1';
78
+ const sessionKey = this.config.sessionKey ?? 'main';
79
+ // Create ChatHandler — it manages the WS connection to Gateway
80
+ this.chatHandler = new ChatHandler({
81
+ port,
82
+ host,
83
+ gatewayToken: this.config.gatewayToken,
84
+ sessionKey,
85
+ });
86
+ // Inject sendBack: messages go directly to desktop via EventEmitter
87
+ this.chatHandler.setSendBack(async (msg) => {
88
+ this.emit('message', msg);
89
+ });
90
+ // Create InstallHandler — needs wsClient + chatHandler
91
+ this.installHandler = new InstallHandler(this.chatHandler.client, this.chatHandler, { sessionKey });
92
+ this.installHandler.setSendBack(async (msg) => {
93
+ this.emit('message', msg);
94
+ });
95
+ // Listen for Gateway connection events
96
+ this.chatHandler.client.on('connected', () => {
97
+ this._reconnectAttempt = 0;
98
+ this.setStatus('connected');
99
+ this.emit('connected');
100
+ });
101
+ this.chatHandler.client.on('disconnected', () => {
102
+ if (this._status === 'connected') {
103
+ this.setStatus('disconnected');
104
+ this.emit('disconnected');
105
+ }
106
+ if (!this._stopped) {
107
+ this._scheduleReconnect();
108
+ }
109
+ });
110
+ this.chatHandler.client.on('error', (err) => {
111
+ this.emit('error', err);
112
+ });
113
+ // Connect to Gateway
114
+ await this.chatHandler.connect();
115
+ }
116
+ catch (err) {
117
+ this.setStatus('disconnected');
118
+ if (!this._stopped) {
119
+ this._scheduleReconnect();
120
+ }
121
+ throw err;
122
+ }
123
+ }
124
+ /**
125
+ * Send a message to OpenClaw (from desktop).
126
+ * Routes to appropriate handler based on message type.
127
+ */
128
+ async send(payload) {
129
+ if (!payload?.type)
130
+ return;
131
+ const type = payload.type;
132
+ // Chat messages
133
+ if (type === 'chat' || type === 'system_prompt') {
134
+ if (this.chatHandler) {
135
+ await this.chatHandler.handle(payload, async (msg) => {
136
+ this.emit('message', msg);
137
+ });
138
+ }
139
+ return;
140
+ }
141
+ // Install messages
142
+ if (type === 'install_request') {
143
+ if (this.installHandler) {
144
+ await this.installHandler.handleInstallRequest(payload);
145
+ }
146
+ return;
147
+ }
148
+ if (type === 'install_abort') {
149
+ if (this.installHandler) {
150
+ await this.installHandler.handleAbort(payload.requestId);
151
+ }
152
+ return;
153
+ }
154
+ // Unknown message type — ignore (forward compat)
155
+ }
156
+ /**
157
+ * Gracefully disconnect.
158
+ */
159
+ async disconnect() {
160
+ this._stopped = true;
161
+ if (this._reconnectTimer) {
162
+ clearTimeout(this._reconnectTimer);
163
+ this._reconnectTimer = null;
164
+ }
165
+ if (this.chatHandler) {
166
+ this.chatHandler.clearSendBack();
167
+ this.chatHandler.disconnect();
168
+ this.chatHandler = null;
169
+ }
170
+ if (this.installHandler) {
171
+ this.installHandler.clearSendBack();
172
+ this.installHandler = null;
173
+ }
174
+ this.setStatus('disconnected');
175
+ }
176
+ // ── Private ─────────────────────────────────────────────
177
+ setStatus(status) {
178
+ if (this._status !== status) {
179
+ this._status = status;
180
+ this.emit('status', status);
181
+ }
182
+ }
183
+ _scheduleReconnect() {
184
+ if (this._stopped || this._reconnectTimer)
185
+ return;
186
+ this._reconnectAttempt++;
187
+ const delay = Math.min(3000 * Math.pow(2, this._reconnectAttempt - 1), 60000);
188
+ this._reconnectTimer = setTimeout(async () => {
189
+ this._reconnectTimer = null;
190
+ if (this._stopped)
191
+ return;
192
+ try {
193
+ await this.connect();
194
+ }
195
+ catch {
196
+ // connect() failed, reconnect scheduled by disconnected event
197
+ }
198
+ }, delay);
199
+ }
200
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * LocalDetector — Detect local OpenClaw installation.
3
+ *
4
+ * Checks two conditions (both must pass):
5
+ * 1. OpenClaw Gateway is reachable at localhost:<port>
6
+ * 2. Gateway auth token is readable from ~/.openclaw/openclaw.json
7
+ *
8
+ * If both pass → local mode. Otherwise → relay mode.
9
+ */
10
+ export interface LocalOpenClawInfo {
11
+ gatewayPort: number;
12
+ gatewayToken: string;
13
+ configPath: string;
14
+ }
15
+ /**
16
+ * Detect if OpenClaw is installed and running locally.
17
+ * Returns config info if available, null otherwise.
18
+ */
19
+ export declare function detectLocalOpenClaw(configPath?: string, port?: number): Promise<LocalOpenClawInfo | null>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * LocalDetector — Detect local OpenClaw installation.
3
+ *
4
+ * Checks two conditions (both must pass):
5
+ * 1. OpenClaw Gateway is reachable at localhost:<port>
6
+ * 2. Gateway auth token is readable from ~/.openclaw/openclaw.json
7
+ *
8
+ * If both pass → local mode. Otherwise → relay mode.
9
+ */
10
+ import { readFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ const DEFAULT_GATEWAY_PORT = 18789;
14
+ const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
15
+ const DETECT_TIMEOUT_MS = 2000;
16
+ /**
17
+ * Detect if OpenClaw is installed and running locally.
18
+ * Returns config info if available, null otherwise.
19
+ */
20
+ export async function detectLocalOpenClaw(configPath = OPENCLAW_CONFIG_PATH, port = DEFAULT_GATEWAY_PORT) {
21
+ try {
22
+ // Step 1: Read gateway token from config file
23
+ const token = await readGatewayToken(configPath);
24
+ if (!token)
25
+ return null;
26
+ // Step 2: Check if Gateway is reachable
27
+ const reachable = await checkGatewayHealth(port, token);
28
+ if (!reachable)
29
+ return null;
30
+ return {
31
+ gatewayPort: port,
32
+ gatewayToken: token,
33
+ configPath,
34
+ };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /**
41
+ * Read gateway auth token from openclaw.json.
42
+ * Returns null if file doesn't exist or token not found.
43
+ */
44
+ async function readGatewayToken(configPath) {
45
+ try {
46
+ const raw = await readFile(configPath, 'utf-8');
47
+ const config = JSON.parse(raw);
48
+ // Token location: gateway.auth.token or gateway.token
49
+ const token = config?.gateway?.auth?.token ||
50
+ config?.gateway?.token ||
51
+ null;
52
+ if (!token || typeof token !== 'string')
53
+ return null;
54
+ return token;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Ping Gateway health endpoint to verify it's running.
62
+ */
63
+ async function checkGatewayHealth(port, token) {
64
+ try {
65
+ const controller = new AbortController();
66
+ const timeout = setTimeout(() => controller.abort(), DETECT_TIMEOUT_MS);
67
+ const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
68
+ headers: { Authorization: `Bearer ${token}` },
69
+ signal: controller.signal,
70
+ });
71
+ clearTimeout(timeout);
72
+ return res.ok;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Local connection module — public API.
3
+ */
4
+ export { LocalAgent, type LocalAgentConfig, type LocalAgentStatus } from './LocalAgent.js';
5
+ export { detectLocalOpenClaw, type LocalOpenClawInfo } from './LocalDetector.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Local connection module — public API.
3
+ */
4
+ export { LocalAgent } from './LocalAgent.js';
5
+ export { detectLocalOpenClaw } from './LocalDetector.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",