@clawchatsai/connector 0.0.28 → 0.0.30

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 +216 -284
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
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
@@ -832,12 +832,14 @@ function handleGetThreads(req, res, params, query) {
832
832
  `SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`
833
833
  ).get(...matchingIds).c;
834
834
  threads = db.prepare(
835
- `SELECT * FROM threads WHERE id IN (${placeholders}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`
835
+ `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
836
+ FROM threads t WHERE t.id IN (${placeholders}) ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
836
837
  ).all(...matchingIds, limit, offset);
837
838
  } else {
838
839
  total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
839
840
  threads = db.prepare(
840
- 'SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?'
841
+ `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
842
+ FROM threads t ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
841
843
  ).all(limit, offset);
842
844
  }
843
845
 
@@ -2228,9 +2230,19 @@ class GatewayClient {
2228
2230
 
2229
2231
  // Clean up stale activity logs every 5 minutes (runs that never completed)
2230
2232
  setInterval(() => {
2231
- const cutoff = Date.now() - 10 * 60 * 1000; // 10 minutes
2233
+ const cutoff = Date.now() - 10 * 60 * 1000;
2232
2234
  for (const [runId, log] of this.activityLogs) {
2233
2235
  if (log.startTime < cutoff) {
2236
+ if (log._messageId) {
2237
+ const db = getDb(log._parsed?.workspace);
2238
+ if (db) {
2239
+ db.prepare(`
2240
+ UPDATE messages SET content = '[Response interrupted]',
2241
+ metadata = json_remove(metadata, '$.pending')
2242
+ WHERE id = ? AND content = ''
2243
+ `).run(log._messageId);
2244
+ }
2245
+ }
2234
2246
  this.activityLogs.delete(runId);
2235
2247
  }
2236
2248
  }
@@ -2311,17 +2323,6 @@ class GatewayClient {
2311
2323
  this.broadcastGatewayStatus(true);
2312
2324
  }
2313
2325
 
2314
- // Handle activity log history responses
2315
- if (msg.type === 'res' && msg.id && this._pendingActivityCallbacks?.has(msg.id)) {
2316
- const callback = this._pendingActivityCallbacks.get(msg.id);
2317
- this._pendingActivityCallbacks.delete(msg.id);
2318
- if (msg.ok) {
2319
- callback(msg.payload);
2320
- } else {
2321
- callback(null);
2322
- }
2323
- }
2324
-
2325
2326
  // Forward all messages to browser clients
2326
2327
  this.broadcastToBrowsers(data);
2327
2328
 
@@ -2369,9 +2370,8 @@ class GatewayClient {
2369
2370
 
2370
2371
  saveAssistantMessage(sessionKey, message, seq) {
2371
2372
  const parsed = parseSessionKey(sessionKey);
2372
- if (!parsed) return; // Non-ClawChats session key, silently ignore
2373
+ if (!parsed) return;
2373
2374
 
2374
- // Guard: verify workspace still exists
2375
2375
  const ws = getWorkspaces();
2376
2376
  if (!ws.workspaces[parsed.workspace]) {
2377
2377
  console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
@@ -2380,49 +2380,66 @@ class GatewayClient {
2380
2380
 
2381
2381
  const db = getDb(parsed.workspace);
2382
2382
 
2383
- // Guard: verify thread still exists
2384
2383
  const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2385
2384
  if (!thread) {
2386
2385
  console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
2387
2386
  return;
2388
2387
  }
2389
2388
 
2390
- // Extract content
2391
2389
  const content = extractContent(message);
2392
-
2393
- // Guard: skip empty content
2394
2390
  if (!content || !content.trim()) {
2395
2391
  console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
2396
2392
  return;
2397
2393
  }
2398
2394
 
2399
- // Deterministic message ID from seq (deduplicates tool-call loops)
2400
2395
  const now = Date.now();
2401
- const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2402
2396
 
2403
- // Upsert message: INSERT OR REPLACE (same seq → same messageId → update content)
2404
- try {
2397
+ // Check for pending activity message
2398
+ const pendingMsg = db.prepare(`
2399
+ SELECT id, metadata FROM messages
2400
+ WHERE thread_id = ? AND role = 'assistant'
2401
+ AND json_extract(metadata, '$.pending') = 1
2402
+ ORDER BY timestamp DESC LIMIT 1
2403
+ `).get(parsed.threadId);
2404
+
2405
+ let messageId;
2406
+
2407
+ if (pendingMsg) {
2408
+ // Merge final content into existing activity row
2409
+ const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
2410
+ delete metadata.pending;
2411
+
2412
+ // Clean up: remove last assistant narration (it's the final reply text)
2413
+ if (metadata.activityLog) {
2414
+ const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
2415
+ if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
2416
+ metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
2417
+ }
2418
+
2419
+ db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
2420
+ .run(content, JSON.stringify(metadata), now, pendingMsg.id);
2421
+
2422
+ messageId = pendingMsg.id;
2423
+ } else {
2424
+ // No pending activity — normal INSERT (simple responses, no tools)
2425
+ messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2405
2426
  db.prepare(`
2406
2427
  INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2407
2428
  VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2408
2429
  ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2409
2430
  `).run(messageId, parsed.threadId, content, now, now);
2431
+ }
2410
2432
 
2411
- // Update thread updated_at
2433
+ // Thread timestamp + unreads + broadcast (same for both paths)
2434
+ try {
2412
2435
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
2413
-
2414
- // Always mark as unread server-side. The browser client is responsible
2415
- // for sending read receipts (active-thread) to clear unreads — same
2416
- // pattern as Slack/Discord. Server doesn't track viewport state.
2417
2436
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2418
2437
  syncThreadUnreadCount(db, parsed.threadId);
2419
2438
 
2420
- // Get thread title and unread info for notification
2421
2439
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2422
2440
  const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2423
2441
  const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2424
2442
 
2425
- // Broadcast message-saved for active thread reload
2426
2443
  this.broadcastToBrowsers(JSON.stringify({
2427
2444
  type: 'clawchats',
2428
2445
  event: 'message-saved',
@@ -2435,7 +2452,6 @@ class GatewayClient {
2435
2452
  unreadCount
2436
2453
  }));
2437
2454
 
2438
- // Always broadcast unread-update — browser sends read receipts to clear
2439
2455
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2440
2456
  this.broadcastToBrowsers(JSON.stringify({
2441
2457
  type: 'clawchats',
@@ -2451,7 +2467,7 @@ class GatewayClient {
2451
2467
  timestamp: now
2452
2468
  }));
2453
2469
 
2454
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
2470
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
2455
2471
  } catch (e) {
2456
2472
  console.error(`Failed to save assistant message:`, e.message);
2457
2473
  }
@@ -2498,8 +2514,6 @@ class GatewayClient {
2498
2514
  // Capture intermediate text turns (narration between tool calls)
2499
2515
  const text = data?.text || '';
2500
2516
  if (text) {
2501
- // Update or create the current assistant text segment
2502
- // Each new assistant segment starts after a tool call
2503
2517
  let currentSegment = log._currentAssistantSegment;
2504
2518
  if (!currentSegment || currentSegment._sealed) {
2505
2519
  currentSegment = {
@@ -2515,15 +2529,11 @@ class GatewayClient {
2515
2529
  }
2516
2530
  }
2517
2531
  // Don't broadcast on every assistant delta — too noisy
2518
- // We'll broadcast when a tool starts (which seals the current segment)
2519
2532
  return;
2520
2533
  }
2521
2534
 
2522
2535
  if (stream === 'thinking') {
2523
- // Reasoning/thinking text from the model (requires reasoningLevel: "stream")
2524
2536
  const thinkingText = data?.text || '';
2525
- const delta = data?.delta || '';
2526
- // Update or create thinking step — we keep a single thinking step that accumulates
2527
2537
  let thinkingStep = log.steps.find(s => s.type === 'thinking');
2528
2538
  if (thinkingStep) {
2529
2539
  thinkingStep.text = thinkingText;
@@ -2534,11 +2544,12 @@ class GatewayClient {
2534
2544
  text: thinkingText
2535
2545
  });
2536
2546
  }
2537
- // Broadcast to browser for live display (throttled — thinking events come fast)
2547
+ // Always write to DB; throttle broadcasts to every 300ms
2548
+ this._writeActivityToDb(runId, log);
2538
2549
  const now = Date.now();
2539
2550
  if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2540
2551
  log._lastThinkingBroadcast = now;
2541
- this.broadcastActivityUpdate(runId, log);
2552
+ this._broadcastActivityUpdate(runId, log);
2542
2553
  }
2543
2554
  }
2544
2555
 
@@ -2546,8 +2557,6 @@ class GatewayClient {
2546
2557
  // Seal any current assistant text segment (narration before this tool call)
2547
2558
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2548
2559
  log._currentAssistantSegment._sealed = true;
2549
- // Broadcast the narration + upcoming tool
2550
- this.broadcastActivityUpdate(runId, log);
2551
2560
  }
2552
2561
 
2553
2562
  const step = {
@@ -2573,21 +2582,18 @@ class GatewayClient {
2573
2582
  log.steps.push(step);
2574
2583
  }
2575
2584
  } else if (data?.phase === 'update') {
2576
- // Merge update events into the existing step — don't create new entries
2577
2585
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2578
2586
  if (existing) {
2579
- // Update meta if the update carries new info
2580
2587
  if (data?.meta) existing.resultMeta = data.meta;
2581
2588
  if (data?.isError) existing.isError = true;
2582
2589
  existing.phase = 'running';
2583
2590
  }
2584
- // If no existing step found, silently ignore the orphaned update
2585
2591
  } else {
2586
2592
  log.steps.push(step);
2587
2593
  }
2588
2594
 
2589
- // Forward to browser for real-time display
2590
- this.broadcastActivityUpdate(runId, log);
2595
+ this._writeActivityToDb(runId, log);
2596
+ this._broadcastActivityUpdate(runId, log);
2591
2597
  }
2592
2598
 
2593
2599
  if (stream === 'lifecycle') {
@@ -2597,184 +2603,85 @@ class GatewayClient {
2597
2603
  log._currentAssistantSegment._sealed = true;
2598
2604
  }
2599
2605
  // Remove the final assistant segment — that's the actual response, not narration
2600
- // The last assistant segment is the final reply text, which is displayed as the message itself
2601
2606
  const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2602
2607
  if (lastAssistantIdx >= 0) {
2603
2608
  log.steps.splice(lastAssistantIdx, 1);
2604
2609
  }
2605
2610
 
2606
- log.steps.push({ type: 'lifecycle', timestamp: Date.now(), phase: data.phase });
2607
-
2608
- // Finalize: save to message metadata
2609
- this.finalizeActivityLog(runId, log);
2611
+ // Write final state to DB, then clean up
2612
+ this._writeActivityToDb(runId, log);
2613
+ this.activityLogs.delete(runId);
2614
+ return;
2610
2615
  }
2611
2616
  }
2612
2617
  }
2613
2618
 
2614
- broadcastActivityUpdate(runId, log) {
2615
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2616
- if (!parsed) return;
2617
-
2618
- this.broadcastToBrowsers(JSON.stringify({
2619
- type: 'clawchats',
2620
- event: 'agent-activity',
2621
- workspace: parsed.workspace,
2622
- threadId: parsed.threadId,
2623
- runId,
2624
- steps: log.steps,
2625
- summary: this.generateActivitySummary(log.steps)
2626
- }));
2627
- }
2628
-
2629
- finalizeActivityLog(runId, log) {
2630
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2619
+ _writeActivityToDb(runId, log) {
2620
+ if (!log._parsed) {
2621
+ log._parsed = parseSessionKey(log.sessionKey);
2622
+ }
2623
+ const parsed = log._parsed;
2631
2624
  if (!parsed) return;
2632
2625
 
2633
2626
  const db = getDb(parsed.workspace);
2634
2627
  if (!db) return;
2635
2628
 
2636
- const hasToolSteps = log.steps.some(s => s.type === 'tool');
2637
-
2638
- // If we got no tool events (e.g., webchat session where we're not a registered recipient),
2639
- // try to extract tool calls from the session history via Gateway API
2640
- if (!hasToolSteps && this.ws && this.ws.readyState === 1 /* OPEN */) {
2641
- this.fetchToolCallsFromHistory(log.sessionKey, parsed, db, log, runId);
2642
- return;
2643
- }
2644
-
2645
- // Save activity log to message metadata (delay to ensure final message is saved first)
2646
- this.saveActivityToMessage(parsed, db, log, runId);
2647
- }
2629
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2630
+ const summary = this.generateActivitySummary(log.steps);
2631
+ const now = Date.now();
2648
2632
 
2649
- fetchToolCallsFromHistory(sessionKey, parsed, db, log, runId) {
2650
- const reqId = `activity-hist-${runId}`;
2633
+ if (!log._messageId) {
2634
+ // First write — INSERT the assistant message row
2635
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2636
+ if (!thread) return;
2651
2637
 
2652
- const timeout = setTimeout(() => {
2653
- // Timeout: save what we have (even if empty)
2654
- this._pendingActivityCallbacks?.delete(reqId);
2655
- this.saveActivityToMessage(parsed, db, log, runId);
2656
- }, 5000);
2638
+ const messageId = `gw-activity-${runId}`;
2639
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
2657
2640
 
2658
- // Register callback for when the gateway responds
2659
- if (!this._pendingActivityCallbacks) this._pendingActivityCallbacks = new Map();
2660
- this._pendingActivityCallbacks.set(reqId, (payload) => {
2661
- clearTimeout(timeout);
2641
+ db.prepare(`
2642
+ INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
2643
+ VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
2644
+ `).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
2662
2645
 
2663
- // Extract tool calls and narration from the last assistant message in history
2664
- if (payload?.messages) {
2665
- for (let i = payload.messages.length - 1; i >= 0; i--) {
2666
- const m = payload.messages[i];
2667
- if (m.role === 'assistant' && Array.isArray(m.content)) {
2668
- let hasToolUse = false;
2669
- for (const block of m.content) {
2670
- if (block.type === 'text' && block.text?.trim() && hasToolUse) {
2671
- // Text after a tool_use = intermediate narration (not the first text which is part of the response)
2672
- // Actually, text BEFORE a tool_use is narration too
2673
- }
2674
- if (block.type === 'text' && block.text?.trim()) {
2675
- // We'll collect all text blocks, then trim the last one (that's the actual response)
2676
- log.steps.push({
2677
- type: 'assistant',
2678
- timestamp: Date.now(),
2679
- text: block.text.trim()
2680
- });
2681
- }
2682
- if (block.type === 'tool_use') {
2683
- hasToolUse = true;
2684
- log.steps.push({
2685
- type: 'tool',
2686
- timestamp: Date.now(),
2687
- name: block.name || 'unknown',
2688
- phase: 'done',
2689
- toolCallId: block.id,
2690
- meta: this.extractToolMeta(block),
2691
- isError: false
2692
- });
2693
- }
2694
- }
2695
- // Remove the last assistant text — that's the final response, not narration
2696
- if (hasToolUse) {
2697
- const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2698
- if (lastAssistantIdx >= 0) {
2699
- log.steps.splice(lastAssistantIdx, 1);
2700
- }
2701
- } else {
2702
- // No tool calls — remove all assistant narration (nothing interesting to show)
2703
- log.steps = log.steps.filter(s => s.type !== 'assistant');
2704
- }
2705
- break; // Only process the last assistant message
2706
- }
2707
- }
2708
- }
2646
+ log._messageId = messageId;
2709
2647
 
2710
- this.saveActivityToMessage(parsed, db, log, runId);
2711
- this.broadcastActivityUpdate(runId, log);
2712
- });
2648
+ // First event — broadcast message-saved so browser creates the message element
2649
+ this.broadcastToBrowsers(JSON.stringify({
2650
+ type: 'clawchats',
2651
+ event: 'message-saved',
2652
+ threadId: parsed.threadId,
2653
+ workspace: parsed.workspace,
2654
+ messageId,
2655
+ timestamp: now
2656
+ }));
2657
+ } else {
2658
+ // Subsequent writes — UPDATE metadata on existing row
2659
+ const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
2660
+ const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
2661
+ metadata.activityLog = cleanSteps;
2662
+ metadata.activitySummary = summary;
2663
+ metadata.pending = true;
2713
2664
 
2714
- // Request session history from gateway
2715
- this.ws.send(JSON.stringify({
2716
- type: 'req',
2717
- id: reqId,
2718
- method: 'sessions.history',
2719
- params: { key: sessionKey, limit: 3, includeTools: true }
2720
- }));
2665
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
2666
+ .run(JSON.stringify(metadata), log._messageId);
2667
+ }
2721
2668
  }
2722
2669
 
2723
- extractToolMeta(toolUseBlock) {
2724
- if (!toolUseBlock.input) return '';
2725
- const input = toolUseBlock.input;
2726
- // Generate a brief description from the tool input
2727
- if (input.query) return input.query;
2728
- if (input.command) return input.command.substring(0, 80);
2729
- if (input.file_path || input.path) return input.file_path || input.path;
2730
- if (input.url) return input.url;
2731
- if (input.text) return input.text.substring(0, 60);
2732
- return '';
2733
- }
2670
+ _broadcastActivityUpdate(runId, log) {
2671
+ const parsed = log._parsed;
2672
+ if (!parsed || !log._messageId) return;
2734
2673
 
2735
- saveActivityToMessage(parsed, db, log, runId) {
2736
- // Delay to ensure the assistant message is saved first (from handleChatEvent final)
2737
- setTimeout(() => {
2738
- try {
2739
- const msg = db.prepare(
2740
- 'SELECT id, metadata FROM messages WHERE thread_id = ? AND role = ? ORDER BY timestamp DESC LIMIT 1'
2741
- ).get(parsed.threadId, 'assistant');
2742
-
2743
- if (msg) {
2744
- const toolSteps = log.steps.filter(s => s.type === 'tool' && s.phase !== 'update');
2745
- const thinkingSteps = log.steps.filter(s => s.type === 'thinking');
2746
- const narrativeSteps = log.steps.filter(s => s.type === 'assistant' && s.text?.trim());
2747
- // Only save if we have meaningful activity
2748
- if (toolSteps.length > 0 || thinkingSteps.length > 0 || narrativeSteps.length > 0) {
2749
- const metadata = msg.metadata ? JSON.parse(msg.metadata) : {};
2750
- // Clean internal fields before persisting
2751
- metadata.activityLog = log.steps.map(s => {
2752
- const clean = { ...s };
2753
- delete clean._sealed;
2754
- return clean;
2755
- });
2756
- metadata.activitySummary = this.generateActivitySummary(log.steps);
2757
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
2758
- .run(JSON.stringify(metadata), msg.id);
2759
-
2760
- console.log(`[ActivityLog] Saved ${toolSteps.length} tool steps for message ${msg.id}`);
2761
-
2762
- // Notify browsers to re-render this message with activity data
2763
- this.broadcastToBrowsers(JSON.stringify({
2764
- type: 'clawchats',
2765
- event: 'activity-saved',
2766
- workspace: parsed.workspace,
2767
- threadId: parsed.threadId,
2768
- messageId: msg.id
2769
- }));
2770
- }
2771
- }
2772
- } catch (e) {
2773
- console.error('Failed to save activity log:', e.message);
2774
- }
2674
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2775
2675
 
2776
- this.activityLogs.delete(runId);
2777
- }, 1000); // 1s delay — must happen after message-saved (which fires on chat:final)
2676
+ this.broadcastToBrowsers(JSON.stringify({
2677
+ type: 'clawchats',
2678
+ event: 'activity-updated',
2679
+ workspace: parsed.workspace,
2680
+ threadId: parsed.threadId,
2681
+ messageId: log._messageId,
2682
+ activityLog: cleanSteps,
2683
+ activitySummary: this.generateActivitySummary(log.steps)
2684
+ }));
2778
2685
  }
2779
2686
 
2780
2687
  generateActivitySummary(steps) {
@@ -3600,7 +3507,19 @@ export function createApp(config = {}) {
3600
3507
  setInterval(() => {
3601
3508
  const cutoff = Date.now() - 10 * 60 * 1000;
3602
3509
  for (const [runId, log] of this.activityLogs) {
3603
- if (log.startTime < cutoff) this.activityLogs.delete(runId);
3510
+ if (log.startTime < cutoff) {
3511
+ if (log._messageId) {
3512
+ const db = _getDb(log._parsed?.workspace);
3513
+ if (db) {
3514
+ db.prepare(`
3515
+ UPDATE messages SET content = '[Response interrupted]',
3516
+ metadata = json_remove(metadata, '$.pending')
3517
+ WHERE id = ? AND content = ''
3518
+ `).run(log._messageId);
3519
+ }
3520
+ }
3521
+ this.activityLogs.delete(runId);
3522
+ }
3604
3523
  }
3605
3524
  }, 5 * 60 * 1000);
3606
3525
  }
@@ -3632,11 +3551,6 @@ export function createApp(config = {}) {
3632
3551
  return;
3633
3552
  }
3634
3553
  if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }
3635
- if (msg.type === 'res' && msg.id && this._pendingActivityCallbacks?.has(msg.id)) {
3636
- const callback = this._pendingActivityCallbacks.get(msg.id);
3637
- this._pendingActivityCallbacks.delete(msg.id);
3638
- if (msg.ok) callback(msg.payload); else callback(null);
3639
- }
3640
3554
  this.broadcastToBrowsers(data);
3641
3555
  if (msg.type === 'event' && msg.event === 'chat' && msg.payload) this.handleChatEvent(msg.payload);
3642
3556
  if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
@@ -3669,11 +3583,37 @@ export function createApp(config = {}) {
3669
3583
  const content = extractContent(message);
3670
3584
  if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
3671
3585
  const now = Date.now();
3672
- const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3673
- try {
3586
+
3587
+ // Check for pending activity message
3588
+ const pendingMsg = db.prepare(`
3589
+ SELECT id, metadata FROM messages
3590
+ WHERE thread_id = ? AND role = 'assistant'
3591
+ AND json_extract(metadata, '$.pending') = 1
3592
+ ORDER BY timestamp DESC LIMIT 1
3593
+ `).get(parsed.threadId);
3594
+
3595
+ let messageId;
3596
+
3597
+ if (pendingMsg) {
3598
+ // Merge final content into existing activity row
3599
+ const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
3600
+ delete metadata.pending;
3601
+ if (metadata.activityLog) {
3602
+ const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
3603
+ if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
3604
+ metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
3605
+ }
3606
+ db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
3607
+ .run(content, JSON.stringify(metadata), now, pendingMsg.id);
3608
+ messageId = pendingMsg.id;
3609
+ } else {
3610
+ // No pending activity — normal INSERT (simple responses, no tools)
3611
+ messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3674
3612
  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);
3613
+ }
3614
+
3615
+ try {
3675
3616
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
3676
- // Always mark as unread — browser sends read receipts to clear
3677
3617
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
3678
3618
  syncThreadUnreadCount(db, parsed.threadId);
3679
3619
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
@@ -3682,7 +3622,7 @@ export function createApp(config = {}) {
3682
3622
  this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
3683
3623
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3684
3624
  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 }));
3685
- console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq: ${seq})`);
3625
+ console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
3686
3626
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3687
3627
  }
3688
3628
 
@@ -3726,14 +3666,15 @@ export function createApp(config = {}) {
3726
3666
  let thinkingStep = log.steps.find(s => s.type === 'thinking');
3727
3667
  if (thinkingStep) { thinkingStep.text = thinkingText; }
3728
3668
  else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
3669
+ this._writeActivityToDb(runId, log);
3729
3670
  const now = Date.now();
3730
3671
  if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3731
3672
  log._lastThinkingBroadcast = now;
3732
- this.broadcastActivityUpdate(runId, log);
3673
+ this._broadcastActivityUpdate(runId, log);
3733
3674
  }
3734
3675
  }
3735
3676
  if (stream === 'tool') {
3736
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; this.broadcastActivityUpdate(runId, log); }
3677
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; }
3737
3678
  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 };
3738
3679
  if (data?.phase === 'result') {
3739
3680
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
@@ -3743,93 +3684,84 @@ export function createApp(config = {}) {
3743
3684
  const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3744
3685
  if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
3745
3686
  } else { log.steps.push(step); }
3746
- this.broadcastActivityUpdate(runId, log);
3687
+ this._writeActivityToDb(runId, log);
3688
+ this._broadcastActivityUpdate(runId, log);
3747
3689
  }
3748
3690
  if (stream === 'lifecycle') {
3749
3691
  if (data?.phase === 'end' || data?.phase === 'error') {
3750
3692
  if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
3751
3693
  const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3752
3694
  if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
3753
- log.steps.push({ type: 'lifecycle', timestamp: Date.now(), phase: data.phase });
3754
- this.finalizeActivityLog(runId, log);
3695
+ this._writeActivityToDb(runId, log);
3696
+ this.activityLogs.delete(runId);
3697
+ return;
3755
3698
  }
3756
3699
  }
3757
3700
  }
3758
3701
 
3759
- broadcastActivityUpdate(runId, log) {
3760
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3702
+ _writeActivityToDb(runId, log) {
3703
+ if (!log._parsed) {
3704
+ log._parsed = parseSessionKey(log.sessionKey);
3705
+ }
3706
+ const parsed = log._parsed;
3761
3707
  if (!parsed) return;
3762
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'agent-activity', workspace: parsed.workspace, threadId: parsed.threadId, runId, steps: log.steps, summary: this.generateActivitySummary(log.steps) }));
3763
- }
3764
3708
 
3765
- finalizeActivityLog(runId, log) {
3766
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3767
- if (!parsed) return;
3768
3709
  const db = _getDb(parsed.workspace);
3769
3710
  if (!db) return;
3770
- const hasToolSteps = log.steps.some(s => s.type === 'tool');
3771
- if (!hasToolSteps && this.ws && this.ws.readyState === 1) { this.fetchToolCallsFromHistory(log.sessionKey, parsed, db, log, runId); return; }
3772
- this.saveActivityToMessage(parsed, db, log, runId);
3773
- }
3774
-
3775
- fetchToolCallsFromHistory(sessionKey, parsed, db, log, runId) {
3776
- const reqId = `activity-hist-${runId}`;
3777
- const timeout = setTimeout(() => { this._pendingActivityCallbacks?.delete(reqId); this.saveActivityToMessage(parsed, db, log, runId); }, 5000);
3778
- if (!this._pendingActivityCallbacks) this._pendingActivityCallbacks = new Map();
3779
- this._pendingActivityCallbacks.set(reqId, (payload) => {
3780
- clearTimeout(timeout);
3781
- if (payload?.messages) {
3782
- for (let i = payload.messages.length - 1; i >= 0; i--) {
3783
- const m = payload.messages[i];
3784
- if (m.role === 'assistant' && Array.isArray(m.content)) {
3785
- let hasToolUse = false;
3786
- for (const block of m.content) {
3787
- if (block.type === 'text' && block.text?.trim()) log.steps.push({ type: 'assistant', timestamp: Date.now(), text: block.text.trim() });
3788
- if (block.type === 'tool_use') { hasToolUse = true; log.steps.push({ type: 'tool', timestamp: Date.now(), name: block.name || 'unknown', phase: 'done', toolCallId: block.id, meta: this.extractToolMeta(block), isError: false }); }
3789
- }
3790
- if (hasToolUse) { const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant'); if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1); }
3791
- else log.steps = log.steps.filter(s => s.type !== 'assistant');
3792
- break;
3793
- }
3794
- }
3795
- }
3796
- this.saveActivityToMessage(parsed, db, log, runId);
3797
- this.broadcastActivityUpdate(runId, log);
3798
- });
3799
- this.ws.send(JSON.stringify({ type: 'req', id: reqId, method: 'sessions.history', params: { key: sessionKey, limit: 3, includeTools: true } }));
3800
- }
3801
3711
 
3802
- extractToolMeta(toolUseBlock) {
3803
- if (!toolUseBlock.input) return '';
3804
- const input = toolUseBlock.input;
3805
- if (input.query) return input.query;
3806
- if (input.command) return input.command.substring(0, 80);
3807
- if (input.file_path || input.path) return input.file_path || input.path;
3808
- if (input.url) return input.url;
3809
- if (input.text) return input.text.substring(0, 60);
3810
- return '';
3712
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3713
+ const summary = this.generateActivitySummary(log.steps);
3714
+ const now = Date.now();
3715
+
3716
+ if (!log._messageId) {
3717
+ const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
3718
+ if (!thread) return;
3719
+
3720
+ const messageId = `gw-activity-${runId}`;
3721
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
3722
+
3723
+ db.prepare(`
3724
+ INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
3725
+ VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
3726
+ `).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
3727
+
3728
+ log._messageId = messageId;
3729
+
3730
+ this.broadcastToBrowsers(JSON.stringify({
3731
+ type: 'clawchats',
3732
+ event: 'message-saved',
3733
+ threadId: parsed.threadId,
3734
+ workspace: parsed.workspace,
3735
+ messageId,
3736
+ timestamp: now
3737
+ }));
3738
+ } else {
3739
+ const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
3740
+ const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
3741
+ metadata.activityLog = cleanSteps;
3742
+ metadata.activitySummary = summary;
3743
+ metadata.pending = true;
3744
+
3745
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
3746
+ .run(JSON.stringify(metadata), log._messageId);
3747
+ }
3811
3748
  }
3812
3749
 
3813
- saveActivityToMessage(parsed, db, log, runId) {
3814
- setTimeout(() => {
3815
- try {
3816
- const msg = db.prepare('SELECT id, metadata FROM messages WHERE thread_id = ? AND role = ? ORDER BY timestamp DESC LIMIT 1').get(parsed.threadId, 'assistant');
3817
- if (msg) {
3818
- const toolSteps = log.steps.filter(s => s.type === 'tool' && s.phase !== 'update');
3819
- const thinkingSteps = log.steps.filter(s => s.type === 'thinking');
3820
- const narrativeSteps = log.steps.filter(s => s.type === 'assistant' && s.text?.trim());
3821
- if (toolSteps.length > 0 || thinkingSteps.length > 0 || narrativeSteps.length > 0) {
3822
- const metadata = msg.metadata ? JSON.parse(msg.metadata) : {};
3823
- metadata.activityLog = log.steps.map(s => { const clean = { ...s }; delete clean._sealed; return clean; });
3824
- metadata.activitySummary = this.generateActivitySummary(log.steps);
3825
- db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), msg.id);
3826
- console.log(`[ActivityLog] Saved ${toolSteps.length} tool steps for message ${msg.id}`);
3827
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'activity-saved', workspace: parsed.workspace, threadId: parsed.threadId, messageId: msg.id }));
3828
- }
3829
- }
3830
- } catch (e) { console.error('Failed to save activity log:', e.message); }
3831
- this.activityLogs.delete(runId);
3832
- }, 1000);
3750
+ _broadcastActivityUpdate(runId, log) {
3751
+ const parsed = log._parsed;
3752
+ if (!parsed || !log._messageId) return;
3753
+
3754
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
3755
+
3756
+ this.broadcastToBrowsers(JSON.stringify({
3757
+ type: 'clawchats',
3758
+ event: 'activity-updated',
3759
+ workspace: parsed.workspace,
3760
+ threadId: parsed.threadId,
3761
+ messageId: log._messageId,
3762
+ activityLog: cleanSteps,
3763
+ activitySummary: this.generateActivitySummary(log.steps)
3764
+ }));
3833
3765
  }
3834
3766
 
3835
3767
  generateActivitySummary(steps) {