@clawchatsai/connector 0.0.28 → 0.0.29

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 +319 -386
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
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,6 +368,13 @@ 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
+
371
378
  // FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
372
379
  // so check if it exists first
373
380
  const hasFts = db.prepare(
@@ -832,12 +839,14 @@ function handleGetThreads(req, res, params, query) {
832
839
  `SELECT COUNT(*) as c FROM threads WHERE id IN (${placeholders})`
833
840
  ).get(...matchingIds).c;
834
841
  threads = db.prepare(
835
- `SELECT * FROM threads WHERE id IN (${placeholders}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`
842
+ `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
843
+ FROM threads t WHERE t.id IN (${placeholders}) ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
836
844
  ).all(...matchingIds, limit, offset);
837
845
  } else {
838
846
  total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
839
847
  threads = db.prepare(
840
- 'SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?'
848
+ `SELECT t.*, (SELECT MAX(m.timestamp) FROM messages m WHERE m.thread_id = t.id) as last_message_at
849
+ FROM threads t ORDER BY t.pinned DESC, t.sort_order DESC, t.updated_at DESC LIMIT ? OFFSET ?`
841
850
  ).all(limit, offset);
842
851
  }
843
852
 
@@ -2224,14 +2233,14 @@ class GatewayClient {
2224
2233
  this.maxReconnectDelay = 30000;
2225
2234
  this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
2226
2235
  this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
2227
- this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
2236
+ this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
2228
2237
 
2229
- // Clean up stale activity logs every 5 minutes (runs that never completed)
2238
+ // Clean up stale run states every 5 minutes (runs that never completed)
2230
2239
  setInterval(() => {
2231
2240
  const cutoff = Date.now() - 10 * 60 * 1000; // 10 minutes
2232
- for (const [runId, log] of this.activityLogs) {
2233
- if (log.startTime < cutoff) {
2234
- this.activityLogs.delete(runId);
2241
+ for (const [runId, run] of this.runState) {
2242
+ if (run.startTime < cutoff) {
2243
+ this.runState.delete(runId);
2235
2244
  }
2236
2245
  }
2237
2246
  }, 5 * 60 * 1000);
@@ -2311,17 +2320,6 @@ class GatewayClient {
2311
2320
  this.broadcastGatewayStatus(true);
2312
2321
  }
2313
2322
 
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
2323
  // Forward all messages to browser clients
2326
2324
  this.broadcastToBrowsers(data);
2327
2325
 
@@ -2356,9 +2354,10 @@ class GatewayClient {
2356
2354
  this.streamState.delete(sessionKey);
2357
2355
  }
2358
2356
 
2359
- // Save assistant messages on final
2357
+ // Save assistant messages on final (use segment seq position if available)
2360
2358
  if (state === 'final') {
2361
- this.saveAssistantMessage(sessionKey, message, seq);
2359
+ const run = this.findRunBySessionKey(sessionKey);
2360
+ this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq);
2362
2361
  }
2363
2362
 
2364
2363
  // Save error markers
@@ -2367,7 +2366,7 @@ class GatewayClient {
2367
2366
  }
2368
2367
  }
2369
2368
 
2370
- saveAssistantMessage(sessionKey, message, seq) {
2369
+ saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
2371
2370
  const parsed = parseSessionKey(sessionKey);
2372
2371
  if (!parsed) return; // Non-ClawChats session key, silently ignore
2373
2372
 
@@ -2400,13 +2399,16 @@ class GatewayClient {
2400
2399
  const now = Date.now();
2401
2400
  const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
2402
2401
 
2402
+ // Use segment seq for correct ordering after tool segments
2403
+ const msgSeq = segmentSeq != null ? segmentSeq : (seq != null ? seq : null);
2404
+
2403
2405
  // Upsert message: INSERT OR REPLACE (same seq → same messageId → update content)
2404
2406
  try {
2405
2407
  db.prepare(`
2406
- INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
2407
- VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
2408
- ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
2409
- `).run(messageId, parsed.threadId, content, now, now);
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);
2410
2412
 
2411
2413
  // Update thread updated_at
2412
2414
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
@@ -2488,295 +2490,245 @@ class GatewayClient {
2488
2490
  const { runId, stream, data, sessionKey } = payload;
2489
2491
  if (!runId) return;
2490
2492
 
2491
- // Initialize log if needed
2492
- if (!this.activityLogs.has(runId)) {
2493
- this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
2493
+ // Initialize run state if needed
2494
+ if (!this.runState.has(runId)) {
2495
+ this.runState.set(runId, { sessionKey, seqCounter: 0, startTime: Date.now() });
2494
2496
  }
2495
- const log = this.activityLogs.get(runId);
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;
2496
2508
 
2497
2509
  if (stream === 'assistant') {
2498
- // Capture intermediate text turns (narration between tool calls)
2499
2510
  const text = data?.text || '';
2500
- if (text) {
2501
- // Update or create the current assistant text segment
2502
- // Each new assistant segment starts after a tool call
2503
- let currentSegment = log._currentAssistantSegment;
2504
- if (!currentSegment || currentSegment._sealed) {
2505
- currentSegment = {
2506
- type: 'assistant',
2507
- timestamp: Date.now(),
2508
- text: text,
2509
- _sealed: false
2510
- };
2511
- log._currentAssistantSegment = currentSegment;
2512
- log.steps.push(currentSegment);
2513
- } else {
2514
- currentSegment.text = text;
2515
- }
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
2535
  }
2517
- // Don't broadcast on every assistant delta — too noisy
2518
- // We'll broadcast when a tool starts (which seals the current segment)
2519
2536
  return;
2520
2537
  }
2521
2538
 
2522
2539
  if (stream === 'thinking') {
2523
- // Reasoning/thinking text from the model (requires reasoningLevel: "stream")
2524
2540
  const thinkingText = data?.text || '';
2525
- const delta = data?.delta || '';
2526
- // Update or create thinking step — we keep a single thinking step that accumulates
2527
- let thinkingStep = log.steps.find(s => s.type === 'thinking');
2528
- if (thinkingStep) {
2529
- thinkingStep.text = thinkingText;
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
+ }
2530
2556
  } else {
2531
- log.steps.push({
2532
- type: 'thinking',
2533
- timestamp: Date.now(),
2534
- text: thinkingText
2535
- });
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
+ }
2536
2562
  }
2537
- // Broadcast to browser for live display (throttled — thinking events come fast)
2563
+
2564
+ // Broadcast (throttled ~300ms)
2538
2565
  const now = Date.now();
2539
- if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
2540
- log._lastThinkingBroadcast = now;
2541
- this.broadcastActivityUpdate(runId, log);
2566
+ if (!run.lastThinkingBroadcast || now - run.lastThinkingBroadcast >= 300) {
2567
+ run.lastThinkingBroadcast = now;
2568
+ this.broadcastSegmentUpdate(parsed, runId, {
2569
+ id: run.thinkingSegmentId,
2570
+ type: 'thinking',
2571
+ seq: run.thinkingSeq,
2572
+ content: thinkingText
2573
+ });
2542
2574
  }
2575
+ return;
2543
2576
  }
2544
2577
 
2545
2578
  if (stream === 'tool') {
2546
- // Seal any current assistant text segment (narration before this tool call)
2547
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2548
- log._currentAssistantSegment._sealed = true;
2549
- // Broadcast the narration + upcoming tool
2550
- this.broadcastActivityUpdate(runId, log);
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
+ }
2551
2593
  }
2552
-
2553
- const step = {
2554
- type: 'tool',
2555
- timestamp: Date.now(),
2556
- name: data?.name || 'unknown',
2557
- phase: data?.phase || 'start',
2558
- toolCallId: data?.toolCallId,
2559
- meta: data?.meta,
2560
- isError: data?.isError || false
2561
- };
2562
-
2563
- // On result phase, update the existing start step or add new
2564
- if (data?.phase === 'result') {
2565
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
2566
- if (existing) {
2567
- existing.phase = 'done';
2568
- existing.resultMeta = data?.meta;
2569
- existing.isError = data?.isError || false;
2570
- existing.durationMs = Date.now() - existing.timestamp;
2571
- } else {
2572
- step.phase = 'done';
2573
- log.steps.push(step);
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
+ });
2574
2627
  }
2575
- } else if (data?.phase === 'update') {
2576
- // Merge update events into the existing step — don't create new entries
2577
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
2578
- if (existing) {
2579
- // Update meta if the update carries new info
2580
- if (data?.meta) existing.resultMeta = data.meta;
2581
- if (data?.isError) existing.isError = true;
2582
- existing.phase = 'running';
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
+ });
2583
2647
  }
2584
- // If no existing step found, silently ignore the orphaned update
2585
2648
  } else {
2586
- log.steps.push(step);
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
+ });
2587
2675
  }
2588
-
2589
- // Forward to browser for real-time display
2590
- this.broadcastActivityUpdate(runId, log);
2676
+ return;
2591
2677
  }
2592
2678
 
2593
2679
  if (stream === 'lifecycle') {
2594
2680
  if (data?.phase === 'end' || data?.phase === 'error') {
2595
- // Seal any remaining assistant segment
2596
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
2597
- log._currentAssistantSegment._sealed = true;
2598
- }
2599
- // 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
- const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
2602
- if (lastAssistantIdx >= 0) {
2603
- log.steps.splice(lastAssistantIdx, 1);
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;
2604
2697
  }
2605
2698
 
2606
- log.steps.push({ type: 'lifecycle', timestamp: Date.now(), phase: data.phase });
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
+ }));
2607
2707
 
2608
- // Finalize: save to message metadata
2609
- this.finalizeActivityLog(runId, log);
2708
+ // Keep run state briefly for handleChatEvent.final to read finalSeq
2709
+ setTimeout(() => this.runState.delete(runId), 30000);
2610
2710
  }
2611
2711
  }
2612
2712
  }
2613
2713
 
2614
- broadcastActivityUpdate(runId, log) {
2615
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2616
- if (!parsed) return;
2714
+ findRunBySessionKey(sessionKey) {
2715
+ for (const [, run] of this.runState.entries()) {
2716
+ if (run.sessionKey === sessionKey) return run;
2717
+ }
2718
+ return null;
2719
+ }
2617
2720
 
2721
+ broadcastSegmentUpdate(parsed, runId, segment) {
2618
2722
  this.broadcastToBrowsers(JSON.stringify({
2619
2723
  type: 'clawchats',
2620
- event: 'agent-activity',
2724
+ event: 'segment-update',
2621
2725
  workspace: parsed.workspace,
2622
2726
  threadId: parsed.threadId,
2623
2727
  runId,
2624
- steps: log.steps,
2625
- summary: this.generateActivitySummary(log.steps)
2728
+ segment
2626
2729
  }));
2627
2730
  }
2628
2731
 
2629
- finalizeActivityLog(runId, log) {
2630
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
2631
- if (!parsed) return;
2632
-
2633
- const db = getDb(parsed.workspace);
2634
- if (!db) return;
2635
-
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
- }
2648
-
2649
- fetchToolCallsFromHistory(sessionKey, parsed, db, log, runId) {
2650
- const reqId = `activity-hist-${runId}`;
2651
-
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);
2657
-
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);
2662
-
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
- }
2709
-
2710
- this.saveActivityToMessage(parsed, db, log, runId);
2711
- this.broadcastActivityUpdate(runId, log);
2712
- });
2713
-
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
- }));
2721
- }
2722
-
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
- }
2734
-
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
- }
2775
-
2776
- this.activityLogs.delete(runId);
2777
- }, 1000); // 1s delay — must happen after message-saved (which fires on chat:final)
2778
- }
2779
-
2780
2732
  generateActivitySummary(steps) {
2781
2733
  const toolSteps = steps.filter(s => s.type === 'tool' && s.phase !== 'result' && s.phase !== 'update');
2782
2734
  const hasThinking = steps.some(s => s.type === 'thinking' && s.text);
@@ -3596,11 +3548,11 @@ export function createApp(config = {}) {
3596
3548
  this.browserClients = new Map();
3597
3549
  this._externalBroadcastTargets = [];
3598
3550
  this.streamState = new Map();
3599
- this.activityLogs = new Map();
3551
+ this.runState = new Map(); // Map<runId, { sessionKey, seqCounter, currentTextSegmentId, startTime }>
3600
3552
  setInterval(() => {
3601
3553
  const cutoff = Date.now() - 10 * 60 * 1000;
3602
- for (const [runId, log] of this.activityLogs) {
3603
- if (log.startTime < cutoff) this.activityLogs.delete(runId);
3554
+ for (const [runId, run] of this.runState) {
3555
+ if (run.startTime < cutoff) this.runState.delete(runId);
3604
3556
  }
3605
3557
  }, 5 * 60 * 1000);
3606
3558
  }
@@ -3632,11 +3584,6 @@ export function createApp(config = {}) {
3632
3584
  return;
3633
3585
  }
3634
3586
  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
3587
  this.broadcastToBrowsers(data);
3641
3588
  if (msg.type === 'event' && msg.event === 'chat' && msg.payload) this.handleChatEvent(msg.payload);
3642
3589
  if (msg.type === 'event' && msg.event === 'agent' && msg.payload) this.handleAgentEvent(msg.payload);
@@ -3654,11 +3601,11 @@ export function createApp(config = {}) {
3654
3601
  return;
3655
3602
  }
3656
3603
  if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
3657
- if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
3604
+ if (state === 'final') { const run = this.findRunBySessionKey(sessionKey); this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq); }
3658
3605
  if (state === 'error') this.saveErrorMarker(sessionKey, message);
3659
3606
  }
3660
3607
 
3661
- saveAssistantMessage(sessionKey, message, seq) {
3608
+ saveAssistantMessage(sessionKey, message, seq, segmentSeq) {
3662
3609
  const parsed = parseSessionKey(sessionKey);
3663
3610
  if (!parsed) return;
3664
3611
  const ws = _getWorkspaces();
@@ -3670,8 +3617,9 @@ export function createApp(config = {}) {
3670
3617
  if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
3671
3618
  const now = Date.now();
3672
3619
  const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
3620
+ const msgSeq = segmentSeq != null ? segmentSeq : (seq != null ? seq : null);
3673
3621
  try {
3674
- 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);
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);
3675
3623
  db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
3676
3624
  // Always mark as unread — browser sends read receipts to clear
3677
3625
  db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
@@ -3707,129 +3655,114 @@ export function createApp(config = {}) {
3707
3655
  handleAgentEvent(payload) {
3708
3656
  const { runId, stream, data, sessionKey } = payload;
3709
3657
  if (!runId) return;
3710
- if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
3711
- const log = this.activityLogs.get(runId);
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
+
3712
3668
  if (stream === 'assistant') {
3713
3669
  const text = data?.text || '';
3714
- if (text) {
3715
- let currentSegment = log._currentAssistantSegment;
3716
- if (!currentSegment || currentSegment._sealed) {
3717
- currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
3718
- log._currentAssistantSegment = currentSegment;
3719
- log.steps.push(currentSegment);
3720
- } else { currentSegment.text = 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 });
3721
3682
  }
3722
3683
  return;
3723
3684
  }
3685
+
3724
3686
  if (stream === 'thinking') {
3725
3687
  const thinkingText = data?.text || '';
3726
- let thinkingStep = log.steps.find(s => s.type === 'thinking');
3727
- if (thinkingStep) { thinkingStep.text = thinkingText; }
3728
- else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
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
+ }
3729
3698
  const now = Date.now();
3730
- if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
3731
- log._lastThinkingBroadcast = now;
3732
- this.broadcastActivityUpdate(runId, log);
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 });
3733
3702
  }
3703
+ return;
3734
3704
  }
3705
+
3735
3706
  if (stream === 'tool') {
3736
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; this.broadcastActivityUpdate(runId, log); }
3737
- 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
- if (data?.phase === 'result') {
3739
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
3740
- if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
3741
- else { step.phase = 'done'; log.steps.push(step); }
3742
- } else if (data?.phase === 'update') {
3743
- const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
3744
- if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
3745
- } else { log.steps.push(step); }
3746
- this.broadcastActivityUpdate(runId, log);
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;
3747
3742
  }
3743
+
3748
3744
  if (stream === 'lifecycle') {
3749
3745
  if (data?.phase === 'end' || data?.phase === 'error') {
3750
- if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
3751
- const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
3752
- 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);
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);
3755
3753
  }
3756
3754
  }
3757
3755
  }
3758
3756
 
3759
- broadcastActivityUpdate(runId, log) {
3760
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3761
- 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
-
3765
- finalizeActivityLog(runId, log) {
3766
- const parsed = log.sessionKey ? parseSessionKey(log.sessionKey) : null;
3767
- if (!parsed) return;
3768
- const db = _getDb(parsed.workspace);
3769
- 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
-
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 '';
3757
+ findRunBySessionKey(sessionKey) {
3758
+ for (const [, run] of this.runState.entries()) {
3759
+ if (run.sessionKey === sessionKey) return run;
3760
+ }
3761
+ return null;
3811
3762
  }
3812
3763
 
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);
3764
+ broadcastSegmentUpdate(parsed, runId, segment) {
3765
+ this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'segment-update', workspace: parsed.workspace, threadId: parsed.threadId, runId, segment }));
3833
3766
  }
3834
3767
 
3835
3768
  generateActivitySummary(steps) {