@clawchatsai/connector 0.0.35 → 0.0.37

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 (3) hide show
  1. package/dist/index.js +29 -2
  2. package/package.json +1 -1
  3. package/server.js +272 -0
package/dist/index.js CHANGED
@@ -313,6 +313,33 @@ async function stopClawChats(ctx) {
313
313
  ctx.logger.info('ClawChats service stopped');
314
314
  }
315
315
  // ---------------------------------------------------------------------------
316
+ // ---------------------------------------------------------------------------
317
+ // Gateway payload normalization
318
+ // ---------------------------------------------------------------------------
319
+ /**
320
+ * Normalize gateway-bound payloads before forwarding.
321
+ *
322
+ * Fixes image-only messages: some OpenClaw versions reject chat.send when
323
+ * the message body is empty, even if attachments are present (the empty-body
324
+ * guard checks MediaPath/MediaPaths but not inline base64 attachments).
325
+ * Injecting a minimal placeholder ensures the agent run proceeds.
326
+ */
327
+ function normalizeGatewayPayload(raw) {
328
+ try {
329
+ const parsed = JSON.parse(raw);
330
+ if (parsed.method === 'chat.send' &&
331
+ Array.isArray(parsed.params?.attachments) &&
332
+ parsed.params.attachments.length > 0 &&
333
+ !parsed.params.message?.trim()) {
334
+ parsed.params.message = '[Image]';
335
+ return JSON.stringify(parsed);
336
+ }
337
+ }
338
+ catch {
339
+ // Not JSON or unexpected shape — pass through unchanged
340
+ }
341
+ return raw;
342
+ }
316
343
  // DataChannel message handler (spec section 6.4)
317
344
  // ---------------------------------------------------------------------------
318
345
  function setupDataChannelHandler(dc, connectionId, ctx) {
@@ -455,7 +482,7 @@ function processAuthenticatedMessage(dc, connectionId, msg, ctx) {
455
482
  }
456
483
  case 'gateway-msg':
457
484
  if (app?.gatewayClient && typeof msg['payload'] === 'string') {
458
- app.gatewayClient.sendToGateway(msg['payload']);
485
+ app.gatewayClient.sendToGateway(normalizeGatewayPayload(msg['payload']));
459
486
  }
460
487
  break;
461
488
  case 'gateway-msg-chunk': {
@@ -485,7 +512,7 @@ function processAuthenticatedMessage(dc, connectionId, msg, ctx) {
485
512
  gatewayMsgChunkBuffers.delete(chunkId);
486
513
  const fullPayload = buf.chunks.join('');
487
514
  if (app?.gatewayClient) {
488
- app.gatewayClient.sendToGateway(fullPayload);
515
+ app.gatewayClient.sendToGateway(normalizeGatewayPayload(fullPayload));
489
516
  }
490
517
  }
491
518
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "type": "module",
5
5
  "description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
6
6
  "main": "dist/index.js",
package/server.js CHANGED
@@ -1064,6 +1064,26 @@ async function handleCreateMessage(req, res, params) {
1064
1064
 
1065
1065
  // Bump thread updated_at
1066
1066
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
1067
+
1068
+ // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
1069
+ if (body.role === 'user' && body.content) {
1070
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
1071
+ if (threadInfo?.title === 'New chat') {
1072
+ const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
1073
+ + (body.content.length > 40 ? '...' : '');
1074
+ if (heuristic) {
1075
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
1076
+ const activeWs = getWorkspaces().active;
1077
+ gatewayClient.broadcastToBrowsers(JSON.stringify({
1078
+ type: 'clawchats',
1079
+ event: 'thread-title-updated',
1080
+ threadId: params.id,
1081
+ workspace: activeWs,
1082
+ title: heuristic
1083
+ }));
1084
+ }
1085
+ }
1086
+ }
1067
1087
  }
1068
1088
 
1069
1089
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
@@ -2297,6 +2317,15 @@ async function handleRequest(req, res) {
2297
2317
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) {
2298
2318
  return handleContextFill(req, res, p);
2299
2319
  }
2320
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
2321
+ const db = getActiveDb();
2322
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
2323
+ if (!thread) return sendError(res, 404, 'Thread not found');
2324
+ // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
2325
+ const activeWs = getWorkspaces().active;
2326
+ gatewayClient.generateThreadTitle(db, p.id, activeWs);
2327
+ return send(res, 200, { ok: true });
2328
+ }
2300
2329
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) {
2301
2330
  return await handleUpload(req, res, p);
2302
2331
  }
