@clawchatsai/connector 0.0.29 → 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 +335 -336
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.29",
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
@@ -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(
@@ -2233,14 +2226,24 @@ class GatewayClient {
2233
2226
  this.maxReconnectDelay = 30000;
2234
2227
  this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
2235
2228
  this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
2236
- this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
2229
+ this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
2237
2230
 
2238
- // Clean up stale run states every 5 minutes (runs that never completed)
2231
+ // Clean up stale activity logs every 5 minutes (runs that never completed)
2239
2232
  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);
2233
+ const cutoff = Date.now() - 10 * 60 * 1000;
2234
+ for (const [runId, log] of this.activityLogs) {
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
+ }
2246
+ this.activityLogs.delete(runId);
2244
2247
  }
2245
2248
  }
2246
2249
  }, 5 * 60 * 1000);
@@ -2354,10 +2357,9 @@ class GatewayClient {
2354
2357
  this.streamState.delete(sessionKey);
2355
2358
  }
2356
2359
 
2357
- // Save assistant messages on final (use segment seq position if available)
2360
+ // Save assistant messages on final
2358
2361
  if (state === 'final') {
2359
- const run = this.findRunBySessionKey(sessionKey);
2360
- this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq);
2362
+ this.saveAssistantMessage(sessionKey, message, seq);
2361
2363
  }
2362
2364
 
2363
2365
  // Save error markers
@@ -2366,11 +2368,10 @@ class GatewayClient {
2366
2368
  }
2367
2369
  }
2368
2370
 
2369
- saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
2371
+ saveAssistantMessage(sessionKey, message, seq) {
2370
2372
  const parsed = parseSessionKey(sessionKey);
2371
- if (!parsed) return; // Non-ClawChats session key, silently ignore
2373
+ if (!parsed) return;
2372
2374
 
2373
- // Guard: verify workspace still exists
2374
2375
  const ws = getWorkspaces();
2375
2376
  if (!ws.workspaces[parsed.workspace]) {
2376
2377
  console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
@@ -2379,52 +2380,66 @@ class GatewayClient {
2379
2380
 
2380
2381
  const db = getDb(parsed.workspace);
2381
2382
 
2382
- // Guard: verify thread still exists
2383
2383
  const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
2384
2384
  if (!thread) {
2385
2385
  console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
2386
2386
  return;
2387
2387
  }
2388
2388
 
2389
- // Extract content
2390
2389
  const content = extractContent(message);
2391
-
2392
- // Guard: skip empty content
2393
2390
  if (!content || !content.trim()) {
2394
2391
  console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
2395
2392
  return;
2396
2393
  }
2397
2394
 
2398
- // Deterministic message ID from seq (deduplicates tool-call loops)
2399
2395
  const now = Date.now();
2400
- const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2401
2396
 
2402
- // Use segment seq for correct ordering after tool segments
2403
- const msgSeq = segmentSeq != null ? segmentSeq : (seq != null ? seq : null);
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
+ }
2404
2418
 
2405
- // Upsert message: INSERT OR REPLACE (same seq same messageId update content)
2406
- try {
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}`;
2407
2426
  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);
2427
+ INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2428
+ VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2429
+ ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2430
+ `).run(messageId, parsed.threadId, content, now, now);
2431
+ }
2412
2432
 
