@clawchatsai/connector 0.0.61 → 0.0.63

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.
@@ -70,6 +70,21 @@ export class WebRTCPeerManager extends EventEmitter {
70
70
  }
71
71
  async handleOffer(offer) {
72
72
  const { connectionId, sdp, candidates } = offer;
73
+ // ICE restart: existing PC — renegotiate on it, DataChannel stays open.
74
+ const existingPc = this.peerConnections.get(connectionId);
75
+ if (existingPc) {
76
+ console.log(`[WebRTCPeerManager] ICE restart for connection ${connectionId}`);
77
+ await existingPc.setRemoteDescription(new RTCSessionDescription({ sdp, type: 'offer' }));
78
+ for (const rawCandidate of candidates) {
79
+ try {
80
+ await existingPc.addIceCandidate(new RTCIceCandidate(rawCandidate));
81
+ }
82
+ catch { /* ignore */ }
83
+ }
84
+ const answer = await existingPc.createAnswer();
85
+ await existingPc.setLocalDescription(answer);
86
+ return { connectionId, sdp: answer.sdp, candidates: [] };
87
+ }
73
88
  console.log(`[WebRTCPeerManager] Handling ICE offer for connection ${connectionId}`);
74
89
  const iceServers = this.pendingIceServers.get(connectionId) ?? [
75
90
  { urls: 'stun:stun.l.google.com:19302' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
6
6
  "main": "dist/index.js",
package/server.js CHANGED
@@ -11,6 +11,7 @@ import { execSync } from 'node:child_process';
11
11
  import os from 'node:os';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { createRequire } from 'node:module';
14
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
15
  import { WebSocket as WS, WebSocketServer } from 'ws';
15
16
 
16
17
  const __filename = fileURLToPath(import.meta.url);
@@ -22,6 +23,9 @@ const __dirname = path.dirname(__filename);
22
23
  // auto-rebuild in-place before proceeding. Falls back to a clear error message
23
24
  // if the user is missing build tools.
24
25
  const _require = createRequire(import.meta.url);
26
+
27
+ // Per-request workspace DB — isolates concurrent clients on different workspaces.
28
+ const _requestDbStore = new AsyncLocalStorage();
25
29
  let Database;
26
30
  {
27
31
  const _nativeErr = (e) =>
@@ -331,7 +335,7 @@ function getDb(workspaceName) {
331
335
  }
332
336
 
333
337
  function getActiveDb() {
334
- return getDb(getWorkspaces().active);
338
+ return _requestDbStore.getStore() || getDb(getWorkspaces().active);
335
339
  }
336
340
 
337
341
  const _globalDbCache = new Map(); // keyed by resolved dbPath
@@ -2172,6 +2176,12 @@ async function handleTranscribe(req, res) {
2172
2176
  }
2173
2177
 
2174
2178
  async function handleRequest(req, res) {
2179
+ const _wsName = req.headers?.['x-workspace'];
2180
+ const _requestDb = _wsName ? getDb(_wsName) : getActiveDb();
2181
+ return _requestDbStore.run(_requestDb, () => _handleRequestImpl(req, res));
2182
+ }
2183
+
2184
+ async function _handleRequestImpl(req, res) {
2175
2185
  // Parse URL and query string
2176
2186
  const [urlPath, queryString] = (req.url || '/').split('?');
2177
2187
  const query = {};
@@ -3207,6 +3217,61 @@ function extractContent(message) {
3207
3217
  return '';
3208
3218
  }
3209
3219
 
3220
+ // ─── Sentinel filtering helpers ───────────────────────────────────────────────
3221
+ // Mirrors logic from OpenClaw's tokens-C27XM9Ox.js so we can filter NO_REPLY /
3222
+ // HEARTBEAT_OK tokens before they reach the browser — including partial leaks
3223
+ // during streaming (e.g. "NO_" appearing mid-stream before the full token is
3224
+ // assembled). See GitHub issue #96.
3225
+
3226
+ // Returns true if text is exactly the sentinel (with optional surrounding whitespace).
3227
+ function isSilentReplyExact(text, token = 'NO_REPLY') {
3228
+ if (!text) return false;
3229
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3230
+ return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
3231
+ }
3232
+
3233
+ // Returns true if text could be the beginning of the sentinel token —
3234
+ // i.e. it is a strict uppercase-only prefix of the token.
3235
+ // Example: "NO" and "NO_" and "NO_REPLY" all return true for token="NO_REPLY".
3236
+ function isSilentReplyPrefix(text, token = 'NO_REPLY') {
3237
+ if (!text) return false;
3238
+ const trimmed = text.trimStart();
3239
+ if (!trimmed) return false;
3240
+ if (trimmed !== trimmed.toUpperCase()) return false; // must be ALL CAPS
3241
+ const normalized = trimmed.toUpperCase();
3242
+ if (normalized.length < 2) return false;
3243
+ if (/[^A-Z_]/.test(normalized)) return false; // only letters + underscore
3244
+ const tokenUpper = token.toUpperCase();
3245
+ if (!tokenUpper.startsWith(normalized)) return false; // must be a prefix
3246
+ if (normalized.includes('_')) return true; // past the first word — unambiguous
3247
+ // Single word without underscore: only allow "NO" for NO_REPLY (avoids false-positives
3248
+ // on other short all-caps words like "HI", "OK", etc.)
3249
+ return tokenUpper === 'NO_REPLY' && normalized === 'NO';
3250
+ }
3251
+
3252
+ // Strip a trailing sentinel token from mixed-content text (e.g. "answer NO_REPLY" → "answer").
3253
+ function stripTrailingSentinel(text, token = 'NO_REPLY') {
3254
+ if (!text) return text;
3255
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3256
+ return text.replace(new RegExp(`(?:^|\\s+|\\*+)${escaped}\\s*$`), '').trim();
3257
+ }
3258
+
3259
+ // Strip internal <final> / </final> tags that occasionally leak from the model.
3260
+ function stripFinalTags(text) {
3261
+ if (!text) return text;
3262
+ return text.replace(/<\s*\/?\s*final\s*>/gi, '');
3263
+ }
3264
+
3265
+ // Apply all content sanitization steps before saving to DB.
3266
+ function sanitizeAssistantContent(text) {
3267
+ if (!text) return text;
3268
+ let out = stripFinalTags(text);
3269
+ out = out.replace(/^(?:[ \t]*\r?\n)+/, ''); // strip leading blank lines
3270
+ if (out.includes('NO_REPLY')) out = stripTrailingSentinel(out, 'NO_REPLY');
3271
+ if (out.includes('HEARTBEAT_OK')) out = stripTrailingSentinel(out, 'HEARTBEAT_OK');
3272
+ return out;
3273
+ }
3274
+
3210
3275
  // ─── Shared activity log helpers ─────────────────────────────────────────────
3211
3276
  // Pure functions extracted from GatewayClient / _GatewayClient so both classes
3212
3277
  // share a single implementation. Pass the workspace-scoped DB getter and a
@@ -3398,7 +3463,7 @@ export function createApp(config = {}) {
3398
3463
  }
3399
3464
 
3400
3465
  function _getActiveDb() {
3401
- return _getDb(_getWorkspaces().active);
3466
+ return _requestDbStore.getStore() || _getDb(_getWorkspaces().active);
3402
3467
  }
3403
3468
 
3404
3469
  function _closeDb(workspaceName) {
@@ -4028,25 +4093,51 @@ export function createApp(config = {}) {
4028
4093
  return;
4029
4094
  }
4030
4095
  if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
4031
- this.broadcastToBrowsers(data);
4032
- if (msg.type === 'event' && msg.event === 'chat' && msg.payload) this.handleChatEvent(msg.payload);
4096
+ // Chat events are routed through handleChatEvent which decides when/if to
4097
+ // broadcast (sentinel hold, flush ordering, suppression). All other events
4098
+ // broadcast unconditionally.
4099
+ if (msg.type === 'event' && msg.event === 'chat' && msg.payload) {
4100
+ this.handleChatEvent(msg.payload, data);
4101
+ } else {
4102
+ this.broadcastToBrowsers(data);
4103
+ }
4033
4104
  if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
4034
4105
  }
4035
4106
 
4036
- handleChatEvent(params) {
4107
+ handleChatEvent(params, rawData) {
4037
4108
  const { sessionKey, state, message, seq } = params;
4109
+
4038
4110
  if (state === 'delta') {
4039
4111
  const parsed = parseSessionKey(sessionKey);
4040
4112
  if (parsed) {
4041
- const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming' };
4113
+ const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
4042
4114
  existing.buffer += extractContent(message);
4115
+
4116
+ // If the accumulated buffer looks like a NO_REPLY or HEARTBEAT_OK prefix,
4117
+ // hold this raw delta — don't forward to browsers yet.
4118
+ if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
4119
+ existing.held = existing.held || [];
4120
+ existing.held.push(rawData);
4121
+ this.streamState.set(sessionKey, existing);
4122
+ return; // suppressed — wait for more chunks
4123
+ }
4124
+
4125
+ // Buffer diverged from any sentinel — flush held deltas first, then current.
4126
+ if (existing.held?.length > 0) {
4127
+ for (const h of existing.held) this.broadcastToBrowsers(h);
4128
+ existing.held = [];
4129
+ }
4043
4130
  this.streamState.set(sessionKey, existing);
4044
4131
  }
4132
+ this.broadcastToBrowsers(rawData);
4045
4133
  return;
4046
4134
  }
4135
+
4136
+ // Capture stream entry before deletion so we can flush held deltas if needed.
4137
+ const streamEntry = this.streamState.get(sessionKey);
4047
4138
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
4048
4139
 
4049
- // Intercept title generation responses (final, error, or aborted)
4140
+ // Title generation sessions: pass through without sentinel filtering.
4050
4141
  if (sessionKey && sessionKey.includes('__clawchats_title_')) {
4051
4142
  if (state === 'final') {
4052
4143
  const content = extractContent(message);
@@ -4061,7 +4152,33 @@ export function createApp(config = {}) {
4061
4152
  }
4062
4153
  }
4063
4154
 
4064
- if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
4155
+ if (state === 'final') {
4156
+ const rawContent = extractContent(message);
4157
+
4158
+ // Exact NO_REPLY or HEARTBEAT_OK — suppress entirely. Discard any held deltas,
4159
+ // don't broadcast the final event, don't save to DB.
4160
+ if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) {
4161
+ return;
4162
+ }
4163
+
4164
+ // If we held deltas (buffer was a sentinel prefix), flush them before the final
4165
+ // so the browser has a response element ready for finalization.
4166
+ if (streamEntry?.held?.length > 0) {
4167
+ for (const h of streamEntry.held) this.broadcastToBrowsers(h);
4168
+ }
4169
+
4170
+ // Broadcast the final event, then persist.
4171
+ this.broadcastToBrowsers(rawData);
4172
+ this.saveAssistantMessage(sessionKey, message, seq);
4173
+ return;
4174
+ }
4175
+
4176
+ if (state === 'aborted' || state === 'error') {
4177
+ // Discard any held deltas silently — if stream was aborted while holding a
4178
+ // NO_REPLY prefix, there's nothing to show. Broadcast the terminal event normally.
4179
+ this.broadcastToBrowsers(rawData);
4180
+ }
4181
+
4065
4182
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
4066
4183
  }
4067
4184
 
@@ -4073,7 +4190,7 @@ export function createApp(config = {}) {
4073
4190
  const db = _getDb(parsed.workspace);
4074
4191
  const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
4075
4192
  if (!thread) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
4076
- let content = extractContent(message);
4193
+ let content = sanitizeAssistantContent(extractContent(message));
4077
4194
  if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
4078
4195
 
4079
4196
  // Attach media captured by the after_tool_call hook (MEDIA: lines from exec stdout).
@@ -4336,7 +4453,11 @@ export function createApp(config = {}) {
4336
4453
  ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
4337
4454
  const streams = [];
4338
4455
  for (const [sessionKey, state] of this.streamState.entries()) {
4339
- if (state.state === 'streaming') streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
4456
+ // Skip sessions in sentinel-hold state their buffer contains a NO_REPLY/HEARTBEAT_OK
4457
+ // prefix that should not be forwarded to reconnecting browsers.
4458
+ if (state.state === 'streaming' && !(state.held?.length > 0)) {
4459
+ streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
4460
+ }
4340
4461
  }
4341
4462
  if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
4342
4463
  }
@@ -4369,6 +4490,12 @@ export function createApp(config = {}) {
4369
4490
 
4370
4491
  // ── handleRequest (scoped to factory state) ────────────────────────────────
4371
4492
  async function _handleRequest(req, res) {
4493
+ const _wsName = req.headers?.['x-workspace'];
4494
+ const _requestDb = _wsName ? _getDb(_wsName) : _getActiveDb();
4495
+ return _requestDbStore.run(_requestDb, () => _handleRequestImplFactory(req, res));
4496
+ }
4497
+
4498
+ async function _handleRequestImplFactory(req, res) {
4372
4499
  const [urlPath, queryString] = (req.url || '/').split('?');
4373
4500
  const query = {};
4374
4501
  if (queryString) {