@clawchatsai/connector 0.0.29 → 0.0.31

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
@@ -488,15 +488,33 @@ async function handleRpcMessage(dc, msg, ctx) {
488
488
  };
489
489
  try {
490
490
  const response = await dispatchRpc(rpcReq, app.handleRequest);
491
+ // For binary content types (images, audio, etc.), wrap as _binary envelope
492
+ // so the browser transport can reconstruct a proper Blob with correct MIME type
493
+ const contentType = response.headers['content-type'] || '';
494
+ const isBinaryResponse = /^(image|audio|video|application\/octet-stream|application\/pdf)/.test(contentType);
495
+ let responseBody;
496
+ if (isBinaryResponse && response.rawBody) {
497
+ // Encode raw bytes as base64 in a _binary envelope.
498
+ // The transport layer on the browser side already handles this format,
499
+ // reconstructing a proper Blob with the correct MIME type.
500
+ responseBody = {
501
+ _binary: true,
502
+ contentType,
503
+ data: response.rawBody.toString('base64'),
504
+ };
505
+ }
506
+ else {
507
+ responseBody = response.body;
508
+ }
491
509
  const responseMsg = {
492
510
  type: 'rpc-res',
493
511
  id: response.id,
494
512
  status: response.status,
495
- body: response.body,
513
+ body: responseBody,
496
514
  };
497
515
  const responseStr = JSON.stringify(responseMsg);
498
516
  if (responseStr.length > MAX_DC_MESSAGE_SIZE) {
499
- sendChunked(dc, response.id, response.status, response.body);
517
+ sendChunked(dc, response.id, response.status, JSON.stringify(responseBody));
500
518
  }
501
519
  else {
502
520
  dc.send(responseStr);
package/dist/shim.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface RpcResponse {
20
20
  status: number;
21
21
  headers: Record<string, string>;
22
22
  body: string;
23
+ rawBody?: Buffer;
23
24
  }
24
25
  type HandleRequestFn = (req: FakeReq, res: FakeRes) => void | Promise<void>;
25
26
  /**
package/dist/shim.js CHANGED
@@ -150,5 +150,6 @@ export async function dispatchRpc(rpc, handleRequest) {
150
150
  status: result.status,
151
151
  headers: result.headers,
152
152
  body: tryJsonParse(result.body),
153
+ rawBody: result.body, // Preserve raw buffer for binary encoding
153
154
  };
154
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
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
@@ -368,13 +368,6 @@ function migrate(db) {
368
368
  `);
369
369
  db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
370
370
 
371
- // Migration: add type column for segment model (text/tool/thinking)
372
- try {
373
- db.exec("ALTER TABLE messages ADD COLUMN type TEXT DEFAULT 'text'");
374
- } catch (e) {
375
- // Column already exists — ignore
376
- }
377
-
378
371
  // FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
379
372
  // so check if it exists first
380
373
  const hasFts = db.prepare(
@@ -2044,16 +2037,17 @@ async function handleRequest(req, res) {
2044
2037
  const isIcon = urlPath.startsWith('/icons/');
2045
2038
  const isLib = urlPath.startsWith('/lib/');
2046
2039
  const isFrontend = urlPath.startsWith('/frontend/');
2040
+ const isEmoji = urlPath.startsWith('/emoji/');
2047
2041
  const isConfig = urlPath === '/config.js';
2048
2042
  const staticPath = fileName ? path.join(__dirname, fileName)
2049
- : (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1))
2043
+ : (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1))
2050
2044
  : null;
2051
2045
  if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
2052
2046
  const ext = path.extname(staticPath).toLowerCase();
2053
2047
  const mimeMap = {
2054
2048
  '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
2055
2049
  '.json': 'application/json', '.ico': 'image/x-icon',
2056
- '.png': 'image/png', '.svg': 'image/svg+xml',
2050
+ '.png': 'image/png', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp',
2057
2051
  };
2058
2052
  const ct = mimeMap[ext] || 'application/octet-stream';
2059
2053
  const stat = fs.statSync(staticPath);
@@ -2072,6 +2066,133 @@ async function handleRequest(req, res) {
2072
2066
  return handleServeUpload(req, res, p);
2073
2067
  }
2074
2068
 
2069
+ // Custom emoji listing (no auth — public like static assets)
2070
+ if (method === 'GET' && urlPath === '/api/emoji') {
2071
+ const emojiDir = path.join(__dirname, 'emoji');
2072
+ if (!fs.existsSync(emojiDir)) {
2073
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2074
+ return res.end('[]');
2075
+ }
2076
+ try {
2077
+ const packs = fs.readdirSync(emojiDir).filter(d =>
2078
+ fs.statSync(path.join(emojiDir, d)).isDirectory()
2079
+ );
2080
+ const result = [];
2081
+ for (const pack of packs) {
2082
+ const packDir = path.join(emojiDir, pack);
2083
+ const files = fs.readdirSync(packDir).filter(f =>
2084
+ /\.(png|gif|webp|jpg|jpeg)$/i.test(f)
2085
+ );
2086
+ for (const file of files) {
2087
+ const name = file.replace(/\.[^.]+$/, '');
2088
+ result.push({ name, pack, path: `/emoji/${pack}/${file}` });
2089
+ }
2090
+ }
2091
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
2092
+ return res.end(JSON.stringify(result));
2093
+ } catch (e) {
2094
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2095
+ return res.end(JSON.stringify({ error: e.message }));
2096
+ }
2097
+ }
2098
+
2099
+ // Search slackmojis.com (scrapes HTML search since JSON API has no search)
2100
+ if (method === 'GET' && urlPath === '/api/emoji/search') {
2101
+ const q = query.q || '';
2102
+ if (!q) {
2103
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2104
+ return res.end(JSON.stringify({ error: 'Missing ?q= parameter' }));
2105
+ }
2106
+ try {
2107
+ const https = await import('https');
2108
+ const fetchUrl = `https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`;
2109
+ const html = await new Promise((resolve, reject) => {
2110
+ https.default.get(fetchUrl, (resp) => {
2111
+ let body = '';
2112
+ resp.on('data', chunk => body += chunk);
2113
+ resp.on('end', () => resolve(body));
2114
+ }).on('error', reject);
2115
+ });
2116
+ // Parse emoji entries from HTML:
2117
+ // <li class='emoji name' title='name'>
2118
+ // <a ... data-emoji-id="123" data-emoji-id-name="123-name" href="/emojis/123-name/download">
2119
+ // <img ... src="https://emojis.slackmojis.com/emojis/images/.../name.png?..." />
2120
+ const results = [];
2121
+ const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
2122
+ let match;
2123
+ while ((match = regex.exec(html)) !== null && results.length < 50) {
2124
+ const idName = match[1]; // e.g. "57350-sextant"
2125
+ const downloadPath = match[2];
2126
+ const imageUrl = match[3];
2127
+ const name = idName.replace(/^\d+-/, '');
2128
+ results.push({
2129
+ name,
2130
+ image_url: imageUrl,
2131
+ download_url: `https://slackmojis.com${downloadPath}`,
2132
+ });
2133
+ }
2134
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2135
+ return res.end(JSON.stringify(results));
2136
+ } catch (e) {
2137
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2138
+ return res.end(JSON.stringify({ error: e.message }));
2139
+ }
2140
+ }
2141
+
2142
+ // Download emoji from URL and save to emoji/ directory
2143
+ if (method === 'POST' && urlPath === '/api/emoji/add') {
2144
+ if (!checkAuth(req, res)) return;
2145
+ try {
2146
+ const { url, name, pack } = await parseBody(req);
2147
+ if (!url || !name) {
2148
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2149
+ return res.end(JSON.stringify({ error: 'Missing url or name' }));
2150
+ }
2151
+ const targetPack = pack || 'custom';
2152
+ const emojiDir = path.join(__dirname, 'emoji', targetPack);
2153
+ if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
2154
+
2155
+ // Follow redirects and download
2156
+ const https = await import('https');
2157
+ const http = await import('http');
2158
+ const download = (downloadUrl, redirects = 0) => new Promise((resolve, reject) => {
2159
+ if (redirects > 5) return reject(new Error('Too many redirects'));
2160
+ const mod = downloadUrl.startsWith('https') ? https.default : http.default;
2161
+ mod.get(downloadUrl, (resp) => {
2162
+ if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
2163
+ return resolve(download(resp.headers.location, redirects + 1));
2164
+ }
2165
+ if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
2166
+ const chunks = [];
2167
+ resp.on('data', chunk => chunks.push(chunk));
2168
+ resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
2169
+ }).on('error', reject);
2170
+ });
2171
+
2172
+ const { buffer, contentType } = await download(url);
2173
+ // Determine extension from content-type or URL
2174
+ let ext = 'png';
2175
+ if (contentType.includes('gif')) ext = 'gif';
2176
+ else if (contentType.includes('webp')) ext = 'webp';
2177
+ else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
2178
+ else {
2179
+ const urlExt = url.split('?')[0].split('.').pop().toLowerCase();
2180
+ if (['png', 'gif', 'webp', 'jpg', 'jpeg'].includes(urlExt)) ext = urlExt;
2181
+ }
2182
+
2183
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
2184
+ const filePath = path.join(emojiDir, `${safeName}.${ext}`);
2185
+ fs.writeFileSync(filePath, buffer);
2186
+
2187
+ const result = { name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` };
2188
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2189
+ return res.end(JSON.stringify(result));
2190
+ } catch (e) {
2191
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2192
+ return res.end(JSON.stringify({ error: e.message }));
2193
+ }
2194
+ }
2195
+
2075
2196
  // Auth check for all other routes
2076
2197
  if (!checkAuth(req, res)) return;
2077
2198
 
@@ -2233,14 +2354,24 @@ class GatewayClient {
2233
2354
  this.maxReconnectDelay = 30000;
2234
2355
  this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
2235
2356
  this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
2236
- this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
2357
+ this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
2237
2358
 
2238
- // Clean up stale run states every 5 minutes (runs that never completed)
2359
+ // Clean up stale activity logs every 5 minutes (runs that never completed)
2239
2360
  setInterval(() => {
2240
- const cutoff = Date.now() - 10 * 60 * 1000; // 10 minutes
2241
- for (const [runId, run] of this.runState) {
2242
- if (run.startTime < cutoff) {
2243
- this.runState.delete(runId);
2361
+ const cutoff = Date.now() - 10 * 60 * 1000;
2362
+ for (const [runId, log] of this.activityLogs) {
2363
+ if (log.startTime < cutoff) {
2364
+ if (log._messageId) {
2365
+ const db = getDb(log._parsed?.workspace);
2366
+ if (db) {
2367
+ db.prepare(`
2368
+ UPDATE messages SET content = '[Response interrupted]',
2369
+ metadata = json_remove(metadata, '$.pending')
2370
+ WHERE id = ? AND content = ''
2371
+ `).run(log._messageId);
2372
+ }
2373
+ }
2374
+ this.activityLogs.delete(runId);
2244
2375
  }
2245
2376
  }
2246
2377
  }, 5 * 60 * 1000);
@@ -2354,10 +2485,9 @@ class GatewayClient {
2354
2485
  this.streamState.delete(sessionKey);
2355
2486
  }
2356
2487
 
2357
- // Save assistant messages on final (use segment seq position if available)
2488
+ // Save assistant messages on final
2358
2489
  if (state === 'final') {
2359
- const run = this.findRunBySessionKey(sessionKey);
2360
- this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq);
2490
+ this.saveAssistantMessage(sessionKey, message, seq);
2361
2491
  }
2362
2492
 
2363
2493
  // Save error markers
@@ -2366,11 +2496,10 @@ class GatewayClient {
2366
2496
  }
2367
2497
  }
2368
2498
 
2369
- saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
2499
+ saveAssistantMessage(sessionKey, message, seq) {
2370
2500
  const parsed = parseSessionKey(sessionKey);
2371
- if (!parsed) return; // Non-ClawChats session key, silently ignore
2501
+ if (!parsed) return;
2372
2502
 
2373
- // Guard: verify workspace still exists
2374
2503
  const ws = getWorkspaces();
2375
2504
  if (!ws.workspaces[parsed.workspace]) {
2376
2505
  console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
@@ -2379,52 +2508,66 @@ class GatewayClient {
2379
2508
 
2380
2509
  const db = getDb(parsed.workspace);
2381
2510
 
2382
- // Guard: verify thread still exists
2383
2511
  const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2384
2512
  if (!thread) {
2385
2513
  console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
2386
2514
  return;
2387
2515
  }
2388
2516
 
2389
- // Extract content
2390
2517
  const content = extractContent(message);
2391
-
2392
- // Guard: skip empty content
2393
2518
  if (!content || !content.trim()) {
2394
2519
  console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
2395
2520
  return;
2396
2521
  }
2397
2522
 
2398
- // Deterministic message ID from seq (deduplicates tool-call loops)
2399
2523
  const now = Date.now();
2400
- const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2401
2524
 
2402
- // Use segment seq for correct ordering after tool segments
2403
- const msgSeq = segmentSeq != null ? segmentSeq : (seq != null ? seq : null);
2525
+ // Check for pending activity message
2526
+ const pendingMsg = db.prepare(`
2527
+ SELECT id, metadata FROM messages
2528
+ WHERE thread_id = ? AND role = 'assistant'
2529
+ AND json_extract(metadata, '$.pending') = 1
2530
+ ORDER BY timestamp DESC LIMIT 1
2531
+ `).get(parsed.threadId);
2532
+
2533
+ let messageId;
2534
+
2535
+ if (pendingMsg) {
2536
+ // Merge final content into existing activity row
2537
+ const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
2538
+ delete metadata.pending;
2539
+
2540
+ // Clean up: remove last assistant narration (it's the final reply text)
2541
+ if (metadata.activityLog) {
2542
+ const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
2543
+ if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
2544
+ metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
2545
+ }
2404
2546
 
2405
- // Upsert message: INSERT OR REPLACE (same seq same messageId update content)
2406
- try {
2547
+ db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
2548
+ .run(content, JSON.stringify(metadata), now, pendingMsg.id);
2549
+
2550
+ messageId = pendingMsg.id;
2551
+ } else {
2552
+ // No pending activity — normal INSERT (simple responses, no tools)
2553
+ messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2407
2554
  db.prepare(`
2408
- INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at)
2409
- VALUES (?, ?, 'assistant', 'text', ?, 'sent', ?, ?, ?)
2410
- ON CONFLICT(id) DO UPDATE SET content = excluded.content, seq = excluded.seq, timestamp = excluded.timestamp
2411
- `).run(messageId, parsed.threadId, content, msgSeq, now, now);
2555
+ INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2556
+ VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2557
+ ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2558
+ `).run(messageId, parsed.threadId, content, now, now);
2559
+ }
2412
2560
 
2413
- // Update thread updated_at
2561
+ // Thread timestamp + unreads + broadcast (same for both paths)
2562
+ try {
2414
2563
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
2415
-
2416
- // Always mark as unread server-side. The browser client is responsible
2417
- // for sending read receipts (active-thread) to clear unreads — same
2418
- // pattern as Slack/Discord. Server doesn't track viewport state.
2419
2564
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2420
2565
  syncThreadUnreadCount(db, parsed.threadId);
2421
2566
 
2422
- // Get thread title and unread info for notification
2423
2567
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2424
2568
  const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2425
2569
  const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2426
2570
 
2427
- // Broadcast message-saved for active thread reload
2428
2571
  this.broadcastToBrowsers(JSON.stringify({
2429
2572
  type: 'clawchats',
2430
2573
  event: 'message-saved',
@@ -2437,7 +2580,6 @@ class GatewayClient {
2437
2580
  unreadCount
2438
2581
  }));
2439
2582
 
2440
- // Always broadcast unread-update — browser sends read receipts to clear
2441
2583
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2442
2584
  this.broadcastToBrowsers(JSON.stringify({
2443
2585
  type: 'clawchats',
@@ -2453,7 +2595,7 @@ class GatewayClient {
2453
2595
  timestamp: now
2454
2596
  }));
2455
2597
 
2456
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
2598
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2457
2599
  } catch (e) {
2458
2600
  console.error(`Failed to save assistant message:`, e.message);
2459
2601
  }
@@ -2490,242 +2632,183 @@ class GatewayClient {
2490
2632
  const { runId, stream, data, sessionKey } = payload;
2491
2633
  if (!runId) return;
2492
2634
 
2493
- // Initialize run state if needed
2494
- if (!this.runState.has(runId)) {
2495
- this.runState.set(runId, { sessionKey, seqCounter: 0, startTime: Date.now() });
2635
+ // Initialize log if needed
2636
+ if (!this.activityLogs.has(runId)) {
2637
+ this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
2496
2638
  }
2497
- const run = this.runState.get(runId);
2498
-
2499
- const parsed = parseSessionKey(sessionKey);
2500
- if (!parsed) return;
2501
-
2502
- // Verify workspace/thread still exist
2503
- const ws = getWorkspaces();
2504
- if (!ws.workspaces[parsed.workspace]) return;
2505
- const db = getDb(parsed.workspace);
2506
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2507
- if (!thread) return;
2639
+ const log = this.activityLogs.get(runId);
2508
2640
 
2509
2641
  if (stream === 'assistant') {
2642
+ // Capture intermediate text turns (narration between tool calls)
2510
2643
  const text = data?.text || '';
2511
- if (!text) return;
2512
-
2513
- if (!run.currentTextSegmentId) {
2514
- // Start a new text segment — broadcast only, saved when sealed or discarded on lifecycle.end
2515
- run.seqCounter++;
2516
- run.currentTextSegmentId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
2517
- run.currentTextContent = text;
2518
- run.currentTextSeq = run.seqCounter;
2519
- run.currentTextTimestamp = Date.now();
2520
- } else {
2521
- // Accumulate text for this segment
2522
- run.currentTextContent = text;
2523
- }
2524
-
2525
- // Broadcast segment update for live display (throttled ~150ms)
2526
- const now = Date.now();
2527
- if (!run.lastTextBroadcast || now - run.lastTextBroadcast >= 150) {
2528
- run.lastTextBroadcast = now;
2529
- this.broadcastSegmentUpdate(parsed, runId, {
2530
- id: run.currentTextSegmentId,
2531
- type: 'text',
2532
- seq: run.currentTextSeq,
2533
- content: run.currentTextContent
2534
- });
2644
+ if (text) {
2645
+ let currentSegment = log._currentAssistantSegment;
2646
+ if (!currentSegment || currentSegment._sealed) {
2647
+ currentSegment = {
2648
+ type: 'assistant',
2649
+ timestamp: Date.now(),
2650
+ text: text,
2651
+ _sealed: false
2652
+ };
2653
+ log._currentAssistantSegment = currentSegment;
2654
+ log.steps.push(currentSegment);
2655
+ } else {
2656
+ currentSegment.text = text;
2657
+ }
2535
2658
  }
2659
+ // Don't broadcast on every assistant delta — too noisy
2536
2660
  return;
2537
2661
  }
2538
2662
 
2539
2663
  if (stream === 'thinking') {
2540
2664
  const thinkingText = data?.text || '';
2541
-
2542
- if (!run.thinkingSegmentId) {
2543
- run.seqCounter++;
2544
- const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
2545
- run.thinkingSegmentId = segId;
2546
- run.thinkingSeq = run.seqCounter;
2547
- const now = Date.now();
2548
- try {
2549
- db.prepare(`
2550
- INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at)
2551
- VALUES (?, ?, 'assistant', 'thinking', ?, 'sent', ?, ?, ?)
2552
- `).run(segId, parsed.threadId, thinkingText, run.seqCounter, now, now);
2553
- } catch (e) {
2554
- console.error('Failed to save thinking segment:', e.message);
2555
- }
2665
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
2666
+ if (thinkingStep) {
2667
+ thinkingStep.text = thinkingText;
2556
2668
  } else {
2557
- try {
2558
- db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(thinkingText, run.thinkingSegmentId);
2559
- } catch (e) {
2560
- console.error('Failed to update thinking segment:', e.message);
2561
- }
2562
- }
2563
-
2564
- // Broadcast (throttled ~300ms)
2565
- const now = Date.now();
2566
- if (!run.lastThinkingBroadcast || now - run.lastThinkingBroadcast >= 300) {
2567
- run.lastThinkingBroadcast = now;
2568
- this.broadcastSegmentUpdate(parsed, runId, {
2569
- id: run.thinkingSegmentId,
2669
+ log.steps.push({
2570
2670
  type: 'thinking',
2571
- seq: run.thinkingSeq,
2572
- content: thinkingText
2671
+ timestamp: Date.now(),
2672
+ text: thinkingText
2573
2673
  });
2574
2674
  }
2575
- return;
2675
+ // Always write to DB; throttle broadcasts to every 300ms
2676
+ this._writeActivityToDb(runId, log);
2677
+ const now = Date.now();
2678
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2679
+ log._lastThinkingBroadcast = now;
2680
+ this._broadcastActivityUpdate(runId, log);
2681
+ }
2576
2682
  }
2577
2683
 
2578
2684
  if (stream === 'tool') {
2579
- // Seal and save any current text segment (intermediate narration before this tool call)
2580
- if (run.currentTextSegmentId && run.currentTextContent?.trim()) {
2581
- const segId = run.currentTextSegmentId;
2582
- const content = run.currentTextContent;
2583
- const seq = run.currentTextSeq;
2584
- const ts = run.currentTextTimestamp || Date.now();
2585
- try {
2586
- db.prepare(`
2587
- INSERT OR REPLACE INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at)
2588
- VALUES (?, ?, 'assistant', 'text', ?, 'sent', ?, ?, ?)
2589
- `).run(segId, parsed.threadId, content, seq, ts, ts);
2590
- } catch (e) {
2591
- console.error('Failed to save sealed text segment:', e.message);
2592
- }
2685
+ // Seal any current assistant text segment (narration before this tool call)
2686
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2687
+ log._currentAssistantSegment._sealed = true;
2593
2688
  }
2594
- run.currentTextSegmentId = null;
2595
- run.currentTextContent = null;
2596
- run.currentTextSeq = null;
2597
-
2598
- if (!run.toolSegmentMap) run.toolSegmentMap = new Map();
2599
- if (!run.toolSeqMap) run.toolSeqMap = new Map();
2600
- if (!run.toolStartTimes) run.toolStartTimes = new Map();
2601
-
2602
- const toolCallId = data?.toolCallId;
2603
- const phase = data?.phase || 'start';
2604
-
2605
- if (phase === 'result') {
2606
- const existingId = run.toolSegmentMap.get(toolCallId);
2607
- if (existingId) {
2608
- const durationMs = run.toolStartTimes.get(toolCallId) ? Date.now() - run.toolStartTimes.get(toolCallId) : null;
2609
- const metadata = {
2610
- name: data?.name || 'unknown',
2611
- status: 'done',
2612
- meta: data?.meta || '',
2613
- isError: data?.isError || false,
2614
- durationMs
2615
- };
2616
- try {
2617
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId);
2618
- } catch (e) {
2619
- console.error('Failed to update tool segment result:', e.message);
2620
- }
2621
- this.broadcastSegmentUpdate(parsed, runId, {
2622
- id: existingId,
2623
- type: 'tool',
2624
- seq: run.toolSeqMap.get(toolCallId),
2625
- metadata
2626
- });
2689
+
2690
+ const step = {
2691
+ type: 'tool',
2692
+ timestamp: Date.now(),
2693
+ name: data?.name || 'unknown',
2694
+ phase: data?.phase || 'start',
2695
+ toolCallId: data?.toolCallId,
2696
+ meta: data?.meta,
2697
+ isError: data?.isError || false
2698
+ };
2699
+
2700
+ // On result phase, update the existing start step or add new
2701
+ if (data?.phase === 'result') {
2702
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
2703
+ if (existing) {
2704
+ existing.phase = 'done';
2705
+ existing.resultMeta = data?.meta;
2706
+ existing.isError = data?.isError || false;
2707
+ existing.durationMs = Date.now() - existing.timestamp;
2708
+ } else {
2709
+ step.phase = 'done';
2710
+ log.steps.push(step);
2627
2711
  }
2628
- } else if (phase === 'update') {
2629
- const existingId = run.toolSegmentMap.get(toolCallId);
2630
- if (existingId) {
2631
- const metadata = {
2632
- name: data?.name || 'unknown',
2633
- status: 'running',
2634
- meta: data?.meta || ''
2635
- };
2636
- try {
2637
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId);
2638
- } catch (e) {
2639
- console.error('Failed to update tool segment:', e.message);
2640
- }
2641
- this.broadcastSegmentUpdate(parsed, runId, {
2642
- id: existingId,
2643
- type: 'tool',
2644
- seq: run.toolSeqMap.get(toolCallId),
2645
- metadata
2646
- });
2712
+ } else if (data?.phase === 'update') {
2713
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2714
+ if (existing) {
2715
+ if (data?.meta) existing.resultMeta = data.meta;
2716
+ if (data?.isError) existing.isError = true;
2717
+ existing.phase = 'running';
2647
2718
  }
2648
2719
  } else {
2649
- // phase === 'start': insert new tool segment
2650
- run.seqCounter++;
2651
- const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
2652
- const now = Date.now();
2653
- const metadata = {
2654
- name: data?.name || 'unknown',
2655
- status: 'running',
2656
- meta: data?.meta || ''
2657
- };
2658
- try {
2659
- db.prepare(`
2660
- INSERT INTO messages (id, thread_id, role, type, content, status, metadata, seq, timestamp, created_at)
2661
- VALUES (?, ?, 'assistant', 'tool', '', 'sent', ?, ?, ?, ?)
2662
- `).run(segId, parsed.threadId, JSON.stringify(metadata), run.seqCounter, now, now);
2663
- } catch (e) {
2664
- console.error('Failed to save tool segment:', e.message);
2665
- }
2666
- run.toolSegmentMap.set(toolCallId, segId);
2667
- run.toolSeqMap.set(toolCallId, run.seqCounter);
2668
- run.toolStartTimes.set(toolCallId, now);
2669
- this.broadcastSegmentUpdate(parsed, runId, {
2670
- id: segId,
2671
- type: 'tool',
2672
- seq: run.seqCounter,
2673
- metadata
2674
- });
2720
+ log.steps.push(step);
2675
2721
  }
2676
- return;
2722
+
2723
+ this._writeActivityToDb(runId, log);
2724
+ this._broadcastActivityUpdate(runId, log);
2677
2725
  }
2678
2726
 
2679
2727
  if (stream === 'lifecycle') {
2680
2728
  if (data?.phase === 'end' || data?.phase === 'error') {
2681
- // Broadcast final text content one last time for live display
2682
- // Do NOT save to DB — handleChatEvent.final saves the authoritative final text
2683
- if (run.currentTextSegmentId) {
2684
- this.broadcastSegmentUpdate(parsed, runId, {
2685
- id: run.currentTextSegmentId,
2686
- type: 'text',
2687
- seq: run.currentTextSeq,
2688
- content: run.currentTextContent || ''
2689
- });
2690
- // Store seq for handleChatEvent.final to position final message correctly
2691
- run.finalSeq = run.currentTextSeq;
2692
- run.currentTextSegmentId = null;
2693
- run.currentTextContent = null;
2694
- } else {
2695
- // No active text segment — final seq is next position
2696
- run.finalSeq = run.seqCounter + 1;
2729
+ // Seal any remaining assistant segment
2730
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2731
+ log._currentAssistantSegment._sealed = true;
2732
+ }
2733
+ // Remove the final assistant segment — that's the actual response, not narration
2734
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2735
+ if (lastAssistantIdx >= 0) {
2736
+ log.steps.splice(lastAssistantIdx, 1);
2697
2737
  }
2698
2738
 
2699
- // Broadcast segments-complete
2700
- this.broadcastToBrowsers(JSON.stringify({
2701
- type: 'clawchats',
2702
- event: 'segments-complete',
2703
- workspace: parsed.workspace,
2704
- threadId: parsed.threadId,
2705
- runId
2706
- }));
2707
-
2708
- // Keep run state briefly for handleChatEvent.final to read finalSeq
2709
- setTimeout(() => this.runState.delete(runId), 30000);
2739
+ // Write final state to DB, then clean up
2740
+ this._writeActivityToDb(runId, log);
2741
+ this.activityLogs.delete(runId);
2742
+ return;
2710
2743
  }
2711
2744
  }
2712
2745
  }
2713
2746
 
2714
- findRunBySessionKey(sessionKey) {
2715
- for (const [, run] of this.runState.entries()) {
2716
- if (run.sessionKey === sessionKey) return run;
2747
+ _writeActivityToDb(runId, log) {
2748
+ if (!log._parsed) {
2749
+ log._parsed = parseSessionKey(log.sessionKey);
2750
+ }
2751
+ const parsed = log._parsed;
2752
+ if (!parsed) return;
2753
+
2754
+ const db = getDb(parsed.workspace);
2755
+ if (!db) return;
2756
+
2757
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2758
+ const summary = this.generateActivitySummary(log.steps);
2759
+ const now = Date.now();
2760
+
2761
+ if (!log._messageId) {
2762
+ // First write — INSERT the assistant message row
2763
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2764
+ if (!thread) return;
2765
+
2766
+ const messageId = `gw-activity-${runId}`;
2767
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
2768
+
2769
+ db.prepare(`
2770
+ INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
2771
+ VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
2772
+ `).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
2773
+
2774
+ log._messageId = messageId;
2775
+
2776
+ // First event — broadcast message-saved so browser creates the message element
2777
+ this.broadcastToBrowsers(JSON.stringify({
2778
+ type: 'clawchats',
2779
+ event: 'message-saved',
2780
+ threadId: parsed.threadId,
2781
+ workspace: parsed.workspace,
2782
+ messageId,
2783
+ timestamp: now
2784
+ }));
2785
+ } else {
2786
+ // Subsequent writes — UPDATE metadata on existing row
2787
+ const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
2788
+ const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
2789
+ metadata.activityLog = cleanSteps;
2790
+ metadata.activitySummary = summary;
2791
+ metadata.pending = true;
2792
+
2793
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
2794
+ .run(JSON.stringify(metadata), log._messageId);
2717
2795
  }
2718
- return null;
2719
2796
  }
2720
2797
 
2721
- broadcastSegmentUpdate(parsed, runId, segment) {
2798
+ _broadcastActivityUpdate(runId, log) {
2799
+ const parsed = log._parsed;
2800
+ if (!parsed || !log._messageId) return;
2801
+
2802
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2803
+
2722
2804
  this.broadcastToBrowsers(JSON.stringify({
2723
2805
  type: 'clawchats',
2724
- event: 'segment-update',
2806
+ event: 'activity-updated',
2725
2807
  workspace: parsed.workspace,
2726
2808
  threadId: parsed.threadId,
2727
- runId,
2728
- segment
2809
+ messageId: log._messageId,
2810
+ activityLog: cleanSteps,
2811
+ activitySummary: this.generateActivitySummary(log.steps)
2729
2812
  }));
2730
2813
  }
2731
2814
 
@@ -3292,10 +3375,14 @@ export function createApp(config = {}) {
3292
3375
  return sendError(res, 400, 'Required: id, role, content, timestamp');
3293
3376
  }
3294
3377
  const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
3295
- const existing = db.prepare('SELECT id, status FROM messages WHERE id = ?').get(body.id);
3378
+ const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
3296
3379
  if (existing) {
3297
- if (body.status && body.status !== existing.status) {
3298
- db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status, body.content, metadata, body.id);
3380
+ const newStatus = body.status || existing.status;
3381
+ const statusChanged = body.status && body.status !== existing.status;
3382
+ if (statusChanged || metadata) {
3383
+ // Only overwrite metadata if new metadata is provided; otherwise preserve existing
3384
+ const finalMetadata = metadata || existing.metadata || null;
3385
+ db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(newStatus, body.content, finalMetadata, body.id);
3299
3386
  }
3300
3387
  } else {
3301
3388
  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());
@@ -3548,11 +3635,23 @@ export function createApp(config = {}) {
3548
3635
  this.browserClients = new Map();
3549
3636
  this._externalBroadcastTargets = [];
3550
3637
  this.streamState = new Map();
3551
- this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
3638
+ this.activityLogs = new Map();
3552
3639
  setInterval(() => {
3553
3640
  const cutoff = Date.now() - 10 * 60 * 1000;
3554
- for (const [runId, run] of this.runState) {
3555
- if (run.startTime < cutoff) this.runState.delete(runId);
3641
+ for (const [runId, log] of this.activityLogs) {
3642
+ if (log.startTime < cutoff) {
3643
+ if (log._messageId) {
3644
+ const db = _getDb(log._parsed?.workspace);
3645
+ if (db) {
3646
+ db.prepare(`
3647
+ UPDATE messages SET content = '[Response interrupted]',
3648
+ metadata = json_remove(metadata, '$.pending')
3649
+ WHERE id = ? AND content = ''
3650
+ `).run(log._messageId);
3651
+ }
3652
+ }
3653
+ this.activityLogs.delete(runId);
3654
+ }
3556
3655
  }
3557
3656
  }, 5 * 60 * 1000);
3558
3657
  }
@@ -3601,11 +3700,11 @@ export function createApp(config = {}) {
3601
3700
  return;
3602
3701
  }
3603
3702
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
3604
- if (state === 'final') { const run = this.findRunBySessionKey(sessionKey); this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq); }
3703
+ if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3605
3704
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
3606
3705
  }
3607
3706
 
3608
- saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
3707
+ saveAssistantMessage(sessionKey, message, seq) {
3609
3708
  const parsed = parseSessionKey(sessionKey);
3610
3709
  if (!parsed) return;
3611
3710
  const ws = _getWorkspaces();
@@ -3616,12 +3715,37 @@ export function createApp(config = {}) {
3616
3715
  const content = extractContent(message);
3617
3716
  if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
3618
3717
  const now = Date.now();
3619
- const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3620
- const msgSeq = segmentSeq != null ? segmentSeq : (seq != null ? seq : null);
3718
+
3719
+ // Check for pending activity message
3720
+ const pendingMsg = db.prepare(`
3721
+ SELECT id, metadata FROM messages
3722
+ WHERE thread_id = ? AND role = 'assistant'
3723
+ AND json_extract(metadata, '$.pending') = 1
3724
+ ORDER BY timestamp DESC LIMIT 1
3725
+ `).get(parsed.threadId);
3726
+
3727
+ let messageId;
3728
+
3729
+ if (pendingMsg) {
3730
+ // Merge final content into existing activity row
3731
+ const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
3732
+ delete metadata.pending;
3733
+ if (metadata.activityLog) {
3734
+ const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
3735
+ if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
3736
+ metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
3737
+ }
3738
+ db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
3739
+ .run(content, JSON.stringify(metadata), now, pendingMsg.id);
3740
+ messageId = pendingMsg.id;
3741
+ } else {
3742
+ // No pending activity — normal INSERT (simple responses, no tools)
3743
+ messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3744
+ db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, now, now);
3745
+ }
3746
+
3621
3747
  try {
3622
- db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'text\', ?, \'sent\', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, seq = excluded.seq, timestamp = excluded.timestamp').run(messageId, parsed.threadId, content, msgSeq, now, now);
3623
3748
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
3624
- // Always mark as unread — browser sends read receipts to clear
3625
3749
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
3626
3750
  syncThreadUnreadCount(db, parsed.threadId);
3627
3751
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
@@ -3630,7 +3754,7 @@ export function createApp(config = {}) {
3630
3754
  this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
3631
3755
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3632
3756
  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 }));
3633
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
3757
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
3634
3758
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3635
3759
  }
3636
3760
 
@@ -3655,114 +3779,121 @@ export function createApp(config = {}) {
3655
3779
  handleAgentEvent(payload) {
3656
3780
  const { runId, stream, data, sessionKey } = payload;
3657
3781
  if (!runId) return;
3658
- if (!this.runState.has(runId)) this.runState.set(runId, { sessionKey, seqCounter: 0, startTime: Date.now() });
3659
- const run = this.runState.get(runId);
3660
- const parsed = parseSessionKey(sessionKey);
3661
- if (!parsed) return;
3662
- const wsData = _getWorkspaces();
3663
- if (!wsData.workspaces[parsed.workspace]) return;
3664
- const db = _getDb(parsed.workspace);
3665
- const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3666
- if (!thread) return;
3667
-
3782
+ if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
3783
+ const log = this.activityLogs.get(runId);
3668
3784
  if (stream === 'assistant') {
3669
3785
  const text = data?.text || '';
3670
- if (!text) return;
3671
- if (!run.currentTextSegmentId) {
3672
- run.seqCounter++;
3673
- run.currentTextSegmentId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
3674
- run.currentTextContent = text;
3675
- run.currentTextSeq = run.seqCounter;
3676
- run.currentTextTimestamp = Date.now();
3677
- } else { run.currentTextContent = text; }
3678
- const now = Date.now();
3679
- if (!run.lastTextBroadcast || now - run.lastTextBroadcast >= 150) {
3680
- run.lastTextBroadcast = now;
3681
- this.broadcastSegmentUpdate(parsed, runId, { id: run.currentTextSegmentId, type: 'text', seq: run.currentTextSeq, content: run.currentTextContent });
3786
+ if (text) {
3787
+ let currentSegment = log._currentAssistantSegment;
3788
+ if (!currentSegment || currentSegment._sealed) {
3789
+ currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
3790
+ log._currentAssistantSegment = currentSegment;
3791
+ log.steps.push(currentSegment);
3792
+ } else { currentSegment.text = text; }
3682
3793
  }
3683
3794
  return;
3684
3795
  }
3685
-
3686
3796
  if (stream === 'thinking') {
3687
3797
  const thinkingText = data?.text || '';
3688
- if (!run.thinkingSegmentId) {
3689
- run.seqCounter++;
3690
- const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
3691
- run.thinkingSegmentId = segId;
3692
- run.thinkingSeq = run.seqCounter;
3693
- const now = Date.now();
3694
- try { db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'thinking\', ?, \'sent\', ?, ?, ?)').run(segId, parsed.threadId, thinkingText, run.seqCounter, now, now); } catch (e) { console.error('Failed to save thinking segment:', e.message); }
3695
- } else {
3696
- try { db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(thinkingText, run.thinkingSegmentId); } catch (e) { console.error('Failed to update thinking segment:', e.message); }
3697
- }
3798
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
3799
+ if (thinkingStep) { thinkingStep.text = thinkingText; }
3800
+ else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
3801
+ this._writeActivityToDb(runId, log);
3698
3802
  const now = Date.now();
3699
- if (!run.lastThinkingBroadcast || now - run.lastThinkingBroadcast >= 300) {
3700
- run.lastThinkingBroadcast = now;
3701
- this.broadcastSegmentUpdate(parsed, runId, { id: run.thinkingSegmentId, type: 'thinking', seq: run.thinkingSeq, content: thinkingText });
3803
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3804
+ log._lastThinkingBroadcast = now;
3805
+ this._broadcastActivityUpdate(runId, log);
3702
3806
  }
3703
- return;
3704
3807
  }
3705
-
3706
3808
  if (stream === 'tool') {
3707
- if (run.currentTextSegmentId && run.currentTextContent?.trim()) {
3708
- const { currentTextSegmentId: segId, currentTextContent: content, currentTextSeq: seq, currentTextTimestamp: ts = Date.now() } = run;
3709
- try { db.prepare('INSERT OR REPLACE INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'text\', ?, \'sent\', ?, ?, ?)').run(segId, parsed.threadId, content, seq, ts, ts); } catch (e) { console.error('Failed to save sealed text segment:', e.message); }
3710
- }
3711
- run.currentTextSegmentId = null; run.currentTextContent = null; run.currentTextSeq = null;
3712
- if (!run.toolSegmentMap) run.toolSegmentMap = new Map();
3713
- if (!run.toolSeqMap) run.toolSeqMap = new Map();
3714
- if (!run.toolStartTimes) run.toolStartTimes = new Map();
3715
- const toolCallId = data?.toolCallId;
3716
- const phase = data?.phase || 'start';
3717
- if (phase === 'result') {
3718
- const existingId = run.toolSegmentMap.get(toolCallId);
3719
- if (existingId) {
3720
- const durationMs = run.toolStartTimes.get(toolCallId) ? Date.now() - run.toolStartTimes.get(toolCallId) : null;
3721
- const metadata = { name: data?.name || 'unknown', status: 'done', meta: data?.meta || '', isError: data?.isError || false, durationMs };
3722
- try { db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId); } catch (e) { console.error('Failed to update tool segment result:', e.message); }
3723
- this.broadcastSegmentUpdate(parsed, runId, { id: existingId, type: 'tool', seq: run.toolSeqMap.get(toolCallId), metadata });
3724
- }
3725
- } else if (phase === 'update') {
3726
- const existingId = run.toolSegmentMap.get(toolCallId);
3727
- if (existingId) {
3728
- const metadata = { name: data?.name || 'unknown', status: 'running', meta: data?.meta || '' };
3729
- try { db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId); } catch (e) { console.error('Failed to update tool segment:', e.message); }
3730
- this.broadcastSegmentUpdate(parsed, runId, { id: existingId, type: 'tool', seq: run.toolSeqMap.get(toolCallId), metadata });
3731
- }
3732
- } else {
3733
- run.seqCounter++;
3734
- const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
3735
- const now = Date.now();
3736
- const metadata = { name: data?.name || 'unknown', status: 'running', meta: data?.meta || '' };
3737
- try { db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'tool\', \'\', \'sent\', ?, ?, ?, ?)').run(segId, parsed.threadId, JSON.stringify(metadata), run.seqCounter, now, now); } catch (e) { console.error('Failed to save tool segment:', e.message); }
3738
- run.toolSegmentMap.set(toolCallId, segId); run.toolSeqMap.set(toolCallId, run.seqCounter); run.toolStartTimes.set(toolCallId, now);
3739
- this.broadcastSegmentUpdate(parsed, runId, { id: segId, type: 'tool', seq: run.seqCounter, metadata });
3740
- }
3741
- return;
3809
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; }
3810
+ const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta, isError: data?.isError || false };
3811
+ if (data?.phase === 'result') {
3812
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
3813
+ if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
3814
+ else { step.phase = 'done'; log.steps.push(step); }
3815
+ } else if (data?.phase === 'update') {
3816
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3817
+ if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
3818
+ } else { log.steps.push(step); }
3819
+ this._writeActivityToDb(runId, log);
3820
+ this._broadcastActivityUpdate(runId, log);
3742
3821
  }
3743
-
3744
3822
  if (stream === 'lifecycle') {
3745
3823
  if (data?.phase === 'end' || data?.phase === 'error') {
3746
- if (run.currentTextSegmentId) {
3747
- this.broadcastSegmentUpdate(parsed, runId, { id: run.currentTextSegmentId, type: 'text', seq: run.currentTextSeq, content: run.currentTextContent || '' });
3748
- run.finalSeq = run.currentTextSeq;
3749
- run.currentTextSegmentId = null; run.currentTextContent = null;
3750
- } else { run.finalSeq = run.seqCounter + 1; }
3751
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'segments-complete', workspace: parsed.workspace, threadId: parsed.threadId, runId }));
3752
- setTimeout(() => this.runState.delete(runId), 30000);
3824
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
3825
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3826
+ if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
3827
+ this._writeActivityToDb(runId, log);
3828
+ this.activityLogs.delete(runId);
3829
+ return;
3753
3830
  }
3754
3831
  }
3755
3832
  }
3756
3833
 
3757
- findRunBySessionKey(sessionKey) {
3758
- for (const [, run] of this.runState.entries()) {
3759
- if (run.sessionKey === sessionKey) return run;
3834
+ _writeActivityToDb(runId, log) {
3835
+ if (!log._parsed) {
3836
+ log._parsed = parseSessionKey(log.sessionKey);
3837
+ }
3838
+ const parsed = log._parsed;
3839
+ if (!parsed) return;
3840
+
3841
+ const db = _getDb(parsed.workspace);
3842
+ if (!db) return;
3843
+
3844
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3845
+ const summary = this.generateActivitySummary(log.steps);
3846
+ const now = Date.now();
3847
+
3848
+ if (!log._messageId) {
3849
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3850
+ if (!thread) return;
3851
+
3852
+ const messageId = `gw-activity-${runId}`;
3853
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
3854
+
3855
+ db.prepare(`
3856
+ INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
3857
+ VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
3858
+ `).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
3859
+
3860
+ log._messageId = messageId;
3861
+
3862
+ this.broadcastToBrowsers(JSON.stringify({
3863
+ type: 'clawchats',
3864
+ event: 'message-saved',
3865
+ threadId: parsed.threadId,
3866
+ workspace: parsed.workspace,
3867
+ messageId,
3868
+ timestamp: now
3869
+ }));
3870
+ } else {
3871
+ const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
3872
+ const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
3873
+ metadata.activityLog = cleanSteps;
3874
+ metadata.activitySummary = summary;
3875
+ metadata.pending = true;
3876
+
3877
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
3878
+ .run(JSON.stringify(metadata), log._messageId);
3760
3879
  }
3761
- return null;
3762
3880
  }
3763
3881
 
3764
- broadcastSegmentUpdate(parsed, runId, segment) {
3765
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'segment-update', workspace: parsed.workspace, threadId: parsed.threadId, runId, segment }));
3882
+ _broadcastActivityUpdate(runId, log) {
3883
+ const parsed = log._parsed;
3884
+ if (!parsed || !log._messageId) return;
3885
+
3886
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3887
+
3888
+ this.broadcastToBrowsers(JSON.stringify({
3889
+ type: 'clawchats',
3890
+ event: 'activity-updated',
3891
+ workspace: parsed.workspace,
3892
+ threadId: parsed.threadId,
3893
+ messageId: log._messageId,
3894
+ activityLog: cleanSteps,
3895
+ activitySummary: this.generateActivitySummary(log.steps)
3896
+ }));
3766
3897
  }
3767
3898
 
3768
3899
  generateActivitySummary(steps) {
@@ -3870,11 +4001,12 @@ export function createApp(config = {}) {
3870
4001
  const isIcon = urlPath.startsWith('/icons/');
3871
4002
  const isLib = urlPath.startsWith('/lib/');
3872
4003
  const isFrontend = urlPath.startsWith('/frontend/');
4004
+ const isEmoji = urlPath.startsWith('/emoji/');
3873
4005
  const isConfig = urlPath === '/config.js';
3874
- const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
4006
+ const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
3875
4007
  if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
3876
4008
  const ext = path.extname(staticPath).toLowerCase();
3877
- const mimeMap = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml' };
4009
+ const mimeMap = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp' };
3878
4010
  const ct = mimeMap[ext] || 'application/octet-stream';
3879
4011
  const stat = fs.statSync(staticPath);
3880
4012
  res.writeHead(200, { 'Content-Type': ct, 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' });
@@ -3885,8 +4017,79 @@ export function createApp(config = {}) {
3885
4017
  let p;
3886
4018
  if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return _handleServeUpload(req, res, p);
3887
4019
 
4020
+ // Custom emoji listing (no auth)
4021
+ if (method === 'GET' && urlPath === '/api/emoji') {
4022
+ const emojiDir = path.join(__dirname, 'emoji');
4023
+ if (!fs.existsSync(emojiDir)) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('[]'); }
4024
+ try {
4025
+ const packs = fs.readdirSync(emojiDir).filter(d => fs.statSync(path.join(emojiDir, d)).isDirectory());
4026
+ const result = [];
4027
+ for (const pack of packs) {
4028
+ const files = fs.readdirSync(path.join(emojiDir, pack)).filter(f => /\.(png|gif|webp|jpg|jpeg)$/i.test(f));
4029
+ for (const file of files) result.push({ name: file.replace(/\.[^.]+$/, ''), pack, path: `/emoji/${pack}/${file}` });
4030
+ }
4031
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
4032
+ return res.end(JSON.stringify(result));
4033
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4034
+ }
4035
+
4036
+ // Search slackmojis.com (no auth, proxied)
4037
+ if (method === 'GET' && urlPath === '/api/emoji/search') {
4038
+ const q = query.q || '';
4039
+ if (!q) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing ?q=' })); }
4040
+ try {
4041
+ const https = await import('https');
4042
+ const html = await new Promise((resolve, reject) => {
4043
+ https.default.get(`https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`, (resp) => {
4044
+ let body = ''; resp.on('data', c => body += c); resp.on('end', () => resolve(body));
4045
+ }).on('error', reject);
4046
+ });
4047
+ const results = [];
4048
+ const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
4049
+ let match;
4050
+ while ((match = regex.exec(html)) !== null && results.length < 50) {
4051
+ const name = match[1].replace(/^\d+-/, '');
4052
+ results.push({ name, image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
4053
+ }
4054
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4055
+ return res.end(JSON.stringify(results));
4056
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4057
+ }
4058
+
3888
4059
  if (!_checkAuth(req, res)) return;
3889
4060
 
4061
+ // Download emoji from URL (auth required)
4062
+ if (method === 'POST' && urlPath === '/api/emoji/add') {
4063
+ try {
4064
+ const { url, name, pack } = await parseBody(req);
4065
+ if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
4066
+ const targetPack = pack || 'custom';
4067
+ const emojiDir = path.join(__dirname, 'emoji', targetPack);
4068
+ if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
4069
+ const https = await import('https');
4070
+ const http = await import('http');
4071
+ const download = (dlUrl, redirects = 0) => new Promise((resolve, reject) => {
4072
+ if (redirects > 5) return reject(new Error('Too many redirects'));
4073
+ const mod = dlUrl.startsWith('https') ? https.default : http.default;
4074
+ mod.get(dlUrl, (resp) => {
4075
+ if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) return resolve(download(resp.headers.location, redirects + 1));
4076
+ if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
4077
+ const chunks = []; resp.on('data', c => chunks.push(c)); resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
4078
+ }).on('error', reject);
4079
+ });
4080
+ const { buffer, contentType } = await download(url);
4081
+ let ext = 'png';
4082
+ if (contentType.includes('gif')) ext = 'gif';
4083
+ else if (contentType.includes('webp')) ext = 'webp';
4084
+ else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
4085
+ else { const u = url.split('?')[0].split('.').pop().toLowerCase(); if (['png','gif','webp','jpg','jpeg'].includes(u)) ext = u; }
4086
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
4087
+ fs.writeFileSync(path.join(emojiDir, `${safeName}.${ext}`), buffer);
4088
+ res.writeHead(200, { 'Content-Type': 'application/json' });
4089
+ return res.end(JSON.stringify({ name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` }));
4090
+ } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
4091
+ }
4092
+
3890
4093
  try {
3891
4094
  if (method === 'GET' && urlPath === '/api/file') return handleServeFile(req, res, query);
3892
4095
  if (method === 'GET' && urlPath === '/api/workspace') return handleWorkspaceList(req, res, query);