@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.
- package/package.json +1 -1
- package/server/gateway.js +44 -33
package/package.json
CHANGED
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
|
-
//
|
|
123
|
-
//
|
|
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 => ``).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
|
-
|
|
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
|
-
|
|
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 }));
|