@clawchatsai/connector 0.0.91 → 0.0.93
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/controllers/messages.js +1 -3
- package/server/gateway.js +150 -21
- package/server/index.js +1 -1
package/package.json
CHANGED
|
@@ -33,9 +33,7 @@ export class MessageController {
|
|
|
33
33
|
const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
|
|
34
34
|
const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
|
|
35
35
|
if (existing) {
|
|
36
|
-
|
|
37
|
-
db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status, body.content, metadata || existing.metadata, body.id);
|
|
38
|
-
}
|
|
36
|
+
db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status || existing.status, body.content, metadata || existing.metadata, body.id);
|
|
39
37
|
} else {
|
|
40
38
|
db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(body.id, params.id, body.role, body.content, body.status || 'sent', metadata, body.seq || null, body.timestamp, Date.now());
|
|
41
39
|
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
|
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:[^:]+:/, '');
|
|
73
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
|
+
}
|
|
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
|
|
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
|
-
|
|
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.
|
|
108
|
-
this.
|
|
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'
|
|
112
|
-
|
|
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
|
-
|
|
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,10 +195,13 @@ 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
|
-
|
|
124
|
-
|
|
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));
|
|
125
202
|
|
|
126
|
-
// Attach media (MEDIA: lines from exec stdout captured by after_tool_call hook)
|
|
203
|
+
// Attach media (MEDIA: lines from exec stdout captured by after_tool_call hook).
|
|
204
|
+
// Stash is read before the empty-content guard — media-only responses (no text) must not be dropped.
|
|
127
205
|
const pendingPaths = this.mediaStash?.get(sessionKey) ?? [];
|
|
128
206
|
this.mediaStash?.delete(sessionKey);
|
|
129
207
|
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','avif','tiff']);
|
|
@@ -134,9 +212,12 @@ export class GatewayClient {
|
|
|
134
212
|
if (IMAGE_EXTS.has(ext)) imagePaths.push(p);
|
|
135
213
|
else pendingAttachments.push({ path: p, name: p.split('/').pop(), type: AUDIO_EXTS.has(ext) ? 'audio' : 'file' });
|
|
136
214
|
}
|
|
137
|
-
if (imagePaths.length > 0) content = content
|
|
215
|
+
if (imagePaths.length > 0) content = (content?.trimEnd() || '') + '\n\n' + imagePaths.map(p => ``).join('\n');
|
|
138
216
|
if (pendingPaths.length > 0) console.log(`[clawchats] media-attach: ${imagePaths.length} image(s), ${pendingAttachments.length} attachment(s) for ${sessionKey}`);
|
|
139
217
|
|
|
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
|
+
|
|
140
221
|
const now = Date.now();
|
|
141
222
|
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);
|
|
142
223
|
let messageId;
|
|
@@ -226,11 +307,21 @@ export class GatewayClient {
|
|
|
226
307
|
const log = this.activityLogs.get(runId);
|
|
227
308
|
|
|
228
309
|
if (stream === 'assistant') {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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;
|
|
234
325
|
}
|
|
235
326
|
return;
|
|
236
327
|
}
|
|
@@ -243,13 +334,27 @@ export class GatewayClient {
|
|
|
243
334
|
if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) { log._lastThinkingBroadcast = now; this._broadcastActivityUpdate(runId, log); }
|
|
244
335
|
}
|
|
245
336
|
if (stream === 'tool') {
|
|
246
|
-
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed)
|
|
337
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
|
|
338
|
+
log._currentAssistantSegment._sealed = true;
|
|
339
|
+
}
|
|
247
340
|
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') || '') : '';
|
|
248
341
|
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 };
|
|
249
342
|
if (data?.phase === 'result') {
|
|
250
343
|
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
|
|
251
344
|
if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
|
|
252
345
|
else { step.phase = 'done'; log.steps.push(step); }
|
|
346
|
+
// Advance the visible content offset to the current buffer end.
|
|
347
|
+
// Future streaming-delta broadcasts and the saved message content start from here,
|
|
348
|
+
// so the response area always shows only the most recent thought / final answer.
|
|
349
|
+
const streamEntry = this.streamState.get(sessionKey);
|
|
350
|
+
if (streamEntry && log._parsed) {
|
|
351
|
+
streamEntry.thoughtStartOffset = streamEntry.buffer.length;
|
|
352
|
+
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
|
+
}
|
|
253
358
|
} else if (data?.phase === 'update') {
|
|
254
359
|
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
|
|
255
360
|
if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
|
|
@@ -258,12 +363,32 @@ export class GatewayClient {
|
|
|
258
363
|
this._broadcastActivityUpdate(runId, log);
|
|
259
364
|
}
|
|
260
365
|
if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
|
|
261
|
-
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed)
|
|
366
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
|
|
367
|
+
log._currentAssistantSegment._sealed = true;
|
|
368
|
+
}
|
|
262
369
|
const idx = log.steps.findLastIndex(s => s.type === 'assistant');
|
|
263
370
|
if (idx >= 0) log.steps.splice(idx, 1);
|
|
264
371
|
writeActivityToDb(this.getDb, this.broadcastToBrowsers.bind(this), runId, log);
|
|
265
|
-
|
|
372
|
+
// Store finalized state — streaming-end (handleChatEvent) will pick it up and carry it
|
|
373
|
+
// as one atomic payload. Do NOT broadcast activity-updated here anymore.
|
|
374
|
+
const cleanSteps = log.steps.map(s => { const c = { ...s }; delete c._sealed; return c; });
|
|
375
|
+
log.finalized = true;
|
|
376
|
+
log.finalSteps = cleanSteps;
|
|
377
|
+
log.finalSummary = generateActivitySummary(log.steps);
|
|
378
|
+
// Note: activityLogs entry is kept until _popActivityLogForSession cleans it up
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Returns and removes the finalized activity log for a given sessionKey, or null if none.
|
|
383
|
+
// Called by handleChatEvent so streaming-end carries the final activity state atomically.
|
|
384
|
+
_popActivityLogForSession(sessionKey) {
|
|
385
|
+
for (const [runId, log] of this.activityLogs) {
|
|
386
|
+
if (log.sessionKey === sessionKey && log.finalized) {
|
|
387
|
+
this.activityLogs.delete(runId);
|
|
388
|
+
return { activityLog: log.finalSteps, activitySummary: log.finalSummary };
|
|
389
|
+
}
|
|
266
390
|
}
|
|
391
|
+
return null;
|
|
267
392
|
}
|
|
268
393
|
|
|
269
394
|
_broadcastActivityUpdate(runId, log) {
|
|
@@ -301,7 +426,11 @@ export class GatewayClient {
|
|
|
301
426
|
ws.send(JSON.stringify({ type: 'clawchats', event: 'gateway-status', connected: this.connected }));
|
|
302
427
|
const streams = [];
|
|
303
428
|
for (const [sessionKey, state] of this.streamState.entries()) {
|
|
304
|
-
if (state.state === 'streaming' && !(state.held?.length > 0))
|
|
429
|
+
if (state.state === 'streaming' && !(state.held?.length > 0)) {
|
|
430
|
+
const parsed = parseSessionKey(sessionKey);
|
|
431
|
+
// 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 } : {}) });
|
|
433
|
+
}
|
|
305
434
|
}
|
|
306
435
|
if (streams.length > 0) ws.send(JSON.stringify({ type: 'clawchats', event: 'stream-sync', streams }));
|
|
307
436
|
}
|
package/server/index.js
CHANGED
|
@@ -81,7 +81,7 @@ export function createApp(config = {}) {
|
|
|
81
81
|
const { getWorkspaces, setWorkspaces } = createWorkspaceStore(WORKSPACES_FILE);
|
|
82
82
|
|
|
83
83
|
const debugLogger = new DebugLogger(DATA_DIR);
|
|
84
|
-
const mediaStash = new Map();
|
|
84
|
+
const mediaStash = config.mediaStash ?? new Map();
|
|
85
85
|
|
|
86
86
|
const memoryConfig = discoverMemoryConfig(config.memoryEnv || {});
|
|
87
87
|
const memoryProvider = createMemoryProvider(memoryConfig);
|