@eyeclaw/eyeclaw 2.3.11 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eyeclaw/eyeclaw",
3
- "version": "2.3.11",
3
+ "version": "2.4.0",
4
4
  "description": "EyeClaw plugin for OpenClaw - HTTP SSE streaming + WebSocket client",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -22,11 +22,19 @@ export class EyeClawWebSocketClient {
22
22
  private config: EyeClawConfig
23
23
  private getState: () => any
24
24
  private reconnectAttempts = 0
25
- private maxReconnectAttempts = 5
26
- private reconnectDelay = 3000
25
+ private maxReconnectAttempts = 10
26
+ private baseReconnectDelay = 1000 // 1 second
27
+ private maxReconnectDelay = 30000 // 30 seconds
28
+ private currentReconnectDelay = 1000
29
+ private reconnecting = false
27
30
  private subscribed = false
28
31
  private pingInterval: any = null
29
32
  private chunkSequence = 0 // 每个会话的 chunk 序号
33
+ private accumulatedContent = '' // 累积完整内容用于兜底
34
+
35
+ // 🔥 ACK 机制:追踪已发送和已确认的 chunks
36
+ private sentChunks = 0 // 已发送的 chunks 数量
37
+ private ackedChunks = new Set<number>() // 已确认的 chunk 序号集合
30
38
 
31
39
  constructor(api: OpenClawPluginApi, config: EyeClawConfig, getState: () => any) {
32
40
  this.api = api
@@ -83,6 +91,8 @@ export class EyeClawWebSocketClient {
83
91
  */
84
92
  stop() {
85
93
  this.stopPing()
94
+ this.reconnecting = false
95
+ this.resetReconnectDelay()
86
96
  if (this.ws) {
87
97
  this.ws.close()
88
98
  this.ws = null
@@ -90,6 +100,30 @@ export class EyeClawWebSocketClient {
90
100
  this.subscribed = false
91
101
  }
92
102
 
103
+ /**
104
+ * 重置重连延迟
105
+ */
106
+ private resetReconnectDelay() {
107
+ this.currentReconnectDelay = this.baseReconnectDelay
108
+ this.reconnectAttempts = 0
109
+ }
110
+
111
+ /**
112
+ * 计算下一次重连延迟(指数退避 + 随机抖动)
113
+ */
114
+ private calculateReconnectDelay(): number {
115
+ // 指数增长: 1s, 2s, 4s, 8s, 16s, 30s (cap)
116
+ const delay = Math.min(
117
+ this.currentReconnectDelay * 2,
118
+ this.maxReconnectDelay
119
+ )
120
+ this.currentReconnectDelay = delay
121
+
122
+ // 添加随机抖动 (±25%)
123
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1)
124
+ return Math.floor(delay + jitter)
125
+ }
126
+
93
127
  /**
94
128
  * 处理 WebSocket 消息
95
129
  */
@@ -133,6 +167,15 @@ export class EyeClawWebSocketClient {
133
167
  // 实际消息 - 从 Rails 发送的消息
134
168
  if (message.identifier && message.message) {
135
169
  const payload = message.message
170
+
171
+ // 🔥 ACK 机制:处理 chunk_received 确认
172
+ if (payload.type === 'chunk_received') {
173
+ const sequence = payload.sequence
174
+ this.ackedChunks.add(sequence)
175
+ this.api.logger.debug(`[EyeClaw] ✅ Received ACK for chunk #${sequence}, total acked: ${this.ackedChunks.size}/${this.sentChunks}`)
176
+ return
177
+ }
178
+
136
179
  this.handleCommand(payload)
137
180
  }
138
181
 
@@ -196,8 +239,10 @@ export class EyeClawWebSocketClient {
196
239
  * 调用自己的 HTTP 端点 /eyeclaw/chat
197
240
  */
198
241
  private async processWithOpenClaw(message: string, sessionId?: string, openclawSessionId?: string) {
199
- // 重置 chunk 序号(每个新会话)
242
+ // 重置 chunk 序号和 ACK 计数器(每个新会话)
200
243
  this.chunkSequence = 0
244
+ this.sentChunks = 0
245
+ this.ackedChunks.clear()
201
246
 
202
247
  const state = this.getState()
203
248
  const gatewayPort = state.gatewayPort
@@ -239,11 +284,12 @@ export class EyeClawWebSocketClient {
239
284
  let currentEvent = ''
240
285
 
241
286
  // 解析 SSE 流式响应
287
+ let streamEnded = false
288
+
242
289
  while (true) {
243
290
  const { done, value } = await reader.read()
244
291
  if (done) {
245
- // 流结束,通知 Rails
246
- this.sendMessage('stream_end', { session_id: sessionId })
292
+ this.api.logger.info(`[EyeClaw] Reader done, stream ended flag: ${streamEnded}`)
247
293
  break
248
294
  }
249
295
 
@@ -283,14 +329,25 @@ export class EyeClawWebSocketClient {
283
329
 
284
330
  // stream_end 事件:流结束(由 HTTP handler 发送)
285
331
  if (currentEvent === 'stream_end') {
286
- this.api.logger.info(`[EyeClaw] Stream ended: ${eventData.stream_id}`)
287
- // 通知 Rails 流已结束
332
+ this.api.logger.info(`[EyeClaw] Received stream_end event: ${eventData.stream_id}`)
333
+ streamEnded = true
334
+
335
+ // 🔥 等待所有 chunks 被确认后再发送 stream_end
336
+ await this.waitForAllAcks(sessionId)
337
+
338
+ // 发送 stream_end 和 stream_summary
288
339
  this.sendMessage('stream_end', { session_id: sessionId })
340
+ this.sendStreamSummary(sessionId)
341
+
342
+ // 退出循环
343
+ return
289
344
  }
290
345
 
291
346
  // stream_error 事件:错误
292
347
  if (currentEvent === 'stream_error') {
293
348
  this.api.logger.error(`[EyeClaw] Stream error: ${eventData.error}`)
349
+ this.sendMessage('stream_error', { error: eventData.error, session_id: sessionId })
350
+ return
294
351
  }
295
352
  } catch (e) {
296
353
  this.api.logger.warn(`[EyeClaw] Failed to parse SSE data: ${data}`)
@@ -298,6 +355,14 @@ export class EyeClawWebSocketClient {
298
355
  }
299
356
  }
300
357
  }
358
+
359
+ // 如果循环正常结束(没有收到 stream_end 事件),也要等待 ACK
360
+ if (!streamEnded) {
361
+ this.api.logger.info(`[EyeClaw] Stream ended without stream_end event, waiting for ACKs`)
362
+ await this.waitForAllAcks(sessionId)
363
+ this.sendMessage('stream_end', { session_id: sessionId })
364
+ this.sendStreamSummary(sessionId)
365
+ }
301
366
 
302
367
  this.api.logger.info(`[EyeClaw] Stream processing completed for session: ${sessionId}`)
303
368
 
@@ -322,6 +387,11 @@ export class EyeClawWebSocketClient {
322
387
  private sendChunk(content: string, sessionId?: string) {
323
388
  const timestamp = new Date().toISOString();
324
389
  const sequence = this.chunkSequence++;
390
+ this.sentChunks++; // 🔥 记录已发送数量
391
+
392
+ // 累积完整内容用于兜底
393
+ this.accumulatedContent += content;
394
+
325
395
  this.api.logger.info(`[EyeClaw] [${timestamp}] Sending chunk #${sequence} to Rails: "${content}"`);
326
396
  this.sendMessage('stream_chunk', {
327
397
  content,
@@ -329,6 +399,86 @@ export class EyeClawWebSocketClient {
329
399
  sequence, // 添加序号
330
400
  })
331
401
  }
402
+
403
+ /**
404
+ * 🔥 等待所有 chunks 被 Rails 确认
405
+ * 实现 TCP 三次握手的应用层版本
406
+ * 超时 2 秒后强制返回,依赖 stream_summary 兜底机制
407
+ */
408
+ private async waitForAllAcks(sessionId?: string): Promise<void> {
409
+ const startTime = Date.now()
410
+ const timeout = 2000 // 2秒超时
411
+ const checkInterval = 50 // 每 50ms 检查一次
412
+
413
+ this.api.logger.info(`[EyeClaw] 🕒 Waiting for all ACKs: sent=${this.sentChunks}, acked=${this.ackedChunks.size}`)
414
+
415
+ while (this.ackedChunks.size < this.sentChunks) {
416
+ const elapsed = Date.now() - startTime
417
+
418
+ if (elapsed >= timeout) {
419
+ const missing = this.sentChunks - this.ackedChunks.size
420
+ const missingSequences: number[] = []
421
+ for (let i = 0; i < this.sentChunks; i++) {
422
+ if (!this.ackedChunks.has(i)) {
423
+ missingSequences.push(i)
424
+ }
425
+ }
426
+
427
+ this.api.logger.warn(
428
+ `[EyeClaw] ⚠️ ACK timeout after ${elapsed}ms: ` +
429
+ `sent=${this.sentChunks}, acked=${this.ackedChunks.size}, ` +
430
+ `missing=${missing}, missing_sequences=[${missingSequences.join(', ')}]`
431
+ )
432
+ this.api.logger.info(`[EyeClaw] Relying on stream_summary fallback mechanism`)
433
+ break
434
+ }
435
+
436
+ // 等待 50ms 后再检查
437
+ await new Promise(resolve => setTimeout(resolve, checkInterval))
438
+ }
439
+
440
+ if (this.ackedChunks.size === this.sentChunks) {
441
+ const elapsed = Date.now() - startTime
442
+ this.api.logger.info(
443
+ `[EyeClaw] ✅ All chunks ACKed: ${this.ackedChunks.size}/${this.sentChunks} in ${elapsed}ms`
444
+ )
445
+ }
446
+ }
447
+
448
+ /**
449
+ * 发送 stream_summary 用于兜底机制
450
+ * 告诉 Rails 完整内容是什么,以便检测丢包并补偿
451
+ */
452
+ private sendStreamSummary(sessionId?: string) {
453
+ // 计算内容 hash
454
+ const contentHash = this.hashCode(this.accumulatedContent);
455
+
456
+ this.api.logger.info(`[EyeClaw] Sending stream_summary: chunks=${this.chunkSequence}, content_len=${this.accumulatedContent.length}, hash=${contentHash}`);
457
+
458
+ this.sendMessage('stream_summary', {
459
+ session_id: sessionId,
460
+ total_content: this.accumulatedContent,
461
+ total_chunks: this.chunkSequence,
462
+ content_hash: contentHash,
463
+ })
464
+
465
+ // 重置累积内容(为下一个会话做准备)
466
+ this.accumulatedContent = '';
467
+ this.chunkSequence = 0;
468
+ }
469
+
470
+ /**
471
+ * 简单 hash 函数
472
+ */
473
+ private hashCode(str: string): string {
474
+ let hash = 0;
475
+ for (let i = 0; i < str.length; i++) {
476
+ const char = str.charCodeAt(i);
477
+ hash = ((hash << 5) - hash) + char;
478
+ hash = hash & hash; // Convert to 32bit integer
479
+ }
480
+ return hash.toString(16);
481
+ }
332
482
 
333
483
  /**
334
484
  * 发送消息到 Rails(带 channel identifier)
@@ -379,19 +529,33 @@ export class EyeClawWebSocketClient {
379
529
  }
380
530
 
381
531
  /**
382
- * 计划重连
532
+ * 计划重连(带指数退避)
383
533
  */
384
534
  private scheduleReconnect() {
535
+ // 防止重复调度
536
+ if (this.reconnecting) {
537
+ return
538
+ }
539
+ this.reconnecting = true
540
+
385
541
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
386
- this.api.logger.error('[EyeClaw] Max reconnect attempts reached')
542
+ this.api.logger.error('[EyeClaw] Max reconnect attempts reached, will retry later...')
543
+ // 不放弃,继续重试(每 60 秒检查一次)
544
+ setTimeout(() => {
545
+ this.reconnecting = false
546
+ this.resetReconnectDelay()
547
+ this.scheduleReconnect()
548
+ }, 60000)
387
549
  return
388
550
  }
389
551
 
552
+ const delay = this.calculateReconnectDelay()
390
553
  this.reconnectAttempts++
391
- this.api.logger.info(`[EyeClaw] Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`)
554
+ this.api.logger.info(`[EyeClaw] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
392
555
 
393
556
  setTimeout(() => {
557
+ this.reconnecting = false
394
558
  this.start()
395
- }, this.reconnectDelay)
559
+ }, delay)
396
560
  }
397
561
  }