@clawchatsai/connector 0.0.92 → 0.0.94

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 +141 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.92",
3
+ "version": "0.0.94",
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
@@ -23,6 +23,17 @@ export class GatewayClient {
23
23
  this.activityLogs = new Map();
24
24
  this._pendingTitleGens = new Map();
25
25
 
26
+ // On startup: mark any pending activity log messages from previous crashed/restarted sessions
27
+ // as interrupted. Without this, stale pending=true rows survive gateway restarts and cause
28
+ // phantom "thinking..." indicators in the browser on reconnect.
29
+ try {
30
+ const workspaces = this.getWorkspaces();
31
+ for (const wsName of Object.keys(workspaces.workspaces || {})) {
32
+ const db = this.getDb(wsName);
33
+ if (db) db.prepare(`UPDATE messages SET content = '[Response interrupted]', metadata = json_remove(metadata, '$.pending') WHERE content = '' AND json_extract(metadata, '$.pending') = 1`).run();
34
+ }
35
+ } catch (e) { console.log('[clawchats] startup pending cleanup skipped:', e.message); }
36
+
26
37
  // Periodically clean up stale activity logs (>10 min old)
27
38
  setInterval(() => {
28
39
  const cutoff = Date.now() - 10 * 60 * 1000;
@@ -70,12 +81,32 @@ export class GatewayClient {
70
81
 
71
82
  handleChatEvent(params, rawData) {
72
83
  const { sessionKey, state, message, seq } = params;
84
+ const bareSessionKey = sessionKey.replace(/^agent:[^:]+:/, '');
85
+
86
+ // --- Utility session routing (connector-side) ---
87
+ // Emits semantic utility-response events. Also forwards rawData for dual-emit compatibility
88
+ // during transition — browser handleChatEvent guards these and defers to utility-response handler.
89
+ const UTILITY_SESSIONS = { '__clawchats_summarizer': 'summarizer', '__clawchats_semantic': 'semantic', '__clawchats_intelligence': 'intelligence' };
90
+ const utilityName = UTILITY_SESSIONS[bareSessionKey];
91
+ if (utilityName) {
92
+ const content = extractContent(message);
93
+ if (state === 'delta' && content) {
94
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'utility-response', session: utilityName, state: 'delta', content }));
95
+ } else if (state === 'final' || state === 'aborted') {
96
+ if (content) this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'utility-response', session: utilityName, state: state === 'final' ? 'final' : 'aborted', content }));
97
+ } else if (state === 'error') {
98
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'utility-response', session: utilityName, state: 'error', errorMessage: message?.error || 'Unknown error' }));
99
+ }
100
+ this.broadcastToBrowsers(rawData); // dual-emit: browser guard skips raw handling for utility sessions
101
+ return;
102
+ }
73
103
 
104
+ // --- Delta path ---
74
105
  if (state === 'delta') {
75
106
  const parsed = parseSessionKey(sessionKey);
76
107
  if (parsed) {
77
108
  const existing = this.streamState.get(sessionKey) || { buffer: '', threadId: parsed.threadId, state: 'streaming', held: [] };
78
- existing.buffer += extractContent(message);
109
+ existing.buffer = extractContent(message); // gateway sends full cumulative content per delta, not chunks
79
110
  if (isSilentReplyPrefix(existing.buffer, 'NO_REPLY') || isSilentReplyPrefix(existing.buffer, 'HEARTBEAT_OK')) {
80
111
  existing.held = existing.held || [];
81
112
  existing.held.push(rawData);
@@ -87,32 +118,76 @@ export class GatewayClient {
87
118
  existing.held = [];
88
119
  }
89
120
  this.streamState.set(sessionKey, existing);
121
+ 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.
124
+ const visibleContent = existing.buffer.substring(existing.thoughtStartOffset || 0);
125
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-delta', threadId: parsed.threadId, workspace: parsed.workspace, content: visibleContent }));
126
+ } else {
127
+ this.broadcastToBrowsers(rawData); // non-clawchats session (discord, telegram, etc.)
90
128
  }
91
- this.broadcastToBrowsers(rawData);
92
129
  return;
93
130
  }
94
131
 
132
+ // --- End states ---
95
133
  const streamEntry = this.streamState.get(sessionKey);
96
134
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
97
135
 
136
+ // Title sessions are handled server-side — intercept and skip browser delivery
98
137
  if (sessionKey?.includes('__clawchats_title_')) {
99
138
  if (state === 'final') { const content = extractContent(message); if (content && this.handleTitleResponse(sessionKey, content)) return; }
100
139
  else if (state === 'error' || state === 'aborted') { for (const key of this._pendingTitleGens.keys()) { if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; } } return; }
140
+ return;
101
141
  }
102
142
 
