@clawchatsai/connector 0.0.94 → 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 +42 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.94",
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) {
@@ -308,25 +320,6 @@ export class GatewayClient {
308
320
  if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
309
321
  const log = this.activityLogs.get(runId);
310
322
 
311
- if (stream === 'assistant') {
312
- // Derive narration text from streamState.buffer (same source as thoughtStartOffset).
313
- // This eliminates the _assistantTextOffset race: no longer depends on cumulative
314
- // stream:assistant text which can arrive out of order relative to stream:tool start.
315
- const streamEntry = this.streamState.get(sessionKey);
316
- if (!streamEntry) return;
317
- const narrationStart = log._lastNarrationStart ?? 0;
318
- const narrationText = streamEntry.buffer.substring(narrationStart);
319
- if (!narrationText) return;
320
- let seg = log._currentAssistantSegment;
321
- if (!seg || seg._sealed) {
322
- seg = { type: 'assistant', timestamp: Date.now(), text: narrationText, _sealed: false };
323
- log._currentAssistantSegment = seg;
324
- log.steps.push(seg);
325
- } else {
326
- seg.text = narrationText;
327
- }
328
- return;
329
- }
330
323
  if (stream === 'thinking') {
331
324
  let step = log.steps.find(s => s.type === 'thinking');
332
325
  if (step) step.text = data?.text || '';
@@ -336,7 +329,22 @@ export class GatewayClient {
336
329
  if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) { log._lastThinkingBroadcast = now; this._broadcastActivityUpdate(runId, log); }
337
330
  }
338
331
  if (stream === 'tool') {
339
- 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) {
340
348
  log._currentAssistantSegment._sealed = true;
341
349
  }
342
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') || '') : '';
@@ -350,12 +358,15 @@ export class GatewayClient {
350
358
  // so the response area always shows only the most recent thought / final answer.
351
359
  const streamEntry = this.streamState.get(sessionKey);
352
360
  if (streamEntry && log._parsed) {
353
- 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;
354
369
  log._lastNarrationStart = streamEntry.buffer.length; // align activity log segments with buffer
355
- // Signal the frontend to clear the response area — new thought is starting.
356
- // streaming-reset fires before activity-updated so the typing-indicator is visible
357
- // when startIndicatorTimer attaches to it in the allToolsDone handler.
358
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-reset', threadId: log._parsed.threadId, workspace: log._parsed.workspace }));
359
370
  }
360
371
  } else if (data?.phase === 'update') {
361
372
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
@@ -368,8 +379,6 @@ export class GatewayClient {
368
379
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
369
380
  log._currentAssistantSegment._sealed = true;
370
381
  }
371
- const idx = log.steps.findLastIndex(s => s.type === 'assistant');
372
- if (idx >= 0) log.steps.splice(idx, 1);
373
382
  writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
374
383
  // Store finalized state — streaming-end (handleChatEvent) will pick it up and carry it
375
384
  // as one atomic payload. Do NOT broadcast activity-updated here anymore.
@@ -431,7 +440,7 @@ export class GatewayClient {
431
440
  if (state.state === 'streaming' && !(state.held?.length > 0)) {
432
441
  const parsed = parseSessionKey(sessionKey);
433
442
  // Include both old shape (sessionKey/buffer) and new shape (workspace/content) for dual-emit compat
434
- 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) } : {}) });
435
444
  }
436
445
  }
437
446
  if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));