@swarmclawai/swarmclaw 0.6.0 → 0.6.2

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 (109) hide show
  1. package/README.md +15 -2
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +3 -2
  15. package/src/app/api/tts/stream/route.ts +3 -2
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +46 -22
  31. package/src/components/chat/chat-header.tsx +455 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +180 -7
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +68 -16
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +51 -11
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/manager.ts +218 -7
  78. package/src/lib/server/heartbeat-service.ts +8 -1
  79. package/src/lib/server/main-agent-loop.ts +1 -1
  80. package/src/lib/server/memory-consolidation.ts +15 -2
  81. package/src/lib/server/memory-db.ts +134 -6
  82. package/src/lib/server/mime.ts +51 -0
  83. package/src/lib/server/openclaw-gateway.ts +2 -2
  84. package/src/lib/server/orchestrator-lg.ts +2 -0
  85. package/src/lib/server/orchestrator.ts +5 -2
  86. package/src/lib/server/playwright-proxy.mjs +2 -3
  87. package/src/lib/server/prompt-runtime-context.ts +53 -0
  88. package/src/lib/server/queue.ts +52 -7
  89. package/src/lib/server/session-tools/canvas.ts +67 -0
  90. package/src/lib/server/session-tools/connector.ts +83 -9
  91. package/src/lib/server/session-tools/crud.ts +21 -0
  92. package/src/lib/server/session-tools/delegate.ts +68 -4
  93. package/src/lib/server/session-tools/git.ts +71 -0
  94. package/src/lib/server/session-tools/http.ts +57 -0
  95. package/src/lib/server/session-tools/index.ts +8 -0
  96. package/src/lib/server/session-tools/memory.ts +1 -0
  97. package/src/lib/server/session-tools/search-providers.ts +16 -8
  98. package/src/lib/server/session-tools/subagent.ts +106 -0
  99. package/src/lib/server/session-tools/web.ts +115 -4
  100. package/src/lib/server/stream-agent-chat.ts +32 -10
  101. package/src/lib/server/task-mention.ts +41 -0
  102. package/src/lib/sessions.ts +10 -0
  103. package/src/lib/soul-library.ts +103 -0
  104. package/src/lib/task-dedupe.ts +26 -0
  105. package/src/lib/tool-definitions.ts +2 -0
  106. package/src/lib/tts.ts +2 -2
  107. package/src/stores/use-app-store.ts +5 -1
  108. package/src/stores/use-chat-store.ts +65 -2
  109. package/src/types/index.ts +32 -2
@@ -1,40 +1,68 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useRef, useState } from 'react'
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
4
  import { useContinuousSpeech } from './use-continuous-speech'
5
5
  import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
7
7
 
8
8
  export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
9
9
 