103
143
  if (state === 'final') {
104
144
  const rawContent = extractContent(message);
105
- if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) return;
145
+ const parsed = parseSessionKey(sessionKey);
146
+ if (isSilentReplyExact(rawContent, 'NO_REPLY') || isSilentReplyExact(rawContent, 'HEARTBEAT_OK')) {
147
+ this._cleanupSilentPending(sessionKey);
148
+ if (parsed) this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-end', threadId: parsed.threadId, workspace: parsed.workspace, reason: 'silent' }));
149
+ return;
150
+ }
106
151
  if (streamEntry?.held?.length > 0) for (const h of streamEntry.held) this.broadcastToBrowsers(h);
107
- this.broadcastToBrowsers(rawData);
108
- this.saveAssistantMessage(sessionKey, message, seq);
152
+ this.saveAssistantMessage(sessionKey, message, seq, streamEntry?.thoughtStartOffset); // persist + broadcast message-saved first
153
+ this.broadcastToBrowsers(rawData); // dual-emit: raw final event
154
+ if (parsed) {
155
+ const activity = this._popActivityLogForSession(sessionKey);
156
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-end', threadId: parsed.threadId, workspace: parsed.workspace, reason: 'complete', ...(activity || {}) }));
157
+ }
109
158
  return;
110
159
  }
111
- if (state === 'aborted' || state === 'error') this.broadcastToBrowsers(rawData);
112
- if (state === 'error') this.saveErrorMarker(sessionKey, message);
160
+ if (state === 'aborted') {
161
+ const parsed = parseSessionKey(sessionKey);
162
+ this.broadcastToBrowsers(rawData); // dual-emit
163
+ if (parsed) {
164
+ const activity = this._popActivityLogForSession(sessionKey);
165
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-end', threadId: parsed.threadId, workspace: parsed.workspace, reason: 'aborted', ...(activity || {}) }));
166
+ }
167
+ } else if (state === 'error') {
168
+ const parsed = parseSessionKey(sessionKey);
169
+ this.saveErrorMarker(sessionKey, message);
170
+ this.broadcastToBrowsers(rawData); // dual-emit
171
+ if (parsed) {
172
+ const activity = this._popActivityLogForSession(sessionKey);
173
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'streaming-end', threadId: parsed.threadId, workspace: parsed.workspace, reason: 'error', errorMessage: message?.error || 'Unknown error', ...(activity || {}) }));
174
+ }
175
+ }
113
176
  }
114
177
 
115
- saveAssistantMessage(sessionKey, message, seq) {
178
+ _cleanupSilentPending(sessionKey) {
179
+ const parsed = parseSessionKey(sessionKey);
180
+ if (!parsed) return;
181
+ const ws = this.getWorkspaces();
182
+ if (!ws.workspaces[parsed.workspace]) return;
183
+ const db = this.getDb(parsed.workspace);
184
+ if (!db) return;
185
+ const result = db.prepare(`DELETE FROM messages WHERE thread_id = ? AND role = 'assistant' AND json_extract(metadata, '$.pending') = 1`).run(parsed.threadId);
186
+ if (result.changes > 0) console.log(`[clawchats] silent-reply cleanup: removed ${result.changes} pending message(s) for ${parsed.threadId}`);
187
+ // Caller broadcasts streaming-end { reason: 'silent' } — no event emitted here.
188
+ }
189
+
190
+ saveAssistantMessage(sessionKey, message, seq, thoughtStartOffset = 0) {
116
191
  const parsed = parseSessionKey(sessionKey);
117
192
  if (!parsed) return;
118
193
  const ws = this.getWorkspaces();
@@ -120,7 +195,10 @@ export class GatewayClient {
120
195
  const db = this.getDb(parsed.workspace);
121
196
  if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId)) { console.log(`Ignoring response for deleted thread: ${parsed.threadId}`); return; }
122
197
 
123
- let content = sanitizeAssistantContent(extractContent(message));
198
+ // Trim to final-answer portion: only text after the last tool call offset.
199
+ // thoughtStartOffset is passed in from handleChatEvent (captured before streamState.delete).
200
+ // Intermediate narration lives in activityLog steps; message.content is the clean final answer.
201
+ let content = sanitizeAssistantContent(extractContent(message).substring(thoughtStartOffset));
124
202
 
125
203
  // Attach media (MEDIA: lines from exec stdout captured by after_tool_call hook).
126
204
  // Stash is read before the empty-content guard — media-only responses (no text) must not be dropped.
@@ -182,6 +260,8 @@ export class GatewayClient {
182
260
  const now = Date.now();
183
261
  try {
184
262
  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);
263
+ // Clear stale pending flag so browsers reloading the chat don't re-derive "thinking..." state.
264
+ 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);
185
265
  } catch (e) { console.error('Failed to save error marker:', e.message); }
186
266
  }
187
267
 