2413
- // Update thread updated_at
2433
+ // Thread timestamp + unreads + broadcast (same for both paths)
2434
+ try {
2414
2435
  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
2436
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
2420
2437
  syncThreadUnreadCount(db, parsed.threadId);
2421
2438
 
2422
- // Get thread title and unread info for notification
2423
2439
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
2424
2440
  const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
2425
2441
  const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
2426
2442
 
2427
- // Broadcast message-saved for active thread reload
2428
2443
  this.broadcastToBrowsers(JSON.stringify({
2429
2444
  type: 'clawchats',
2430
2445
  event: 'message-saved',
@@ -2437,7 +2452,6 @@ class GatewayClient {
2437
2452
  unreadCount
2438
2453
  }));
2439
2454
 
2440
- // Always broadcast unread-update — browser sends read receipts to clear
2441
2455
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
2442
2456
  this.broadcastToBrowsers(JSON.stringify({
2443
2457
  type: 'clawchats',
@@ -2453,7 +2467,7 @@ class GatewayClient {
2453
2467
  timestamp: now
2454
2468
  }));
2455
2469
 
2456
- 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})`);
2457
2471
  } catch (e) {
2458
2472
  console.error(`Failed to save assistant message:`, e.message);
2459
2473
  }
@@ -2490,242 +2504,183 @@ class GatewayClient {
2490
2504
  const { runId, stream, data, sessionKey } = payload;
2491
2505
  if (!runId) return;
2492
2506
 
2493
- // Initialize run state if needed
2494
- if (!this.runState.has(runId)) {
2495
- this.runState.set(runId, { sessionKey, seqCounter: 0, startTime: Date.now() });
2507
+ // Initialize log if needed
2508
+ if (!this.activityLogs.has(runId)) {
2509
+ this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
2496
2510
  }
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;
2511
+ const log = this.activityLogs.get(runId);
2508
2512
 
2509
2513
  if (stream === 'assistant') {
2514
+ // Capture intermediate text turns (narration between tool calls)
2510
2515
  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
- });
2516
+ if (text) {
2517
+ let currentSegment = log._currentAssistantSegment;
2518
+ if (!currentSegment || currentSegment._sealed) {
2519
+ currentSegment = {
2520
+ type: 'assistant',
2521
+ timestamp: Date.now(),
2522
+ text: text,
2523
+ _sealed: false
2524
+ };
2525
+ log._currentAssistantSegment = currentSegment;
2526
+ log.steps.push(currentSegment);
2527
+ } else {
2528
+ currentSegment.text = text;
2529
+ }
2535
2530
  }
2531
+ // Don't broadcast on every assistant delta — too noisy
2536
2532
  return;
2537
2533
  }
2538
2534
 
2539
2535
  if (stream === 'thinking') {
2540
2536
  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
- }
2537
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
2538
+ if (thinkingStep) {
2539
+ thinkingStep.text = thinkingText;
2556
2540
  } 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,
2541
+ log.steps.push({
2570
2542
  type: 'thinking',
2571
- seq: run.thinkingSeq,
2572
- content: thinkingText
2543
+ timestamp: Date.now(),
2544
+ text: thinkingText
2573
2545
  });
2574
2546
  }
2575
- return;
2547
+ // Always write to DB; throttle broadcasts to every 300ms
2548
+ this._writeActivityToDb(runId, log);
2549
+ const now = Date.now();
2550
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2551
+ log._lastThinkingBroadcast = now;
2552
+ this._broadcastActivityUpdate(runId, log);
2553
+ }
2576
2554
  }
2577
2555
 
2578
2556
  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
- }
2557
+ // Seal any current assistant text segment (narration before this tool call)
2558
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2559
+ log._currentAssistantSegment._sealed = true;
2593
2560
  }
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
- });
2561
+
2562
+ const step = {
2563
+ type: 'tool',
2564
+ timestamp: Date.now(),
2565
+ name: data?.name || 'unknown',
2566
+ phase: data?.phase || 'start',
2567
+ toolCallId: data?.toolCallId,
2568
+ meta: data?.meta,
2569
+ isError: data?.isError || false
2570
+ };
2571
+
2572
+ // On result phase, update the existing start step or add new
2573
+ if (data?.phase === 'result') {
2574
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
2575
+ if (existing) {
2576
+ existing.phase = 'done';
2577
+ existing.resultMeta = data?.meta;
2578
+ existing.isError = data?.isError || false;
2579
+ existing.durationMs = Date.now() - existing.timestamp;
2580
+ } else {
2581
+ step.phase = 'done';
2582
+ log.steps.push(step);
2627
2583
  }
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
- });
2584
+ } else if (data?.phase === 'update') {
2585
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2586
+ if (existing) {
2587
+ if (data?.meta) existing.resultMeta = data.meta;
2588
+ if (data?.isError) existing.isError = true;
2589
+ existing.phase = 'running';
2647
2590
  }
2648
2591
  } 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
- });
2592
+ log.steps.push(step);
2675
2593
  }
2676
- return;
2594
+
2595
+ this._writeActivityToDb(runId, log);
2596
+ this._broadcastActivityUpdate(runId, log);
2677
2597
  }
2678
2598
 
2679
2599
  if (stream === 'lifecycle') {
2680
2600
  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;
2601
+ // Seal any remaining assistant segment
2602
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2603
+ log._currentAssistantSegment._sealed = true;
2604
+ }
2605
+ // Remove the final assistant segment — that's the actual response, not narration
2606
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2607
+ if (lastAssistantIdx >= 0) {
2608
+ log.steps.splice(lastAssistantIdx, 1);
2697
2609
  }
2698
2610
 
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);
2611
+ // Write final state to DB, then clean up
2612
+ this._writeActivityToDb(runId, log);
2613
+ this.activityLogs.delete(runId);
2614
+ return;
2710
2615
  }
2711
2616
  }
2712
2617
  }
2713
2618
 
2714
- findRunBySessionKey(sessionKey) {
2715
- for (const [, run] of this.runState.entries()) {
2716
- if (run.sessionKey === sessionKey) return run;
2619
+ _writeActivityToDb(runId, log) {
2620
+ if (!log._parsed) {
2621
+ log._parsed = parseSessionKey(log.sessionKey);
2622
+ }
2623
+ const parsed = log._parsed;
2624
+ if (!parsed) return;
2625
+
2626
+ const db = getDb(parsed.workspace);
2627
+ if (!db) return;
2628
+
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();
2632
+
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;
2637
+
2638
+ const messageId = `gw-activity-${runId}`;
2639
+ const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
2640
+
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);
2645
+
2646
+ log._messageId = messageId;
2647
+
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;
2664
+
2665
+ db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
2666
+ .run(JSON.stringify(metadata), log._messageId);
2717
2667
  }
2718
- return null;
2719
2668
  }
2720
2669
 
2721
- broadcastSegmentUpdate(parsed, runId, segment) {
2670
+ _broadcastActivityUpdate(runId, log) {
2671
+ const parsed = log._parsed;
2672
+ if (!parsed || !log._messageId) return;
2673
+
2674
+ const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
2675
+
2722
2676
  this.broadcastToBrowsers(JSON.stringify({
2723
2677
  type: 'clawchats',
2724
- event: 'segment-update',
2678
+ event: 'activity-updated',
2725
2679
  workspace: parsed.workspace,
2726
2680
  threadId: parsed.threadId,
2727
- runId,
2728
- segment
2681
+ messageId: log._messageId,
2682
+ activityLog: cleanSteps,
2683
+ activitySummary: this.generateActivitySummary(log.steps)
2729
2684
  }));
2730
2685
  }
2731
2686
 
@@ -3548,11 +3503,23 @@ export function createApp(config = {}) {
3548
3503
  this.browserClients = new Map();
3549
3504
  this._externalBroadcastTargets = [];
3550
3505
  this.streamState = new Map();
3551
- this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
3506
+ this.activityLogs = new Map();
3552
3507
  setInterval(() => {
3553
3508
  const cutoff = Date.now() - 10 * 60 * 1000;
3554
- for (const [runId, run] of this.runState) {
3555
- if (run.startTime < cutoff) this.runState.delete(runId);
3509
+ for (const [runId, log] of this.activityLogs) {
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
+ }
3556
3523
  }
3557
3524
  }, 5 * 60 * 1000);
3558
3525
  }
@@ -3601,11 +3568,11 @@ export function createApp(config = {}) {
3601
3568
  return;
3602
3569
  }
3603
3570
  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); }
3571
+ if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3605
3572
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
3606
3573
  }
3607
3574
 
3608
- saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
3575
+ saveAssistantMessage(sessionKey, message, seq) {
3609
3576
  const parsed = parseSessionKey(sessionKey);
3610
3577
  if (!parsed) return;
3611
3578
  const ws = _getWorkspaces();
@@ -3616,12 +3583,37 @@ export function createApp(config = {}) {
3616
3583
  const content = extractContent(message);
3617
3584
  if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
3618
3585
  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);
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}`;
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
+
3621
3615
  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
3616
  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
3617
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
3626
3618
  syncThreadUnreadCount(db, parsed.threadId);
3627
3619
  const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
@@ -3630,7 +3622,7 @@ export function createApp(config = {}) {
3630
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 }));
3631
3623
  const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