@@ -2487,6 +2516,22 @@ class GatewayClient {
2487
2516
  this.streamState.delete(sessionKey);
2488
2517
  }
2489
2518
 
2519
+ // Intercept title generation responses (final, error, or aborted)
2520
+ if (sessionKey && sessionKey.includes('__clawchats_title_')) {
2521
+ if (state === 'final') {
2522
+ const content = extractContent(message);
2523
+ if (content && this.handleTitleResponse(sessionKey, content)) return;
2524
+ } else if (state === 'error' || state === 'aborted') {
2525
+ // Clean up pending entry by substring match — heuristic title stays
2526
+ if (this._pendingTitleGens) {
2527
+ for (const key of this._pendingTitleGens.keys()) {
2528
+ if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
2529
+ }
2530
+ }
2531
+ return;
2532
+ }
2533
+ }
2534
+
2490
2535
  // Save assistant messages on final
2491
2536
  if (state === 'final') {
2492
2537
  this.saveAssistantMessage(sessionKey, message, seq);
@@ -2598,6 +2643,16 @@ class GatewayClient {
2598
2643
  }));
2599
2644
 
2600
2645
  console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2646
+
2647
+ // Auto-generate AI title upgrade after first assistant response
2648
+ // Heuristic was already set on user message save; this fires the AI upgrade
2649
+ const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
2650
+ if (currentTitle && currentTitle !== 'New chat') {
2651
+ const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
2652
+ if (msgCount >= 2) {
2653
+ this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
2654
+ }
2655
+ }
2601
2656
  } catch (e) {
2602
2657
  console.error(`Failed to save assistant message:`, e.message);
2603
2658
  }
@@ -2630,6 +2685,125 @@ class GatewayClient {
2630
2685
  }
2631
2686
  }
2632
2687
 