@@ -229,17 +309,21 @@ export class GatewayClient {
229
309
  const log = this.activityLogs.get(runId);
230
310
 
231
311
  if (stream === 'assistant') {
232
- const text = data?.text || '';
233
- if (text) {
234
- const offset = log._assistantTextOffset || 0;
235
- let seg = log._currentAssistantSegment;
236
- if (!seg || seg._sealed) {
237
- seg = { type: 'assistant', timestamp: Date.now(), text: text.substring(offset), _sealed: false };
238
- log._currentAssistantSegment = seg;
239
- log.steps.push(seg);
240
- } else {
241
- seg.text = text.substring(offset);
242
- }
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;
243
327
  }
244
328
  return;
245
329
  }
@@ -253,9 +337,7 @@ export class GatewayClient {
253
337
  }
254
338
  if (stream === 'tool') {
255
339
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
256
- const seg = log._currentAssistantSegment;
257
- seg._sealed = true;
258
- log._assistantTextOffset = (log._assistantTextOffset || 0) + seg.text.length;
340
+ log._currentAssistantSegment._sealed = true;
259
341
  }
260
342
  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') || '') : '';
261
343
  const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta || (argsMeta ? String(argsMeta) : undefined), isError: data?.isError || false };
@@ -263,6 +345,18 @@ export class GatewayClient {
263
345
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
264
346
  if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
265
347
  else { step.phase = 'done'; log.steps.push(step); }
348
+ // Advance the visible content offset to the current buffer end.
349
+ // Future streaming-delta broadcasts and the saved message content start from here,
350
+ // so the response area always shows only the most recent thought / final answer.
351
+ const streamEntry = this.streamState.get(sessionKey);
352
+ if (streamEntry && log._parsed) {
353
+ streamEntry.thoughtStartOffset = streamEntry.buffer.length;
354
+ 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
+ }
266
360
  } else if (data?.phase === 'update') {
267
361
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
268
362
  if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
@@ -272,20 +366,31 @@ export class GatewayClient {
272
366
  }
273
367
  if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
274
368
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
275
- const seg = log._currentAssistantSegment;
276
- seg._sealed = true;
277
- log._assistantTextOffset = (log._assistantTextOffset || 0) + seg.text.length;
369
+ log._currentAssistantSegment._sealed = true;
278
370
  }
279
371
  const idx = log.steps.findLastIndex(s => s.type === 'assistant');
280
372
  if (idx >= 0) log.steps.splice(idx, 1);
281
373
  writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
282
- // Broadcast final state so browser can clean up any in-flight timers (e.g. aborted runs)
283
- if (log._parsed && log._messageId) {
284
- const cleanSteps = log.steps.map(s => { const c = { ...s }; delete c._sealed; return c; });
285
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'activity-updated', workspace: log._parsed.workspace, threadId: log._parsed.threadId, messageId: log._messageId, activityLog: cleanSteps, activitySummary: generateActivitySummary(log.steps), final: true }));
374
+ // Store finalized state streaming-end (handleChatEvent) will pick it up and carry it
375
+ // as one atomic payload. Do NOT broadcast activity-updated here anymore.
376
+ const cleanSteps = log.steps.map(s => { const c = { ...s }; delete c._sealed; return c; });
377
+ log.finalized = true;
378
+ log.finalSteps = cleanSteps;
379
+ log.finalSummary = generateActivitySummary(log.steps);
380
+ // Note: activityLogs entry is kept until _popActivityLogForSession cleans it up
381
+ }
382
+ }
383
+
384
+ // Returns and removes the finalized activity log for a given sessionKey, or null if none.
385
+ // Called by handleChatEvent so streaming-end carries the final activity state atomically.
386
+ _popActivityLogForSession(sessionKey) {
387
+ for (const [runId, log] of this.activityLogs) {
388
+ if (log.sessionKey === sessionKey && log.finalized) {
389
+ this.activityLogs.delete(runId);
390
+ return { activityLog: log.finalSteps, activitySummary: log.finalSummary };
286
391
  }
287
- this.activityLogs.delete(runId);
288
392
  }
393
+ return null;
289
394
  }
290
395
 
291
396
  _broadcastActivityUpdate(runId, log) {
@@ -323,7 +428,11 @@ export class GatewayClient {
323
428
  ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
324
429
  const streams = [];
325
430
  for (const [sessionKey, state] of this.streamState.entries()) {
326
- if (state.state === 'streaming' && !(state.held?.length > 0)) streams.push({ sessionKey, threadId: state.threadId, buffer: state.buffer });
431
+ if (state.state === 'streaming' && !(state.held?.length > 0)) {
432
+ const parsed = parseSessionKey(sessionKey);
433
+ // 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 } : {}) });
435
+ }
327
436
  }
328
437
  if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
329
438
  }