@clawchatsai/connector 0.0.36 → 0.0.38

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.js +355 -88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
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
@@ -296,6 +296,26 @@ function getActiveDb() {
296
296
  return getDb(getWorkspaces().active);
297
297
  }
298
298
 
299
+ let _globalDb = null;
300
+ function getGlobalDb() {
301
+ if (_globalDb) return _globalDb;
302
+ fs.mkdirSync(DATA_DIR, { recursive: true });
303
+ const dbPath = path.join(DATA_DIR, 'global.db');
304
+ _globalDb = new Database(dbPath);
305
+ _globalDb.pragma('journal_mode = WAL');
306
+ _globalDb.exec(`
307
+ CREATE TABLE IF NOT EXISTS custom_emojis (
308
+ name TEXT NOT NULL,
309
+ pack TEXT NOT NULL DEFAULT 'slackmojis',
310
+ url TEXT NOT NULL,
311
+ mime_type TEXT,
312
+ created_at INTEGER DEFAULT (strftime('%s','now')),
313
+ PRIMARY KEY (name, pack)
314
+ )
315
+ `);
316
+ return _globalDb;
317
+ }
318
+
299
319
  function closeDb(workspaceName) {
300
320
  const db = dbCache.get(workspaceName);
301
321
  if (db) {
@@ -309,6 +329,7 @@ function closeAllDbs() {
309
329
  db.close();
310
330
  }
311
331
  dbCache.clear();
332
+ if (_globalDb) { _globalDb.close(); _globalDb = null; }
312
333
  }
313
334
 
314
335
  function migrate(db) {
@@ -1064,6 +1085,26 @@ async function handleCreateMessage(req, res, params) {
1064
1085
 
1065
1086
  // Bump thread updated_at
1066
1087
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
1088
+
1089
+ // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
1090
+ if (body.role === 'user' && body.content) {
1091
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
1092
+ if (threadInfo?.title === 'New chat') {
1093
+ const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
1094
+ + (body.content.length > 40 ? '...' : '');
1095
+ if (heuristic) {
1096
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
1097
+ const activeWs = getWorkspaces().active;
1098
+ gatewayClient.broadcastToBrowsers(JSON.stringify({
1099
+ type: 'clawchats',
1100
+ event: 'thread-title-updated',
1101
+ threadId: params.id,
1102
+ workspace: activeWs,
1103
+ title: heuristic
1104
+ }));
1105
+ }
1106
+ }
1107
+ }
1067
1108
  }
1068
1109
 
1069
1110
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
@@ -2070,28 +2111,11 @@ async function handleRequest(req, res) {
2070
2111
 
2071
2112
  // Custom emoji listing (no auth — public like static assets)
2072
2113
  if (method === 'GET' && urlPath === '/api/emoji') {
2073
- const emojiDir = path.join(__dirname, 'emoji');
2074
- if (!fs.existsSync(emojiDir)) {
2075
- res.writeHead(200, { 'Content-Type': 'application/json' });
2076
- return res.end('[]');
2077
- }
2078
2114
  try {
2079
- const packs = fs.readdirSync(emojiDir).filter(d =>
2080
- fs.statSync(path.join(emojiDir, d)).isDirectory()
2081
- );
2082
- const result = [];
2083
- for (const pack of packs) {
2084
- const packDir = path.join(emojiDir, pack);
2085
- const files = fs.readdirSync(packDir).filter(f =>
2086
- /\.(png|gif|webp|jpg|jpeg)$/i.test(f)
2087
- );
2088
- for (const file of files) {
2089
- const name = file.replace(/\.[^.]+$/, '');
2090
- result.push({ name, pack, path: `/emoji/${pack}/${file}` });
2091
- }
2092
- }
2115
+ const db = getGlobalDb();
2116
+ const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
2093
2117
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
2094
- return res.end(JSON.stringify(result));
2118
+ return res.end(JSON.stringify(rows));
2095
2119
  } catch (e) {
2096
2120
  res.writeHead(500, { 'Content-Type': 'application/json' });
2097
2121
  return res.end(JSON.stringify({ error: e.message }));
@@ -2141,7 +2165,7 @@ async function handleRequest(req, res) {
2141
2165
  }
2142
2166
  }
2143
2167
 
2144
- // Download emoji from URL and save to emoji/ directory
2168
+ // Add custom emoji (store URL in global.db)
2145
2169
  if (method === 'POST' && urlPath === '/api/emoji/add') {
2146
2170
  if (!checkAuth(req, res)) return;
2147
2171
  try {
@@ -2150,45 +2174,40 @@ async function handleRequest(req, res) {
2150
2174
  res.writeHead(400, { 'Content-Type': 'application/json' });
2151
2175
  return res.end(JSON.stringify({ error: 'Missing url or name' }));
2152
2176
  }
2153
- const targetPack = pack || 'custom';
2154
- const emojiDir = path.join(__dirname, 'emoji', targetPack);
2155
- if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
2177
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2178
+ const targetPack = pack || 'slackmojis';
2179
+ // Determine mime type from URL extension
2180
+ const urlLower = url.split('?')[0].toLowerCase();
2181
+ let mimeType = 'image/png';
2182
+ if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
2183
+ else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
2184
+ else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
2185
+
2186
+ const db = getGlobalDb();
2187
+ db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)')
2188
+ .run(safeName, targetPack, url, mimeType);
2156
2189
 
2157
- // Follow redirects and download
2158
- const https = await import('https');
2159
- const http = await import('http');
2160
- const download = (downloadUrl, redirects = 0) => new Promise((resolve, reject) => {
2161
- if (redirects > 5) return reject(new Error('Too many redirects'));
2162
- const mod = downloadUrl.startsWith('https') ? https.default : http.default;
2163
- mod.get(downloadUrl, (resp) => {
2164
- if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
2165
- return resolve(download(resp.headers.location, redirects + 1));
2166
- }
2167
- if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
2168
- const chunks = [];
2169
- resp.on('data', chunk => chunks.push(chunk));
2170
- resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
2171
- }).on('error', reject);
2172
- });
2190
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2191
+ return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
2192
+ } catch (e) {
2193
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2194
+ return res.end(JSON.stringify({ error: e.message }));
2195
+ }
2196
+ }
2173
2197
 
2174
- const { buffer, contentType } = await download(url);
2175
- // Determine extension from content-type or URL
2176
- let ext = 'png';
2177
- if (contentType.includes('gif')) ext = 'gif';
2178
- else if (contentType.includes('webp')) ext = 'webp';
2179
- else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
2180
- else {
2181
- const urlExt = url.split('?')[0].split('.').pop().toLowerCase();
2182
- if (['png', 'gif', 'webp', 'jpg', 'jpeg'].includes(urlExt)) ext = urlExt;
2198
+ // Delete custom emoji
2199
+ if (method === 'DELETE' && urlPath === '/api/emoji') {
2200
+ if (!checkAuth(req, res)) return;
2201
+ try {
2202
+ const { name, pack } = await parseBody(req);
2203
+ if (!name || !pack) {
2204
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2205
+ return res.end(JSON.stringify({ error: 'Missing name or pack' }));
2183
2206
  }
2184
-
2185
- const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2186
- const filePath = path.join(emojiDir, `${safeName}.${ext}`);
2187
- fs.writeFileSync(filePath, buffer);
2188
-
2189
- const result = { name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` };
2207
+ const db = getGlobalDb();
2208
+ db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
2190
2209
  res.writeHead(200, { 'Content-Type': 'application/json' });
2191
- return res.end(JSON.stringify(result));
2210
+ return res.end(JSON.stringify({ ok: true }));
2192
2211
  } catch (e) {
2193
2212
  res.writeHead(500, { 'Content-Type': 'application/json' });
2194
2213
  return res.end(JSON.stringify({ error: e.message }));
@@ -2297,6 +2316,15 @@ async function handleRequest(req, res) {
2297
2316
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) {
2298
2317
  return handleContextFill(req, res, p);
2299
2318
  }
2319
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
2320
+ const db = getActiveDb();
2321
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
2322
+ if (!thread) return sendError(res, 404, 'Thread not found');
2323
+ // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
2324
+ const activeWs = getWorkspaces().active;
2325
+ gatewayClient.generateThreadTitle(db, p.id, activeWs);
2326
+ return send(res, 200, { ok: true });
2327
+ }
2300
2328
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) {
2301
2329
  return await handleUpload(req, res, p);
2302
2330
  }
@@ -2487,6 +2515,22 @@ class GatewayClient {
2487
2515
  this.streamState.delete(sessionKey);
2488
2516
  }
2489
2517
 
2518
+ // Intercept title generation responses (final, error, or aborted)
2519
+ if (sessionKey && sessionKey.includes('__clawchats_title_')) {
2520
+ if (state === 'final') {
2521
+ const content = extractContent(message);
2522
+ if (content && this.handleTitleResponse(sessionKey, content)) return;
2523
+ } else if (state === 'error' || state === 'aborted') {
2524
+ // Clean up pending entry by substring match — heuristic title stays
2525
+ if (this._pendingTitleGens) {
2526
+ for (const key of this._pendingTitleGens.keys()) {
2527
+ if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
2528
+ }
2529
+ }
2530
+ return;
2531
+ }
2532
+ }
2533
+
2490
2534
  // Save assistant messages on final
2491
2535
  if (state === 'final') {
2492
2536
  this.saveAssistantMessage(sessionKey, message, seq);
@@ -2598,6 +2642,16 @@ class GatewayClient {
2598
2642
  }));
2599
2643
 
2600
2644
  console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2645
+
2646
+ // Auto-generate AI title upgrade after first assistant response
2647
+ // Heuristic was already set on user message save; this fires the AI upgrade
2648
+ const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
2649
+ if (currentTitle && currentTitle !== 'New chat') {
2650
+ const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
2651
+ if (msgCount >= 2) {
2652
+ this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
2653
+ }
2654
+ }
2601
2655
  } catch (e) {
2602
2656
  console.error(`Failed to save assistant message:`, e.message);
2603
2657
  }
@@ -2630,6 +2684,125 @@ class GatewayClient {
2630
2684
  }
2631
2685
  }
2632
2686
 
2687
+ /**
2688
+ * Generate a title for a thread. Optionally sets a heuristic title immediately,
2689
+ * then fires an async AI title upgrade via the gateway.
2690
+ * @param {boolean} skipHeuristic - If true, skip heuristic step (already set on user message save)
2691
+ */
2692
+ generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
2693
+ const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
2694
+ if (!thread) return;
2695
+
2696
+ // Skip if AI title gen already in progress for this thread
2697
+ const titleKey = `__clawchats_title_${threadId}`;
2698
+ if (this._pendingTitleGens?.has(titleKey)) return;
2699
+
2700
+ // Get first user message for heuristic title
2701
+ const firstUserMsg = db.prepare(
2702
+ "SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1"
2703
+ ).get(threadId);
2704
+ if (!firstUserMsg?.content) return;
2705
+
2706
+ // Step 1: Heuristic title (immediate) — skip if already set on user message save
2707
+ if (!skipHeuristic) {
2708
+ const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim()
2709
+ + (firstUserMsg.content.length > 40 ? '...' : '');
2710
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
2711
+
2712
+ this.broadcastToBrowsers(JSON.stringify({
2713
+ type: 'clawchats',
2714
+ event: 'thread-title-updated',
2715
+ threadId,
2716
+ workspace,
2717
+ title: heuristic
2718
+ }));
2719
+ }
2720
+
2721
+ // Step 2: AI title upgrade (async, best-effort)
2722
+ const messages = db.prepare(
2723
+ 'SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6'
2724
+ ).all(threadId);
2725
+
2726
+ // Need at least 2 messages (user + assistant) for meaningful AI title
2727
+ if (messages.length < 2) return;
2728
+
2729
+ const conversation = messages.map(m => {
2730
+ const role = m.role === 'user' ? 'User' : 'Assistant';
2731
+ const content = m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content;
2732
+ return `${role}: ${content}`;
2733
+ }).join('\n\n');
2734
+
2735
+ 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:`;
2736
+
2737
+ const reqId = `title-${threadId}-${Date.now()}`;
2738
+ if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
2739
+ this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
2740
+
2741
+ // Timeout cleanup — prevent unbounded map growth if gateway never responds
2742
+ setTimeout(() => {
2743
+ if (this._pendingTitleGens?.has(titleKey)) {
2744
+ this._pendingTitleGens.delete(titleKey);
2745
+ console.log(`Title gen timeout for ${threadId} — keeping heuristic title`);
2746
+ }
2747
+ }, 30000);
2748
+
2749
+ this.sendToGateway(JSON.stringify({
2750
+ type: 'req',
2751
+ id: reqId,
2752
+ method: 'chat.send',
2753
+ params: {
2754
+ sessionKey: titleKey,
2755
+ message: prompt,
2756
+ deliver: false,
2757
+ idempotencyKey: reqId
2758
+ }
2759
+ }));
2760
+ }
2761
+
2762
+ /**
2763
+ * Handle AI title response from gateway.
2764
+ * Returns true if the event was consumed (was a title gen response).
2765
+ */
2766
+ handleTitleResponse(sessionKey, content) {
2767
+ if (!this._pendingTitleGens) return false;
2768
+ // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
2769
+ // Find the matching pending entry by substring
2770
+ let matchKey = null, pending = null;
2771
+ for (const [key, val] of this._pendingTitleGens) {
2772
+ if (sessionKey === key || sessionKey.includes(key)) {
2773
+ matchKey = key;
2774
+ pending = val;
2775
+ break;
2776
+ }
2777
+ }
2778
+ if (!pending) return false;
2779
+
2780
+ this._pendingTitleGens.delete(matchKey);
2781
+
2782
+ let title = content.trim()
2783
+ .replace(/^["']|["']$/g, '')
2784
+ .replace(/^Title:\s*/i, '')
2785
+ .replace(/\n.*/s, '')
2786
+ .trim();
2787
+
2788
+ if (title.length > 50) title = title.substring(0, 47) + '...';
2789
+ if (title.length === 0 || title.length >= 100) return true; // bad response, keep heuristic
2790
+
2791
+ const db = getDb(pending.workspace);
2792
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
2793
+
2794
+ this.broadcastToBrowsers(JSON.stringify({
2795
+ type: 'clawchats',
2796
+ event: 'thread-title-updated',
2797
+ threadId: pending.threadId,
2798
+ workspace: pending.workspace,
2799
+ title
2800
+ }));
2801
+
2802
+ console.log(`AI title generated for ${pending.threadId}: "${title}"`);
2803
+ return true;
2804
+ }
2805
+
2633
2806
  handleAgentEvent(payload) {
2634
2807
  const { runId, stream, data, sessionKey } = payload;
2635
2808
  if (!runId) return;
@@ -3390,6 +3563,26 @@ export function createApp(config = {}) {
3390
3563
  } else {
3391
3564
  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
3565
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
3566
+
3567
+ // Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
3568
+ if (body.role === 'user' && body.content) {
3569
+ const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
3570
+ if (threadInfo?.title === 'New chat') {
3571
+ const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
3572
+ + (body.content.length > 40 ? '...' : '');
3573
+ if (heuristic) {
3574
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
3575
+ const activeWs = _getWorkspaces().active;
3576
+ _gatewayClient.broadcastToBrowsers(JSON.stringify({
3577
+ type: 'clawchats',
3578
+ event: 'thread-title-updated',
3579
+ threadId: params.id,
3580
+ workspace: activeWs,
3581
+ title: heuristic
3582
+ }));
3583
+ }
3584
+ }
3585
+ }
3393
3586
  }
3394
3587
  const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
3395
3588
  if (message && message.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
@@ -3703,6 +3896,22 @@ export function createApp(config = {}) {
3703
3896
  return;
3704
3897
  }
3705
3898
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
3899
+
3900
+ // Intercept title generation responses (final, error, or aborted)
3901
+ if (sessionKey && sessionKey.includes('__clawchats_title_')) {
3902
+ if (state === 'final') {
3903
+ const content = extractContent(message);
3904
+ if (content && this.handleTitleResponse(sessionKey, content)) return;
3905
+ } else if (state === 'error' || state === 'aborted') {
3906
+ if (this._pendingTitleGens) {
3907
+ for (const key of this._pendingTitleGens.keys()) {
3908
+ if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
3909
+ }
3910
+ }
3911
+ return;
3912
+ }
3913
+ }
3914
+
3706
3915
  if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3707
3916
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
3708
3917
  }
@@ -3758,6 +3967,15 @@ export function createApp(config = {}) {
3758
3967
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3759
3968
  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
3969
  console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
3970
+
3971
+ // Auto-generate AI title upgrade after first assistant response
3972
+ const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
3973
+ if (currentTitle && currentTitle !== 'New chat') {
3974
+ const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
3975
+ if (msgCount >= 2) {
3976
+ this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
3977
+ }
3978
+ }
3761
3979
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3762
3980
  }
3763
3981
 
@@ -3779,6 +3997,50 @@ export function createApp(config = {}) {
3779
3997
  } catch (e) { console.error(`Failed to save error marker:`, e.message); }
3780
3998
  }
3781
3999
 
4000
+ generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
4001
+ const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
4002
+ if (!thread) return;
4003
+ const titleKey = `__clawchats_title_${threadId}`;
4004
+ if (this._pendingTitleGens?.has(titleKey)) return;
4005
+ const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
4006
+ if (!firstUserMsg?.content) return;
4007
+
4008
+ if (!skipHeuristic) {
4009
+ const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
4010
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
4011
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
4012
+ }
4013
+
4014
+ const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
4015
+ if (messages.length < 2) return;
4016
+ const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
4017
+ 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:`;
4018
+ const reqId = `title-${threadId}-${Date.now()}`;
4019
+ if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
4020
+ this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
4021
+ setTimeout(() => { if (this._pendingTitleGens?.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId} — keeping heuristic title`); } }, 30000);
4022
+ this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: prompt, deliver: false, idempotencyKey: reqId } }));
4023
+ }
4024
+
4025
+ handleTitleResponse(sessionKey, content) {
4026
+ if (!this._pendingTitleGens) return false;
4027
+ // Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
4028
+ let matchKey = null, pending = null;
4029
+ for (const [key, val] of this._pendingTitleGens) {
4030
+ if (sessionKey === key || sessionKey.includes(key)) { matchKey = key; pending = val; break; }
4031
+ }
4032
+ if (!pending) return false;
4033
+ this._pendingTitleGens.delete(matchKey);
4034
+ let title = content.trim().replace(/^["']|["']$/g, '').replace(/^Title:\s*/i, '').replace(/\n.*/s, '').trim();
4035
+ if (title.length > 50) title = title.substring(0, 47) + '...';
4036
+ if (title.length === 0 || title.length >= 100) return true;
4037
+ const db = _getDb(pending.workspace);
4038
+ db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
4039
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: pending.threadId, workspace: pending.workspace, title }));
4040
+ console.log(`AI title generated for ${pending.threadId}: "${title}"`);
4041
+ return true;
4042
+ }
4043
+
3782
4044
  handleAgentEvent(payload) {
3783
4045
  const { runId, stream, data, sessionKey } = payload;
3784
4046
  if (!runId) return;
@@ -4022,17 +4284,11 @@ export function createApp(config = {}) {
4022
4284
 
4023
4285
  // Custom emoji listing (no auth)
4024
4286
  if (method === 'GET' && urlPath === '/api/emoji') {
4025
- const emojiDir = path.join(__dirname, 'emoji');
4026
- if (!fs.existsSync(emojiDir)) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('[]'); }
4027
4287
  try {
4028
- const packs = fs.readdirSync(emojiDir).filter(d => fs.statSync(path.join(emojiDir, d)).isDirectory());
4029
- const result = [];
4030
- for (const pack of packs) {
4031
- const files = fs.readdirSync(path.join(emojiDir, pack)).filter(f => /\.(png|gif|webp|jpg|jpeg)$/i.test(f));
4032
- for (const file of files) result.push({ name: file.replace(/\.[^.]+$/, ''), pack, path: `/emoji/${pack}/${file}` });
4033
- }
4288
+ const db = getGlobalDb();
4289
+ const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
4034
4290
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
4035
- return res.end(JSON.stringify(result));
4291
+ return res.end(JSON.stringify(rows));
4036
4292
  } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4037
4293
  }
4038
4294
 
@@ -4061,35 +4317,34 @@ export function createApp(config = {}) {
4061
4317
 
4062
4318
  if (!_checkAuth(req, res)) return;
4063
4319
 
4064
- // Download emoji from URL (auth required)
4320
+ // Add custom emoji (auth required)
4065
4321
  if (method === 'POST' && urlPath === '/api/emoji/add') {
4066
4322
  try {
4067
4323
  const { url, name, pack } = await parseBody(req);
4068
4324
  if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
4069
- const targetPack = pack || 'custom';
4070
- const emojiDir = path.join(__dirname, 'emoji', targetPack);
4071
- if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
4072
- const https = await import('https');
4073
- const http = await import('http');
4074
- const download = (dlUrl, redirects = 0) => new Promise((resolve, reject) => {
4075
- if (redirects > 5) return reject(new Error('Too many redirects'));
4076
- const mod = dlUrl.startsWith('https') ? https.default : http.default;
4077
- mod.get(dlUrl, (resp) => {
4078
- if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) return resolve(download(resp.headers.location, redirects + 1));
4079
- if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
4080
- const chunks = []; resp.on('data', c => chunks.push(c)); resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
4081
- }).on('error', reject);
4082
- });
4083
- const { buffer, contentType } = await download(url);
4084
- let ext = 'png';
4085
- if (contentType.includes('gif')) ext = 'gif';
4086
- else if (contentType.includes('webp')) ext = 'webp';
4087
- else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
4088
- else { const u = url.split('?')[0].split('.').pop().toLowerCase(); if (['png','gif','webp','jpg','jpeg'].includes(u)) ext = u; }
4089
4325
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
4090
- fs.writeFileSync(path.join(emojiDir, `${safeName}.${ext}`), buffer);
4326
+ const targetPack = pack || 'slackmojis';
4327
+ const urlLower = url.split('?')[0].toLowerCase();
4328
+ let mimeType = 'image/png';
4329
+ if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
4330
+ else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
4331
+ else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
4332
+ const db = getGlobalDb();
4333
+ db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)').run(safeName, targetPack, url, mimeType);
4091
4334
  res.writeHead(200, { 'Content-Type': 'application/json' });
4092
- return res.end(JSON.stringify({ name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` }));
4335
+ return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
4336
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4337
+ }
4338
+
4339
+ // Delete custom emoji (auth required)
4340
+ if (method === 'DELETE' && urlPath === '/api/emoji') {
4341
+ try {
4342
+ const { name, pack } = await parseBody(req);
4343
+ if (!name || !pack) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing name or pack' })); }
4344
+ const db = getGlobalDb();
4345
+ db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
4346
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4347
+ return res.end(JSON.stringify({ ok: true }));
4093
4348
  } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4094
4349
  }
4095
4350
 
@@ -4122,6 +4377,15 @@ export function createApp(config = {}) {
4122
4377
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await _handleCreateMessage(req, res, p);
4123
4378
  if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return _handleDeleteMessage(req, res, p);
4124
4379
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return _handleContextFill(req, res, p);
4380
+ if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
4381
+ const db = _getActiveDb();
4382
+ const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
4383
+ if (!thread) return sendError(res, 404, 'Thread not found');
4384
+ // Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
4385
+ const activeWs = _getWorkspaces().active;
4386
+ _gatewayClient.generateThreadTitle(db, p.id, activeWs);
4387
+ return send(res, 200, { ok: true });
4388
+ }
4125
4389
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await _handleUpload(req, res, p);
4126
4390
  if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return _handleGetIntelligence(req, res, p);
4127
4391
  if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await _handleSaveIntelligence(req, res, p);
@@ -4231,6 +4495,9 @@ if (isDirectRun) {
4231
4495
 
4232
4496
  // Connect to gateway
4233
4497
  app.gatewayClient.connect();
4498
+
4499
+ // Initialize global DB (custom emojis, etc.)
4500
+ getGlobalDb();
4234
4501
  });
4235
4502
 
4236
4503
  // Graceful shutdown