3632
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 }));
3633
- 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})`);
3634
3626
  } catch (e) { console.error(`Failed to save assistant message:`, e.message); }
3635
3627
  }
3636
3628
 
@@ -3655,114 +3647,121 @@ export function createApp(config = {}) {
3655
3647
  handleAgentEvent(payload) {
3656
3648
  const { runId, stream, data, sessionKey } = payload;
3657
3649
  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
-
3650
+ if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
3651
+ const log = this.activityLogs.get(runId);
3668
3652
  if (stream === 'assistant') {
3669
3653
  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 });
3654
+ if (text) {
3655
+ let currentSegment = log._currentAssistantSegment;
3656
+ if (!currentSegment || currentSegment._sealed) {
3657
+ currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
3658
+ log._currentAssistantSegment = currentSegment;
3659
+ log.steps.push(currentSegment);
3660
+ } else { currentSegment.text = text; }
3682
3661
  }
3683
3662
  return;
3684
3663
  }
3685
-
3686
3664
  if (stream === 'thinking') {
3687
3665
  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
- }
3666
+ let thinkingStep = log.steps.find(s => s.type === 'thinking');
3667
+ if (thinkingStep) { thinkingStep.text = thinkingText; }
3668
+ else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
3669
+ this._writeActivityToDb(runId, log);
3698
3670
  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 });
3671
+ if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3672
+ log._lastThinkingBroadcast = now;
3673
+ this._broadcastActivityUpdate(runId, log);
3702
3674
  }
3703
- return;
3704
3675
  }
3705
-
3706
3676
  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;
3677
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; }
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 };
3679
+ if (data?.phase === 'result') {
3680
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
3681
+ if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
3682
+ else { step.phase = 'done'; log.steps.push(step); }
3683
+ } else if (data?.phase === 'update') {
3684
+ const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3685
+ if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
3686
+ } else { log.steps.push(step); }
3687
+ this._writeActivityToDb(runId, log);
3688
+ this._broadcastActivityUpdate(runId, log);
3742
3689
  }
3743
-
3744
3690
  if (stream === 'lifecycle') {
3745
3691
  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);
3692
+ if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
3693
+ const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3694
+ if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
3695
+ this._writeActivityToDb(runId, log);
3696
+ this.activityLogs.delete(runId);
3697
+ return;
3753
3698
  }
3754
3699
  }
3755
3700
  }
3756
3701
 
3757
- findRunBySessionKey(sessionKey) {
3758
- for (const [, run] of this.runState.entries()) {
3759
- if (run.sessionKey === sessionKey) return run;
3702
+ _writeActivityToDb(runId, log) {
3703
+ if (!log._parsed) {
3704
+ log._parsed = parseSessionKey(log.sessionKey);
3705
+ }
3706
+ const parsed = log._parsed;
3707
+ if (!parsed) return;
3708
+
3709
+ const db = _getDb(parsed.workspace);
3710
+ if (!db) return;
3711
+
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);
3760
3747
  }
3761
- return null;
3762
3748
  }
3763
3749
 
3764
- broadcastSegmentUpdate(parsed, runId, segment) {
3765
- this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'segment-update', workspace: parsed.workspace, threadId: parsed.threadId, runId, segment }));
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
+ }));
3766
3765
  }
3767
3766
 
3768
3767
  generateActivitySummary(steps) {