@clawchatsai/connector 0.0.94 → 0.0.96

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/dist/index.js CHANGED
@@ -39,6 +39,8 @@ let healthServer = null;
39
39
  let _stopRequested = false;
40
40
  /** Model IDs that have 'input' explicitly set without 'image' support. */
41
41
  let _imageRestrictedModels = [];
42
+ /** True if session.reset config risks wiping ClawChats history (daily reset or short idle). */
43
+ let _sessionResetWarning = false;
42
44
  let _uploadsDir = null;
43
45
  // ---------------------------------------------------------------------------
44
46
  // Config helpers
@@ -174,6 +176,22 @@ async function startClawChats(ctx, api, mediaStash) {
174
176
  ctx.logger.error('No gateway token available. Re-run: openclaw clawchats setup <token>');
175
177
  return;
176
178
  }
179
+ // Check session.reset config — warn if daily reset or short idle could wipe ClawChats history.
180
+ _sessionResetWarning = false;
181
+ try {
182
+ const sessionReset = gwCfg?.['session']?.['reset'];
183
+ if (sessionReset) {
184
+ const mode = sessionReset['mode'];
185
+ const idleMinutes = sessionReset['idleMinutes'];
186
+ if (mode === 'daily' || (mode === 'idle' && typeof idleMinutes === 'number' && idleMinutes < 43200)) {
187
+ _sessionResetWarning = true;
188
+ ctx.logger.warn(`[clawchats] session.reset may wipe chat history (mode=${mode}, idleMinutes=${idleMinutes ?? 'unset'}) — set mode=idle + idleMinutes=999999`);
189
+ }
190
+ }
191
+ }
192
+ catch {
193
+ // Non-fatal
194
+ }
177
195
  // Check for model definitions with 'input' set but missing 'image' — they silently drop attachments.
178
196
  _imageRestrictedModels = [];
179
197
  try {
@@ -489,6 +507,13 @@ function setupDataChannelHandler(dc, connectionId, ctx) {
489
507
  payload: JSON.stringify({ type: 'clawchats', event: 'image-capability-warning', models: _imageRestrictedModels }),
490
508
  }));
491
509
  }
510
+ // Warn if session.reset config risks wiping ClawChats history
511
+ if (_sessionResetWarning) {
512
+ dc.send(JSON.stringify({
513
+ type: 'gateway-event',
514
+ payload: JSON.stringify({ type: 'clawchats', event: 'session-reset-warning' }),
515
+ }));
516
+ }
492
517
  // Persist backup code changes if any were consumed
493
518
  if (authConfig.backupCodeHashes && config.backupCodeHashes) {
494
519
  config.backupCodeHashes = authConfig.backupCodeHashes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.94",
3
+ "version": "0.0.96",
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 }));