2688
+ /**
2689
+ * Generate a title for a thread. Optionally sets a heuristic title immediately,
2690
+ * then fires an async AI title upgrade via the gateway.
2691
+ * @param {boolean} skipHeuristic - If true, skip heuristic step (already set on user message save)
2692
+ */
2693
+ generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
2694
+ const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
2695
+ if (!thread) return;
2696
+
2697
+ // Skip if AI title gen already in progress for this thread
2698
+ const titleKey = `__clawchats_title_${threadId}`;
2699
+ if (this._pendingTitleGens?.has(titleKey)) return;
2700
+
2701
+ // Get first user message for heuristic title
2702
+ const firstUserMsg = db.prepare(
2703
+ "SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1"
2704
+ ).get(threadId);
2705
+ if (!firstUserMsg?.content) return;
2706
+
2707
+ // Step 1: Heuristic title (immediate) — skip if already set on user message save
2708
+ if (!skipHeuristic) {
2709
+ const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim()
2710
+ + (firstUserMsg.content.length > 40 ? '...' : '');
2711
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
2712
+
2713
+ this.broadcastToBrowsers(JSON.stringify({
2714
+ type: 'clawchats',
2715
+ event: 'thread-title-updated',
2716
+ threadId,
2717
+ workspace,
2718
+ title: heuristic
2719
+ }));
2720
+ }
2721
+
2722
+ // Step 2: AI title upgrade (async, best-effort)
2723
+ const messages = db.prepare(
2724
+ 'SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6'
2725
+ ).all(threadId);
2726
+
2727
+ // Need at least 2 messages (user + assistant) for meaningful AI title
2728
+ if (messages.length < 2) return;
2729
+
2730
+ const conversation = messages.map(m => {
2731
+ const role = m.role === 'user' ? 'User' : 'Assistant';
2732
+ const content = m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content;
2733
+ return `${role}: ${content}`;
2734
+ }).join('\n\n');
2735
+
2736
+ const prompt = `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`;
2737
+
2738
+ const reqId = `title-${threadId}-${Date.now()}`;
2739
+ if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
2740
+ this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
2741
+
2742
+ // Timeout cleanup — prevent unbounded map growth if gateway never responds
2743
+ setTimeout(() => {
2744
+ if (this._pendingTitleGens?.has(titleKey)) {
2745
+ this._pendingTitleGens.delete(titleKey);
2746
+ console.log(`Title gen timeout for ${threadId} — keeping heuristic title`);
2747
+ }
2748
+ }, 30000);
2749
+
2750
+ this.sendToGateway(JSON.stringify({
2751
+ type: 'req',
2752
+ id: reqId,
2753
+ method: 'chat.send',
2754
+ params: {
2755
+ sessionKey: titleKey,
2756
+ message: prompt,
2757
+ deliver: false,
2758
+ idempotencyKey: reqId
2759
+ }
2760
+ }));
2761
+ }
2762
+
2763
+ /**
2764
+ * Handle AI title response from gateway.
2765
+ * Returns true if the event was consumed (was a title gen response).
2766
+ */
2767
+ handleTitleResponse(sessionKey, content) {
2768
+ if (!this._pendingTitleGens) return false;
2769
+ // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
2770
+ // Find the matching pending entry by substring
2771
+ let matchKey = null, pending = null;
2772
+ for (const [key, val] of this._pendingTitleGens) {
2773
+ if (sessionKey === key || sessionKey.includes(key)) {
2774
+ matchKey = key;
2775
+ pending = val;
2776
+ break;
2777
+ }
2778
+ }
2779
+ if (!pending) return false;
2780
+
2781
+ this._pendingTitleGens.delete(matchKey);
2782
+
2783
+ let title = content.trim()
2784
+ .replace(/^["']|["']$/g, '')
2785
+ .replace(/^Title:\s*/i, '')
2786
+ .replace(/\n.*/s, '')
2787
+ .trim();
2788
+
2789
+ if (title.length > 50) title = title.substring(0, 47) + '...';
2790
+ if (title.length === 0 || title.length >= 100) return true; // bad response, keep heuristic
2791
+
2792
+ const db = getDb(pending.workspace);
2793
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
2794
+
2795
+ this.broadcastToBrowsers(JSON.stringify({
2796
+ type: 'clawchats',
2797
+ event: 'thread-title-updated',
2798
+ threadId: pending.threadId,
2799
+ workspace: pending.workspace,
2800
+ title
2801
+ }));
2802
+
2803
+ console.log(`AI title generated for ${pending.threadId}: "${title}"`);
2804
+ return true;
2805
+ }
2806
+
2633
2807
  handleAgentEvent(payload) {
2634
2808
  const { runId, stream, data, sessionKey } = payload;
2635
2809
  if (!runId) return;
@@ -3390,6 +3564,26 @@ export function createApp(config = {}) {
3390
3564
  } else {
3391
3565
  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());
3392
3566
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
3567
+
3568
+ // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
3569
+ if (body.role === 'user' && body.content) {
3570
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
3571
+ if (threadInfo?.title === 'New chat') {
3572
+ const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
3573
+ + (body.content.length > 40 ? '...' : '');
3574
+ if (heuristic) {
3575
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
3576
+ const activeWs = _getWorkspaces().active;
3577
+ _gatewayClient.broadcastToBrowsers(JSON.stringify({
3578
+ type: 'clawchats',
3579
+ event: 'thread-title-updated',
3580
+ threadId: params.id,
3581
+ workspace: activeWs,
3582
+ title: heuristic
3583
+ }));
3584
+ }
3585
+ }
3586
+ }
3393
3587
  }
3394
3588
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
3395
3589
  if (message && message.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
@@ -3703,6 +3897,22 @@ export function createApp(config = {}) {
3703
3897
  return;
3704
3898
  }
3705
3899
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
3900
+
3901
+ // Intercept title generation responses (final, error, or aborted)
3902
+ if (sessionKey && sessionKey.includes('__clawchats_title_')) {
3903
+ if (state === 'final') {
3904
+ const content = extractContent(message);
3905
+ if (content && this.handleTitleResponse(sessionKey, content)) return;
3906
+ } else if (state === 'error' || state === 'aborted') {
3907
+ if (this._pendingTitleGens) {
3908
+ for (const key of this._pendingTitleGens.keys()) {
3909
+ if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
3910
+ }
3911
+ }
3912
+ return;
3913
+ }
3914
+ }
3915
+
3706
3916
  if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3707
3917
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
3708
3918
  }
@@ -3758,6 +3968,15 @@ export function createApp(config = {}) {
3758
3968
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3759
3969
  this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
3760
3970
  console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
3971
+
3972
+ // Auto-generate AI title upgrade after first assistant response
3973
+ const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
3974
+ if (currentTitle && currentTitle !== 'New chat') {
3975
+ const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
3976
+ if (msgCount >= 2) {
3977
+ this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
3978
+ }
3979
+ }
3761
3980
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3762
3981
  }
3763
3982
 
@@ -3779,6 +3998,50 @@ export function createApp(config = {}) {
3779
3998
  } catch (e) { console.error(`Failed to save error marker:`, e.message); }
3780
3999
  }
3781
4000
 
4001
+ generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
4002
+ const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
4003
+ if (!thread) return;
4004
+ const titleKey = `__clawchats_title_${threadId}`;
4005
+ if (this._pendingTitleGens?.has(titleKey)) return;
4006
+ const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
4007
+ if (!firstUserMsg?.content) return;
4008
+
4009
+ if (!skipHeuristic) {
4010
+ const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
4011
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
4012
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
4013
+ }
4014
+
4015
+ const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
4016
+ if (messages.length < 2) return;
4017
+ const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
4018
+ const prompt = `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`;
4019
+ const reqId = `title-${threadId}-${Date.now()}`;
4020
+ if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
4021
+ this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
4022
+ setTimeout(() => { if (this._pendingTitleGens?.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId} — keeping heuristic title`); } }, 30000);
4023
+ this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: prompt, deliver: false, idempotencyKey: reqId } }));
4024
+ }
4025
+
4026
+ handleTitleResponse(sessionKey, content) {
4027
+ if (!this._pendingTitleGens) return false;
4028
+ // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
4029
+ let matchKey = null, pending = null;
4030
+ for (const [key, val] of this._pendingTitleGens) {
4031
+ if (sessionKey === key || sessionKey.includes(key)) { matchKey = key; pending = val; break; }
4032
+ }
4033
+ if (!pending) return false;
4034
+ this._pendingTitleGens.delete(matchKey);
4035
+ let title = content.trim().replace(/^["']|["']$/g, '').replace(/^Title:\s*/i, '').replace(/\n.*/s, '').trim();
4036
+ if (title.length > 50) title = title.substring(0, 47) + '...';
4037
+ if (title.length === 0 || title.length >= 100) return true;
4038
+ const db = _getDb(pending.workspace);
4039
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
4040
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: pending.threadId, workspace: pending.workspace, title }));
4041
+ console.log(`AI title generated for ${pending.threadId}: "${title}"`);
4042
+ return true;
4043
+ }
4044
+
3782
4045
  handleAgentEvent(payload) {
3783
4046
  const { runId, stream, data, sessionKey } = payload;
3784
4047
  if (!runId) return;
@@ -4122,6 +4385,15 @@ export function createApp(config = {}) {
4122
4385
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await _handleCreateMessage(req, res, p);
4123
4386
  if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return _handleDeleteMessage(req, res, p);
4124
4387
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return _handleContextFill(req, res, p);
4388
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
4389
+ const db = _getActiveDb();
4390
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
4391
+ if (!thread) return sendError(res, 404, 'Thread not found');
4392
+ // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
4393
+ const activeWs = _getWorkspaces().active;
4394
+ _gatewayClient.generateThreadTitle(db, p.id, activeWs);
4395
+ return send(res, 200, { ok: true });
4396
+ }
4125
4397
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await _handleUpload(req, res, p);
4126
4398
  if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return _handleGetIntelligence(req, res, p);
4127
4399
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await _handleSaveIntelligence(req, res, p);