@clawchatsai/connector 0.0.93 → 0.0.95

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server/gateway.js +44 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.93",
3
+ "version": "0.0.95",
4
4
  "type": "module",
5
5
  "description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
6
6
  "main": "dist/index.js",
package/server/gateway.js CHANGED
@@ -106,6 +106,7 @@ export class GatewayClient {
106
106
  const parsed = parseSessionKey(sessionKey);
107
107
  if (parsed) {
108
108
  const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
109
+ const prevLen = existing.buffer.length; // capture before update — used to advance thoughtStartOffset on first post-tool delta
109
110
  existing.buffer = extractContent(message); // gateway sends full cumulative content per delta, not chunks
110
111
  if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
111
112
  existing.held = existing.held || [];
@@ -119,8 +120,16 @@ export class GatewayClient {
119
120
  }
120
121
  this.streamState.set(sessionKey, existing);
121
122
  this.broadcastToBrowsers(rawData); // dual-emit: raw chat event (old protocol)
122
- // Broadcast only the final-answer portion (text after the last tool call offset).
123
- // thoughtStartOffset is updated on each tool result; 0 means no tools yet full buffer.
123
+ // On the first delta after a tool result, advance the segment offset and signal the
124
+ // frontend to clear the bubble. Both happen here right before the new-segment delta
125
+ // so the clear and fill are atomic from the browser's perspective.
126
+ if (existing.pendingReset) {
127
+ existing.thoughtStartOffset = prevLen;
128
+ existing.pendingReset = false;
129
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-reset', threadId: parsed.threadId, workspace: parsed.workspace }));
130
+ }
131
+ // Broadcast only the current-segment portion (text after the last tool call offset).
132
+ // thoughtStartOffset advances on first post-tool delta; 0 means no tools yet → full buffer.
124
133
  const visibleContent = existing.buffer.substring(existing.thoughtStartOffset || 0);
125
134
  this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-delta', threadId: parsed.threadId, workspace: parsed.workspace, content: visibleContent }));
126
135
  } else {
@@ -215,11 +224,14 @@ export class GatewayClient {
215
224
  if (imagePaths.length > 0) content = (content?.trimEnd() || '') + '\n\n' + imagePaths.map(p => `![image](${p})`).join('\n');
216
225
  if (pendingPaths.length > 0) console.log(`[clawchats] media-attach: ${imagePaths.length} image(s), ${pendingAttachments.length} attachment(s) for ${sessionKey}`);
217
226
 
218
- // Skip only if there is truly nothing to save — no text and no pending media.
219
- if (!content?.trim() && pendingPaths.length === 0) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
220
-
221
227
  const now = Date.now();
222
228
  const pendingMsg = db.prepare(`SELECT id, metadata FROM messages WHERE thread_id = ? AND role = 'assistant' AND json_extract(metadata, '$.pending') = 1 ORDER BY timestamp DESC LIMIT 1`).get(parsed.threadId);
229
+
230
+ // Skip only if there is truly nothing to save AND no pending row to resolve.
231
+ // If a pending row exists, always proceed to update it (even with empty content) so the
232
+ // pending flag is cleared. Without this, tool-only responses (no post-tool text) leave
233
+ // pending=true forever, and the startup cleanup marks them '[Response interrupted]'.
234
+ if (!content?.trim() && pendingPaths.length === 0 && !pendingMsg) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
223
235
  let messageId;
224
236
 
225
237
  if (pendingMsg) {
@@ -260,6 +272,8 @@ export class GatewayClient {
260
272
  const now = Date.now();
261
273
  try {
262
274
  db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(`gw-error-${parsed.threadId}-${now}`, parsed.threadId, 'system', `[error] ${message?.error || message?.content || 'Unknown error'}`, 'sent', '{"transient":true}', now, now);
275
+ // Clear stale pending flag so browsers reloading the chat don't re-derive "thinking..." state.
276
+ db.prepare("UPDATE messages SET metadata = json_remove(metadata, '$.pending') WHERE thread_id = ? AND role = 'assistant' AND json_extract(metadata, '$.pending') = 1").run(parsed.threadId);
263
277
  } catch (e) { console.error('Failed to save error marker:', e.message); }
264
278
  }
265
279
 
@@ -306,25 +320,6 @@ export class GatewayClient {
306
320
  if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
307
321
  const log = this.activityLogs.get(runId);
308
322
 
309
- if (stream === 'assistant') {
310
- // Derive narration text from streamState.buffer (same source as thoughtStartOffset).
311
- // This eliminates the _assistantTextOffset race: no longer depends on cumulative
312
- // stream:assistant text which can arrive out of order relative to stream:tool start.
313
- const streamEntry = this.streamState.get(sessionKey);
314
- if (!streamEntry) return;
315
- const narrationStart = log._lastNarrationStart ?? 0;
316
- const narrationText = streamEntry.buffer.substring(narrationStart);
317
- if (!narrationText) return;
318
- let seg = log._currentAssistantSegment;
319
- if (!seg || seg._sealed) {
320
- seg = { type: 'assistant', timestamp: Date.now(), text: narrationText, _sealed: false };
321
- log._currentAssistantSegment = seg;
322
- log.steps.push(seg);
323
- } else {
324
- seg.text = narrationText;
325
- }
326
- return;
327
- }
328
323
  if (stream === 'thinking') {
329
324
  let step = log.steps.find(s => s.type === 'thinking');
330
325
  if (step) step.text = data?.text || '';
@@ -334,7 +329,22 @@ export class GatewayClient {
334
329
  if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) { log._lastThinkingBroadcast = now; this._broadcastActivityUpdate(runId, log); }
335
330
  }
336
331
  if (stream === 'tool') {
337
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
332
+ // At phase:start the gateway has already flushed the text buffer, so the buffer is
333
+ // complete. Capture the full inter-tool narration now — no race, no stale snapshots.
334
+ // For result/update phases, just defensively seal any still-open segment.
335
+ if (data?.phase !== 'result' && data?.phase !== 'update') {
336
+ const streamEntry = this.streamState.get(sessionKey);
337
+ const narrationStart = log._lastNarrationStart ?? 0;
338
+ const narrationText = streamEntry ? streamEntry.buffer.substring(narrationStart) : '';
339
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
340
+ if (narrationText.trim()) log._currentAssistantSegment.text = narrationText;
341
+ log._currentAssistantSegment._sealed = true;
342
+ } else if (narrationText.trim()) {
343
+ const seg = { type: 'assistant', timestamp: Date.now(), text: narrationText, _sealed: true };
344
+ log._currentAssistantSegment = seg;
345
+ log.steps.push(seg);
346
+ }
347
+ } else if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
338
348
  log._currentAssistantSegment._sealed = true;
339
349
  }
340
350
  const argsMeta = data?.args ? (data.args.command || data.args.path || data.args.query || data.args.url || Object.values(data.args).find(v => typeof v === 'string') || '') : '';
@@ -348,12 +358,15 @@ export class GatewayClient {
348
358
  // so the response area always shows only the most recent thought / final answer.
349
359
  const streamEntry = this.streamState.get(sessionKey);
350
360
  if (streamEntry && log._parsed) {
351
- streamEntry.thoughtStartOffset = streamEntry.buffer.length;
361
+ // Don't advance thoughtStartOffset or broadcast streaming-reset yet.
362
+ // Wait for the first post-tool delta before doing either:
363
+ // (a) No post-tool text: thoughtStartOffset stays at the current segment start,
364
+ // bubble retains the last segment text as the final answer.
365
+ // (b) Post-tool text arrives: delta handler advances the offset and broadcasts
366
+ // streaming-reset immediately before the first new-segment delta, so the
367
+ // bubble clears at exactly the right moment.
368
+ streamEntry.pendingReset = true;
352
369
  log._lastNarrationStart = streamEntry.buffer.length; // align activity log segments with buffer
353
- // Signal the frontend to clear the response area — new thought is starting.
354
- // streaming-reset fires before activity-updated so the typing-indicator is visible
355
- // when startIndicatorTimer attaches to it in the allToolsDone handler.
356
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-reset', threadId: log._parsed.threadId, workspace: log._parsed.workspace }));
357
370
  }
358
371
  } else if (data?.phase === 'update') {
359
372
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
@@ -366,8 +379,6 @@ export class GatewayClient {
366
379
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
367
380
  log._currentAssistantSegment._sealed = true;
368
381
  }
369
- const idx = log.steps.findLastIndex(s => s.type === 'assistant');
370
- if (idx >= 0) log.steps.splice(idx, 1);
371
382
  writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
372
383
  // Store finalized state — streaming-end (handleChatEvent) will pick it up and carry it
373
384
  // as one atomic payload. Do NOT broadcast activity-updated here anymore.
@@ -429,7 +440,7 @@ export class GatewayClient {
429
440
  if (state.state === 'streaming' && !(state.held?.length > 0)) {
430
441
  const parsed = parseSessionKey(sessionKey);
431
442
  // Include both old shape (sessionKey/buffer) and new shape (workspace/content) for dual-emit compat
432
- streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer, ...(parsed ? { workspace: parsed.workspace, content: state.buffer } : {}) });
443
+ streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer, ...(parsed ? { workspace: parsed.workspace, content: state.buffer.substring(state.thoughtStartOffset || 0) } : {}) });
433
444
  }
434
445
  }
435
446
  if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));