10
+ /** Max time to wait in 'processing' before falling back to listening (30s). */
11
+ const PROCESSING_TIMEOUT_MS = 30_000
12
+
10
13
  export function useVoiceConversation() {
11
- const [active, setActive] = useState(false)
12
14
  const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
13
15
  const accumulatorRef = useRef<SentenceAccumulator | null>(null)
14
16
  const queueRef = useRef<AudioChunkQueue | null>(null)
17
+ const activeRef = useRef(false)
18
+ const processingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
19
+ const [resumeNeeded, setResumeNeeded] = useState(0)
15
20
  const sendMessage = useChatStore((s) => s.sendMessage)
16
21
 
22
+ const clearProcessingTimer = () => {
23
+ if (processingTimerRef.current) {
24
+ clearTimeout(processingTimerRef.current)
25
+ processingTimerRef.current = null
26
+ }
27
+ }
28
+
17
29
  const speech = useContinuousSpeech({
18
30
  onUtterance: useCallback((text: string) => {
19
31
  setVoiceState('processing')
20
- // Send the transcribed text as a chat message
21
32
  sendMessage(text)
33
+ // Safety net: if no stream events arrive within timeout, resume listening
34
+ clearProcessingTimer()
35
+ processingTimerRef.current = setTimeout(() => {
36
+ if (activeRef.current) {
37
+ setVoiceState('listening')
38
+ setResumeNeeded((n) => n + 1)
39
+ }
40
+ }, PROCESSING_TIMEOUT_MS)
22
41
  }, [sendMessage]),
23
42
  })
24
43
 
44
+ // When resumeNeeded increments, call speech.resume
45
+ useEffect(() => {
46
+ if (resumeNeeded > 0) speech.resume()
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, [resumeNeeded])
49
+
25
50
  // Called by the chat store's onStreamEvent callback
26
51
  const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
27
- if (!active) return
52
+ if (!activeRef.current) return
28
53
 
29
54
  if (event.t === 'd' && event.text) {
55
+ clearProcessingTimer()
30
56
  setVoiceState('speaking')
31
57
  if (!accumulatorRef.current) {
32
58
  const queue = new AudioChunkQueue()
33
59
  queueRef.current = queue
34
60
  queue.onComplete = () => {
35
61
  // Resume listening after TTS playback finishes
36
- setVoiceState('listening')
37
- speech.resume()
62
+ if (activeRef.current) {
63
+ setVoiceState('listening')
64
+ speech.resume()
65
+ }
38
66
  }
39
67
  accumulatorRef.current = new SentenceAccumulator((sentence) => {
40
68
  queue.enqueue(fetchStreamTts(sentence))
@@ -42,16 +70,30 @@ export function useVoiceConversation() {
42
70
  }
43
71
  accumulatorRef.current.push(event.text)
44
72
  } else if (event.t === 'done') {
73
+ clearProcessingTimer()
45
74
  // Flush remaining text to TTS
46
75
  if (accumulatorRef.current) {
47
76
  accumulatorRef.current.flush()
48
77
  accumulatorRef.current = null
78
+ } else {
79
+ // No text was streamed (empty response or error) — resume listening
80
+ if (activeRef.current) {
81
+ setVoiceState('listening')
82
+ speech.resume()
83
+ }
84
+ }
85
+ } else if (event.t === 'err') {
86
+ // Error from the LLM — resume listening instead of staying stuck
87
+ clearProcessingTimer()
88
+ if (activeRef.current) {
89
+ setVoiceState('listening')
90
+ speech.resume()
49
91
  }
50
92
  }
51
- }, [active, speech])
93
+ }, [speech])
52
94
 
53
95
  const start = useCallback(() => {
54
- setActive(true)
96
+ activeRef.current = true
55
97
  setVoiceState('listening')
56
98
  // Register the stream event handler on the chat store
57
99
  useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
@@ -59,8 +101,9 @@ export function useVoiceConversation() {
59
101
  }, [speech, handleStreamEvent])
60
102
 
61
103
  const stop = useCallback(() => {
62
- setActive(false)
104
+ activeRef.current = false
63
105
  setVoiceState('idle')
106
+ clearProcessingTimer()
64
107
  speech.stop()
65
108
  queueRef.current?.stop()
66
109
  queueRef.current = null
@@ -69,7 +112,7 @@ export function useVoiceConversation() {
69
112
  }, [speech])
70
113
 
71
114
  return {
72
- active,
115
+ active: activeRef.current || voiceState !== 'idle',
73
116
  state: voiceState,
74
117
  interimText: speech.interimText,
75
118
  transcript: speech.transcript,
@@ -32,8 +32,10 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
32
32
  let fallbackId: ReturnType<typeof setInterval> | null = null
33
33
  const cb = () => handlerRef.current()
34
34
 
35
- // When page becomes visible again, fire an immediate refresh
36
- if (isActive) {
35
+ // When page becomes visible again, fire an immediate refresh
36
+ // but only for topics that use fallback polling (i.e. data-fetch topics).
37
+ // Event-only topics (like heartbeat pulses) should never fire from this effect.
38
+ if (isActive && fallbackMsRef.current && fallbackMsRef.current > 0) {
37
39
  cb()
38
40
  }
39
41
 
@@ -23,9 +23,9 @@ function fileToContentBlocks(filePath: string): any[] {
23
23
  return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
24
24
  }
25
25
 
26
- export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
26
+ export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
27
  return new Promise((resolve) => {
28
- const messages = buildMessages(session, message, imagePath, loadHistory)
28
+ const messages = buildMessages(session, message, imagePath, loadHistory, imageUrl)
29
29
  const model = session.model || 'claude-sonnet-4-6'
30
30
  let usageInput = 0
31
31
  let usageOutput = 0
@@ -122,14 +122,19 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
122
122
  })
123
123
  }
124
124
 
125
- function buildMessages(session: any, message: string, imagePath: string | undefined, loadHistory: (id: string) => any[]) {
125
+ function urlToImageBlock(url: string): { type: string; source: { type: string; url: string } } {
126
+ return { type: 'image', source: { type: 'url', url } }
127
+ }
128
+
129
+ function buildMessages(session: any, message: string, imagePath: string | undefined, loadHistory: (id: string) => any[], imageUrl?: string) {
126
130
  const msgs: Array<{ role: string; content: any }> = []
127
131
 
128
132
  if (loadHistory) {
129
133
  const history = loadHistory(session.id).slice(-40)
130
134
  for (const m of history) {
131
- if (m.role === 'user' && m.imagePath) {
132
- const blocks = fileToContentBlocks(m.imagePath)
135
+ if (m.role === 'user' && (m.imagePath || m.imageUrl)) {
136
+ const blocks = m.imagePath ? fileToContentBlocks(m.imagePath) : []
137
+ if (m.imageUrl) blocks.push(urlToImageBlock(m.imageUrl))
133
138
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: m.text }] })
134
139
  } else {
135
140
  msgs.push({ role: m.role, content: m.text })
@@ -138,8 +143,9 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
138
143
  }
139
144
 
140
145
  // Current message with optional attachment
141
- if (imagePath) {
142
- const blocks = fileToContentBlocks(imagePath)
146
+ if (imagePath || imageUrl) {
147
+ const blocks = imagePath ? fileToContentBlocks(imagePath) : []
148
+ if (imageUrl) blocks.push(urlToImageBlock(imageUrl))
143
149
  msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: message }] })
144
150
  } else {
145
151
  msgs.push({ role: 'user', content: message })
@@ -22,6 +22,7 @@ export interface StreamChatOptions {
22
22
  session: any
23
23
  message: string
24
24
  imagePath?: string
25
+ imageUrl?: string
25
26
  apiKey?: string | null
26
27
  systemPrompt?: string
27
28
  write: (data: string) => void
@@ -43,9 +43,9 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
43
43
  return [{ type: 'text', text: `[Attached file: ${name}]` }]
44
44
  }
45
45
 
46
- export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
46
+ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
47
47
  return new Promise(async (resolve) => {
48
- const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
48
+ const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory, imageUrl)
49
49
  const model = session.model || 'gpt-4o'
50
50
 
51
51
  const payload = JSON.stringify({
@@ -163,7 +163,11 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
163
163
  })
164
164
  }
165
165
 
166
- async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
166
+ function urlToImagePart(url: string): { type: string; image_url: { url: string; detail: string } } {
167
+ return { type: 'image_url', image_url: { url, detail: 'auto' } }
168
+ }
169
+
170
+ async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[], imageUrl?: string) {
167
171
  const msgs: Array<{ role: string; content: any }> = []
168
172
 
169
173
  if (systemPrompt) {
@@ -173,8 +177,9 @@ async function buildMessages(session: any, message: string, imagePath: string |
173
177
  if (loadHistory) {
174
178
  const history = loadHistory(session.id).slice(-40)
175
179
  for (const m of history) {
176
- if (m.role === 'user' && m.imagePath) {
177
- const parts = await fileToContentParts(m.imagePath)
180
+ if (m.role === 'user' && (m.imagePath || m.imageUrl)) {
181
+ const parts = m.imagePath ? await fileToContentParts(m.imagePath) : []
182
+ if (m.imageUrl) parts.push(urlToImagePart(m.imageUrl))
178
183
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
179
184
  } else {
180
185
  msgs.push({ role: m.role, content: m.text })
@@ -183,8 +188,9 @@ async function buildMessages(session: any, message: string, imagePath: string |
183
188
  }
184
189
 
185
190
  // Current message with optional attachment
186
- if (imagePath) {
187
- const parts = await fileToContentParts(imagePath)
191
+ if (imagePath || imageUrl) {
192
+ const parts = imagePath ? await fileToContentParts(imagePath) : []
193
+ if (imageUrl) parts.push(urlToImagePart(imageUrl))
188
194
  msgs.push({ role: 'user', content: [...parts, { type: 'text', text: message }] })
189
195
  } else {
190
196
  msgs.push({ role: 'user', content: message })
@@ -24,11 +24,20 @@ import { getMemoryDb } from './memory-db'
24
24
  import { routeTaskIntent } from './capability-router'
25
25
  import { notify } from './ws-hub'
26
26
  import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
27
- import type { MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
27
+ import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
28
+ import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
28
29
  import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
29
30
  import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
30
31
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
31
32
 
33
+ /** Slice history from the most recent context-clear marker forward */
34
+ function applyContextClearBoundary(messages: Message[]): Message[] {
35
+ for (let i = messages.length - 1; i >= 0; i--) {
36
+ if (messages[i].kind === 'context-clear') return messages.slice(i + 1)
37
+ }
38
+ return messages
39
+ }
40
+
32
41
  interface SessionWithTools {
33
42
  tools?: string[] | null
34
43
  }
@@ -307,11 +316,11 @@ function buildAgentSystemPrompt(session: any): string | undefined {
307
316
  if (!session.agentId) return undefined
308
317
  const agents = loadAgents()
309
318
  const agent = agents[session.agentId]
310
- if (!agent?.systemPrompt && !agent?.soul) return undefined
311
319
 
312
320
  const settings = loadSettings()
313
321
  const parts: string[] = []
314
322
  if (settings.userPrompt) parts.push(settings.userPrompt)
323
+ parts.push(buildCurrentDateTimePromptContext())
315
324
  if (agent.soul) parts.push(agent.soul)
316
325
  if (agent.systemPrompt) parts.push(agent.systemPrompt)
317
326
  if (agent.skillIds?.length) {
@@ -321,6 +330,7 @@ function buildAgentSystemPrompt(session: any): string | undefined {
321
330
  if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
322
331
  }
323
332
  }
333
+ if (!parts.length) return undefined
324
334
  return parts.join('\n\n')
325
335
  }
326
336
 
@@ -354,13 +364,13 @@ function stripMarkupForHeartbeat(text: string): string {
354
364
  const HEARTBEAT_OK_RE = /HEARTBEAT_OK[^\w]{0,4}$/
355
365
  const NO_MESSAGE_RE = /NO_MESSAGE[^\w]{0,4}$/
356
366
 
357
- function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
367
+ function classifyHeartbeatResponse(text: string, ackMaxChars: number, hadToolCalls: boolean): 'suppress' | 'strip' | 'keep' {
358
368
  const cleaned = stripMarkupForHeartbeat(text)
359
369
  if (cleaned === 'HEARTBEAT_OK' || cleaned === 'NO_MESSAGE') return 'suppress'
360
370
  if (HEARTBEAT_OK_RE.test(cleaned) || NO_MESSAGE_RE.test(cleaned)) return 'suppress'
361
371
  const stripped = cleaned.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
362
372
  if (!stripped) return 'suppress'
363
- if (stripped.length <= ackMaxChars) return 'suppress'
373
+ if (!hadToolCalls && stripped.length <= ackMaxChars) return 'suppress'
364
374
  return stripped.length < cleaned.length ? 'strip' : 'keep'
365
375
  }
366
376
 
@@ -566,6 +576,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
566
576
  const toolEvents: MessageToolEvent[] = []
567
577
  const streamErrors: string[] = []
568
578
 
579
+ let thinkingText = ''
569
580
  const emit = (ev: SSEEvent) => {
570
581
  if (ev.t === 'err' && typeof ev.text === 'string') {
571
582
  const trimmed = ev.text.trim()
@@ -574,6 +585,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
574
585
  if (streamErrors.length > 8) streamErrors.shift()
575
586
  }
576
587
  }
588
+ if (ev.t === 'thinking' && ev.text) {
589
+ thinkingText += ev.text
590
+ }
577
591
  collectToolEvent(ev, toolEvents)
578
592
  onEvent?.(ev)
579
593
  }
@@ -608,9 +622,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
608
622
  const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
609
623
 
610
624
  try {
611
- // Heartbeat runs are self-contained skip conversation history to avoid
612
- // blowing past the context window on long-lived sessions.
613
- const heartbeatHistory = isAutoRunNoHistory ? [] : undefined
625
+ // Heartbeat runs get a small tail of recent messages so the agent can see
626
+ // prior findings and avoid repeating the same searches. Full history is
627
+ // skipped to avoid blowing the context window on long-lived sessions.
628
+ const heartbeatHistory = isAutoRunNoHistory
629
+ ? getSessionMessages(sessionId).slice(-6)
630
+ : undefined
614
631
 
615
632
  console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
616
633
 
@@ -623,7 +640,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
623
640
  apiKey,
624
641
  systemPrompt,
625
642
  write: (raw) => parseAndEmit(raw),
626
- history: heartbeatHistory ?? getSessionMessages(sessionId),
643
+ history: heartbeatHistory ?? applyContextClearBoundary(getSessionMessages(sessionId)),
627
644
  signal: abortController.signal,
628
645
  })).fullText
629
646
  : await provider.handler.streamChat({
@@ -634,7 +651,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
634
651
  systemPrompt,
635
652
  write: (raw: string) => parseAndEmit(raw),
636
653
  active,
637
- loadHistory: isAutoRunNoHistory ? () => [] : getSessionMessages,
654
+ loadHistory: isAutoRunNoHistory ? () => getSessionMessages(sessionId).slice(-6) : (sid: string) => applyContextClearBoundary(getSessionMessages(sid)),
638
655
  onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
639
656
  })
640
657
  } catch (err: any) {
@@ -723,7 +740,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
723
740
  const toolOutput = await selectedTool.invoke(args)
724
741
  const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
725
742
  emit({ t: 'tool_result', toolName, toolOutput: outputText })
726
- if (outputText?.trim()) fullResponse = outputText.trim()
743
+ // Don't overwrite fullResponse with raw tool output — it's already captured
744
+ // in toolEvents. Only set a brief notice when the LLM produced no text,
745
+ // so the message bubble isn't empty.
746
+ if (!fullResponse.trim() && outputText?.trim()) {
747
+ const label = toolName.replace(/_/g, ' ')
748
+ fullResponse = `Used **${label}** — see tool output above for details.`
749
+ }
727
750
  calledNames.add(toolName)
728
751
  return true
729
752
  } catch (forceErr: any) {
@@ -869,7 +892,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
869
892
  const heartbeatConfig = input.heartbeatConfig
870
893
  let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
871
894
  if (isHeartbeatRun && textForPersistence.length > 0) {
872
- heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300)
895
+ heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
873
896
  }
874
897
 
875
898
  // Emit WS notification for every heartbeat completion so UI can show pulse
@@ -924,6 +947,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
924
947
  role: 'assistant',
925
948
  text: persistedText,
926
949
  time: Date.now(),
950
+ thinking: thinkingText || undefined,
927
951
  toolEvents: toolEvents.length ? toolEvents : undefined,
928
952
  kind: persistedKind,
929
953
  })
@@ -964,6 +988,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
964
988
  // Best effort — connector manager may not be loaded
965
989
  }
966
990
  }
991
+
992
+ // Auto-discover connectors linked to this agent when no explicit target is set
993
+ if (isHeartbeatRun && !heartbeatConfig?.target && heartbeatConfig?.showAlerts !== false && session.agentId) {
994
+ try {
995
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
996
+ const { listRunningConnectors: listRunning, sendConnectorMessage: sendMsg } = require('./connectors/manager')
997
+ const agentConnectors = listRunning().filter((c: { agentId: string | null; recentChannelId: string | null; supportsSend: boolean }) =>
998
+ c.agentId === session.agentId && c.recentChannelId && c.supportsSend
999
+ )
1000
+ for (const conn of agentConnectors) {
1001
+ sendMsg({ connectorId: conn.id, channelId: conn.recentChannelId, text: persistedText }).catch(() => {})
1002
+ }
1003
+ } catch {
1004
+ // Best effort — connector manager may not be loaded
1005
+ }
1006
+ }
967
1007
  }
968
1008
 
969
1009
  const autoMemoryEligible = shouldStoreAutoMemoryNote({
@@ -0,0 +1,146 @@
1
+ import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
2
+ import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
3
+ import type { Chatroom, Agent, Session, Message } from '@/types'
4
+
5
+ /** Resolve API key from an agent's credentialId */
6
+ export function resolveApiKey(credentialId: string | null | undefined): string | null {
7
+ if (!credentialId) return null
8
+ const creds = loadCredentials()
9
+ const cred = creds[credentialId]
10
+ if (!cred?.encryptedKey) return null
11
+ try { return decryptKey(cred.encryptedKey) } catch { return null }
12
+ }
13
+
14
+ /** Parse @mentions from message text, returns matching agentIds */
15
+ export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
16
+ if (/@all\b/i.test(text)) return [...memberIds]
17
+ const mentionPattern = /@(\S+)/g
18
+ const mentioned: string[] = []
19
+ let match: RegExpExecArray | null
20
+ while ((match = mentionPattern.exec(text)) !== null) {
21
+ const name = match[1].toLowerCase()
22
+ for (const id of memberIds) {
23
+ const agent = agents[id]
24
+ if (agent && agent.name.toLowerCase().replace(/\s+/g, '') === name) {
25
+ if (!mentioned.includes(id)) mentioned.push(id)
26
+ }
27
+ }
28
+ }
29
+ return mentioned
30
+ }
31
+
32
+ /** Build chatroom context as a system prompt addendum with agent profiles and collaboration guidelines */
33
+ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<string, Agent>, agentId: string): string {
34
+ const selfAgent = agents[agentId]
35
+ const selfName = selfAgent?.name || agentId
36
+
37
+ // Build team profiles with capabilities
38
+ const teamProfiles = chatroom.agentIds
39
+ .filter((id) => id !== agentId)
40
+ .map((id) => {
41
+ const a = agents[id]
42
+ if (!a) return null
43
+ const tools = a.tools?.length ? `Tools: ${a.tools.join(', ')}` : 'No specialized tools'
44
+ const desc = a.description || a.soul || 'No description'
45
+ return `- **${a.name}**: ${desc}\n ${tools}`
46
+ })
47
+ .filter(Boolean)
48
+ .join('\n')
49
+
50
+ const recentMessages = chatroom.messages.slice(-30).map((m) => {
51
+ return `[${m.senderName}]: ${m.text}`
52
+ }).join('\n')
53
+
54
+ const memberCount = chatroom.agentIds.length
55
+ const otherNames = chatroom.agentIds
56
+ .filter((id) => id !== agentId)
57
+ .map((id) => agents[id]?.name)
58
+ .filter(Boolean)
59
+
60
+ return [
61
+ `## Chatroom Context`,
62
+ `You are **${selfName}** in a group chatroom called "${chatroom.name}" with ${memberCount} participants (you, ${otherNames.join(', ') || 'others'}, and the user).`,
63
+ selfAgent?.description ? `Your role: ${selfAgent.description}` : '',
64
+ selfAgent?.tools?.length ? `Your available tools: ${selfAgent.tools.join(', ')}` : '',
65
+ '',
66
+ '## Team Members',
67
+ teamProfiles || '(no other agents)',
68
+ '',
69
+ '## How to Behave in This Chatroom',
70
+ '- **You are in a group chat.** Talk like you are in a real-time conversation with teammates — be direct, casual, and concise.',
71
+ '- **Be yourself.** Respond with personality. Don\'t give generic "let me know if you need anything" responses. Actually engage with what was said.',
72
+ '- **Answer the question or react to the message.** If someone says "how are you doing?" just answer naturally. If someone asks a question you can help with, help directly.',
73
+ '- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
74
+ '- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
75
+ '- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
76
+ '- **Read the room.** Look at recent messages to understand context. Don\'t repeat what others already said.',
77
+ '',
78
+ '## Recent Messages',
79
+ recentMessages || '(no messages yet)',
80
+ ].filter((line) => line !== undefined).join('\n')
81
+ }
82
+
83
+ /** Build a synthetic session object for an agent in a chatroom */
84
+ export function buildSyntheticSession(agent: Agent, chatroomId: string): Session {
85
+ return {
86
+ id: `chatroom-${chatroomId}-${agent.id}`,
87
+ name: `Chatroom session for ${agent.name}`,
88
+ cwd: process.cwd(),
89
+ user: 'chatroom',
90
+ provider: agent.provider,
91
+ model: agent.model,
92
+ credentialId: agent.credentialId ?? null,
93
+ fallbackCredentialIds: agent.fallbackCredentialIds,
94
+ apiEndpoint: agent.apiEndpoint ?? null,
95
+ claudeSessionId: null,
96
+ messages: [],
97
+ createdAt: Date.now(),
98
+ lastActiveAt: Date.now(),
99
+ tools: agent.tools || [],
100
+ agentId: agent.id,
101
+ }
102
+ }
103
+
104
+ /** Build agent's system prompt including skills */
105
+ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
106
+ const settings = loadSettings()
107
+ const parts: string[] = []
108
+ if (settings.userPrompt) parts.push(settings.userPrompt)
109
+ parts.push(buildCurrentDateTimePromptContext())
110
+ if (agent.soul) parts.push(agent.soul)
111
+ if (agent.systemPrompt) parts.push(agent.systemPrompt)
112
+ if (agent.skillIds?.length) {
113
+ const allSkills = loadSkills()
114
+ for (const skillId of agent.skillIds) {
115
+ const skill = allSkills[skillId]
116
+ if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
117
+ }
118
+ }
119
+ return parts.join('\n\n')
120
+ }
121
+
122
+ /** Convert chatroom messages to Message history format for LLM */
123
+ export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imagePath?: string, attachedFiles?: string[]): Message[] {
124
+ const history = chatroom.messages.slice(-50).map((m) => {
125
+ let msgText = `[${m.senderName}]: ${m.text}`
126
+ // Include attachment info in history
127
+ if (m.attachedFiles?.length) {
128
+ const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
129
+ msgText += `\n[Attached: ${names}]`
130
+ }
131
+ return {
132
+ role: m.senderId === agentId ? 'assistant' as const : 'user' as const,
133
+ text: msgText,
134
+ time: m.time,
135
+ ...(m.imagePath ? { imagePath: m.imagePath } : {}),
136
+ ...(m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
137
+ }
138
+ })
139
+ // Pass through imagePath/attachedFiles from the current message to the last history entry
140
+ if (history.length > 0 && (imagePath || attachedFiles)) {
141
+ const last = history[history.length - 1]
142
+ if (imagePath && !last.imagePath) last.imagePath = imagePath
143
+ if (attachedFiles && !last.attachedFiles) last.attachedFiles = attachedFiles
144
+ }
145
+ return history
146
+ }