@swarmclawai/swarmclaw 0.6.0 → 0.6.3

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 (118) hide show
  1. package/README.md +56 -42
  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 +16 -35
  15. package/src/app/api/tts/stream/route.ts +14 -42
  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 +76 -24
  31. package/src/components/chat/chat-header.tsx +522 -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 +113 -8
  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 +84 -17
  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 +125 -14
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  78. package/src/lib/server/connectors/discord.ts +31 -8
  79. package/src/lib/server/connectors/manager.ts +594 -16
  80. package/src/lib/server/connectors/media.ts +5 -0
  81. package/src/lib/server/connectors/telegram.ts +12 -2
  82. package/src/lib/server/connectors/types.ts +2 -0
  83. package/src/lib/server/connectors/whatsapp.ts +28 -2
  84. package/src/lib/server/elevenlabs.test.ts +60 -0
  85. package/src/lib/server/elevenlabs.ts +103 -0
  86. package/src/lib/server/heartbeat-service.ts +8 -1
  87. package/src/lib/server/main-agent-loop.ts +1 -1
  88. package/src/lib/server/memory-consolidation.ts +15 -2
  89. package/src/lib/server/memory-db.ts +134 -6
  90. package/src/lib/server/mime.ts +51 -0
  91. package/src/lib/server/openclaw-gateway.ts +2 -2
  92. package/src/lib/server/orchestrator-lg.ts +2 -0
  93. package/src/lib/server/orchestrator.ts +5 -2
  94. package/src/lib/server/playwright-proxy.mjs +2 -3
  95. package/src/lib/server/prompt-runtime-context.ts +53 -0
  96. package/src/lib/server/queue.ts +182 -8
  97. package/src/lib/server/session-tools/canvas.ts +67 -0
  98. package/src/lib/server/session-tools/connector.ts +583 -63
  99. package/src/lib/server/session-tools/crud.ts +21 -0
  100. package/src/lib/server/session-tools/delegate.ts +68 -4
  101. package/src/lib/server/session-tools/file.ts +26 -7
  102. package/src/lib/server/session-tools/git.ts +71 -0
  103. package/src/lib/server/session-tools/http.ts +57 -0
  104. package/src/lib/server/session-tools/index.ts +8 -0
  105. package/src/lib/server/session-tools/memory.ts +1 -0
  106. package/src/lib/server/session-tools/search-providers.ts +16 -8
  107. package/src/lib/server/session-tools/subagent.ts +106 -0
  108. package/src/lib/server/session-tools/web.ts +118 -8
  109. package/src/lib/server/stream-agent-chat.ts +39 -10
  110. package/src/lib/server/task-mention.ts +41 -0
  111. package/src/lib/sessions.ts +10 -0
  112. package/src/lib/soul-library.ts +103 -0
  113. package/src/lib/task-dedupe.ts +26 -0
  114. package/src/lib/tool-definitions.ts +2 -0
  115. package/src/lib/tts.ts +2 -2
  116. package/src/stores/use-app-store.ts +5 -1
  117. package/src/stores/use-chat-store.ts +65 -2
  118. package/src/types/index.ts +32 -2
@@ -2,14 +2,27 @@ import { genId } from '@/lib/id'
2
2
  import {
3
3
  loadConnectors, saveConnectors, loadSessions, saveSessions,
4
4
  loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
5
+ loadChatrooms, saveChatrooms,
5
6
  } from '../storage'
6
7
  import { WORKSPACE_DIR } from '../data-dir'
8
+ import { UPLOAD_DIR } from '../storage'
9
+ import fs from 'fs'
10
+ import path from 'path'
7
11
  import { streamAgentChat } from '../stream-agent-chat'
8
12
  import { notify } from '../ws-hub'
9
13
  import { logExecution } from '../execution-log'
10
14
  import { enqueueSystemEvent } from '../system-events'
11
15
  import { requestHeartbeatNow } from '../heartbeat-wake'
12
- import type { Connector } from '@/types'
16
+ import { buildCurrentDateTimePromptContext } from '../prompt-runtime-context'
17
+ import {
18
+ parseMentions,
19
+ buildChatroomSystemPrompt,
20
+ buildSyntheticSession,
21
+ buildAgentSystemPromptForChatroom,
22
+ buildHistoryForAgent,
23
+ resolveApiKey as resolveApiKeyHelper,
24
+ } from '../chatroom-helpers'
25
+ import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
13
26
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
14
27
  import {
15
28
  addAllowedSender,
@@ -23,6 +36,191 @@ import {
23
36
  type PairingPolicy,
24
37
  } from './pairing'
25
38
 
39
+ function resolveUploadPathFromUrl(rawUrl: string): string | null {
40
+ if (!rawUrl) return null
41
+ const normalized = rawUrl.trim()
42
+ const match = normalized.match(/\/api\/uploads\/([^?#)\s]+)/)
43
+ if (!match) return null
44
+ let decoded: string
45
+ try { decoded = decodeURIComponent(match[1]) } catch { decoded = match[1] }
46
+ const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
47
+ if (!safeName) return null
48
+ const filePath = path.join(UPLOAD_DIR, safeName)
49
+ return fs.existsSync(filePath) ? filePath : null
50
+ }
51
+
52
+ function uploadApiUrlFromPath(filePath: string): string | null {
53
+ const rel = path.relative(UPLOAD_DIR, filePath)
54
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
55
+ const fileName = path.basename(rel)
56
+ return `/api/uploads/${encodeURIComponent(fileName)}`
57
+ }
58
+
59
+ function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
60
+ if (!raw) return []
61
+ const events: Array<Record<string, unknown>> = []
62
+ const lines = raw.split('\n')
63
+ for (const line of lines) {
64
+ if (!line.startsWith('data: ')) continue
65
+ try {
66
+ const parsed = JSON.parse(line.slice(6).trim())
67
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
68
+ events.push(parsed as Record<string, unknown>)
69
+ }
70
+ } catch { /* ignore malformed event lines */ }
71
+ }
72
+ return events
73
+ }
74
+
75
+ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string } | null {
76
+ const raw = toolOutput.trim()
77
+ if (!raw) return null
78
+ try {
79
+ const parsed = JSON.parse(raw)
80
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
81
+ const record = parsed as Record<string, unknown>
82
+ const status = typeof record.status === 'string' ? String(record.status) : undefined
83
+ const to = typeof record.to === 'string' ? String(record.to) : undefined
84
+ const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
85
+ return { status, to, followUpId }
86
+ } catch {
87
+ return null
88
+ }
89
+ }
90
+
91
+ function canonicalUploadMediaKey(filePath: string): string {
92
+ const base = path.basename(filePath)
93
+ const ext = path.extname(base).toLowerCase()
94
+ const normalized = base
95
+ .replace(/^\d{10,16}-/, '')
96
+ .replace(/^(?:browser|screenshot)-\d{10,16}(?:-\d+)?\./, `playwright-capture.`)
97
+ .toLowerCase()
98
+ return normalized || `unknown${ext}`
99
+ }
100
+
101
+ function shouldAllowMultipleMediaSends(userText: string): boolean {
102
+ const text = (userText || '').toLowerCase()
103
+ return /\b(all|both|multiple|several|many|every|each|two|three|4|four|screenshots|images|photos|files|documents)\b/.test(text)
104
+ }
105
+
106
+ function preferSingleBestMediaFile(files: Array<{ path: string; alt: string }>): Array<{ path: string; alt: string }> {
107
+ if (files.length <= 1) return files
108
+ const ranked = [...files].sort((a, b) => {
109
+ const score = (entry: { path: string }) => {
110
+ const base = path.basename(entry.path).toLowerCase()
111
+ let value = 0
112
+ if (/^\d{10,16}-/.test(base)) value += 20
113
+ if (!base.startsWith('browser-') && !base.startsWith('screenshot-')) value += 10
114
+ if (base.endsWith('.pdf')) value += 8
115
+ if (base.endsWith('.png') || base.endsWith('.jpg') || base.endsWith('.jpeg') || base.endsWith('.webp')) value += 6
116
+ try {
117
+ const stat = fs.statSync(entry.path)
118
+ value += Math.min(5, Math.round((stat.mtimeMs % 10_000) / 2_000))
119
+ } catch { /* ignore stat errors */ }
120
+ return value
121
+ }
122
+ return score(b) - score(a)
123
+ })
124
+ return [ranked[0]]
125
+ }
126
+
127
+ export function selectOutboundMediaFiles(
128
+ files: Array<{ path: string; alt: string }>,
129
+ userText: string,
130
+ ): Array<{ path: string; alt: string }> {
131
+ if (files.length === 0) return []
132
+ const mergedFiles: Array<{ path: string; alt: string }> = []
133
+ const seenMediaKeys = new Set<string>()
134
+ for (const candidate of files) {
135
+ const mediaKey = canonicalUploadMediaKey(candidate.path)
136
+ if (seenMediaKeys.has(mediaKey)) continue
137
+ seenMediaKeys.add(mediaKey)
138
+ mergedFiles.push(candidate)
139
+ }
140
+ return shouldAllowMultipleMediaSends(userText || '')
141
+ ? mergedFiles
142
+ : preferSingleBestMediaFile(mergedFiles)
143
+ }
144
+
145
+ /**
146
+ * Extract embedded media references from agent response text.
147
+ * Supports markdown images/links and bare upload URLs.
148
+ */
149
+ export function extractEmbeddedMedia(text: string): { cleanText: string; files: Array<{ path: string; alt: string }> } {
150
+ const files: Array<{ path: string; alt: string }> = []
151
+ const seen = new Set<string>()
152
+ let cleanText = text
153
+
154
+ const pushFile = (filePath: string, alt: string) => {
155
+ if (!filePath || seen.has(filePath)) return
156
+ seen.add(filePath)
157
+ files.push({ path: filePath, alt: alt.trim() })
158
+ }
159
+
160
+ const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
161
+ cleanText = cleanText.replace(imageRegex, (full, altRaw, urlRaw) => {
162
+ const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
163
+ if (!filePath) return full
164
+ pushFile(filePath, String(altRaw || ''))
165
+ return ''
166
+ })
167
+
168
+ const linkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g
169
+ cleanText = cleanText.replace(linkRegex, (full, altRaw, urlRaw) => {
170
+ const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
171
+ if (!filePath) return full
172
+ pushFile(filePath, String(altRaw || ''))
173
+ return ''
174
+ })
175
+
176
+ const bareUploadUrlRegex = /(?:https?:\/\/[^\s)]+)?\/api\/uploads\/[^\s)\]]+/g
177
+ cleanText = cleanText.replace(bareUploadUrlRegex, (full) => {
178
+ const filePath = resolveUploadPathFromUrl(full)
179
+ if (!filePath) return full
180
+ pushFile(filePath, '')
181
+ return ''
182
+ })
183
+
184
+ if (files.length === 0) return { cleanText: text, files }
185
+ cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
186
+ return { cleanText, files }
187
+ }
188
+
189
+ function buildInboundAttachmentPaths(msg: InboundMessage): string[] {
190
+ if (!Array.isArray(msg.media) || msg.media.length === 0) return []
191
+ const paths: string[] = []
192
+ const seen = new Set<string>()
193
+ for (const media of msg.media) {
194
+ const localPath = typeof media.localPath === 'string' ? media.localPath.trim() : ''
195
+ if (!localPath || seen.has(localPath)) continue
196
+ if (!fs.existsSync(localPath)) continue
197
+ seen.add(localPath)
198
+ paths.push(localPath)
199
+ }
200
+ return paths
201
+ }
202
+
203
+ function normalizeWhatsappTarget(raw: string): string {
204
+ const trimmed = raw.trim()
205
+ if (!trimmed) return trimmed
206
+ if (trimmed.includes('@')) return trimmed
207
+ let cleaned = trimmed.replace(/[^\d+]/g, '')
208
+ if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
209
+ if (cleaned.startsWith('0') && cleaned.length >= 10) {
210
+ cleaned = `44${cleaned.slice(1)}`
211
+ }
212
+ cleaned = cleaned.replace(/[^\d]/g, '')
213
+ return cleaned ? `${cleaned}@s.whatsapp.net` : trimmed
214
+ }
215
+
216
+ function connectorSupportsBinaryMedia(platform: string): boolean {
217
+ return platform === 'whatsapp'
218
+ || platform === 'telegram'
219
+ || platform === 'slack'
220
+ || platform === 'discord'
221
+ || platform === 'openclaw'
222
+ }
223
+
26
224
  /** Sentinel value agents return when no outbound reply should be sent */
27
225
  export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
28
226
 
@@ -60,6 +258,34 @@ const genCounterKey = '__swarmclaw_connector_gen__' as const
60
258
  const generationCounter: Map<string, number> =
61
259
  g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
62
260
 
261
+ type ScheduledConnectorFollowup = {
262
+ id: string
263
+ connectorId?: string
264
+ platform?: string
265
+ channelId: string
266
+ sendAt: number
267
+ timer: ReturnType<typeof setTimeout>
268
+ }
269
+
270
+ const followupKey = '__swarmclaw_connector_followups__' as const
271
+ const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
272
+ g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
273
+
274
+ type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
275
+ const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
276
+ const routeMessageHandlerRef: { current: RouteMessageHandler } =
277
+ g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
278
+
279
+ function dispatchInboundConnectorMessage(
280
+ connectorId: string,
281
+ fallbackConnector: Connector,
282
+ msg: InboundMessage,
283
+ ): Promise<string> {
284
+ const connectors = loadConnectors()
285
+ const currentConnector = connectors[connectorId] as Connector | undefined
286
+ return routeMessageHandlerRef.current(currentConnector ?? fallbackConnector, msg)
287
+ }
288
+
63
289
  /** Get the current generation number for a connector (0 if never started) */
64
290
  export function getConnectorGeneration(connectorId: string): number {
65
291
  return generationCounter.get(connectorId) ?? 0
@@ -421,6 +647,140 @@ async function handleConnectorCommand(params: {
421
647
  return 'Unknown command.'
422
648
  }
423
649
 
650
+ /** Route an inbound message to a chatroom — process mentioned agents and return concatenated responses */
651
+ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage): Promise<string> {
652
+ const chatroomId = connector.chatroomId
653
+ if (!chatroomId) return '[Error] No chatroom configured.'
654
+
655
+ const chatrooms = loadChatrooms()
656
+ const chatroom = chatrooms[chatroomId] as Chatroom | undefined
657
+ if (!chatroom) return '[Error] Chatroom not found.'
658
+
659
+ const agents = loadAgents()
660
+ const source: MessageSource = {
661
+ platform: connector.platform,
662
+ connectorId: connector.id,
663
+ connectorName: connector.name,
664
+ senderName: msg.senderName,
665
+ }
666
+ const inboundText = formatInboundUserText(msg)
667
+ const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
668
+ const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
669
+
670
+ // Parse mentions from the message text
671
+ let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
672
+ // Auto-address: if enabled and no explicit mentions, address all agents
673
+ if (chatroom.autoAddress && mentions.length === 0) {
674
+ mentions = [...chatroom.agentIds]
675
+ }
676
+
677
+ // Create and persist the user message in the chatroom
678
+ const userMessage: ChatroomMessage = {
679
+ id: genId(),
680
+ senderId: 'user',
681
+ senderName: msg.senderName || 'User',
682
+ role: 'user',
683
+ text: msg.text || '',
684
+ mentions,
685
+ reactions: [],
686
+ time: Date.now(),
687
+ ...(firstImagePath ? { imagePath: firstImagePath } : {}),
688
+ ...(inboundAttachmentPaths.length ? { attachedFiles: inboundAttachmentPaths } : {}),
689
+ source,
690
+ }
691
+ chatroom.messages.push(userMessage)
692
+ chatroom.updatedAt = Date.now()
693
+ chatrooms[chatroomId] = chatroom
694
+ saveChatrooms(chatrooms)
695
+ notify('chatrooms')
696
+ notify(`chatroom:${chatroomId}`)
697
+
698
+ // Process mentioned agents sequentially and collect responses
699
+ const responses: string[] = []
700
+ for (const agentId of mentions) {
701
+ const agent = agents[agentId]
702
+ if (!agent) continue
703
+
704
+ const apiKey = resolveApiKeyHelper(agent.credentialId)
705
+ const freshChatrooms = loadChatrooms()
706
+ const freshChatroom = freshChatrooms[chatroomId] as Chatroom
707
+
708
+ const syntheticSession = buildSyntheticSession(agent, chatroomId)
709
+ const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
710
+ const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
711
+ const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
712
+ const history = buildHistoryForAgent(freshChatroom, agent.id)
713
+
714
+ try {
715
+ const result = await streamAgentChat({
716
+ session: syntheticSession,
717
+ message: inboundText,
718
+ imagePath: firstImagePath || undefined,
719
+ attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
720
+ apiKey,
721
+ systemPrompt: fullSystemPrompt,
722
+ write: () => {},
723
+ history,
724
+ })
725
+
726
+ const responseText = result.finalResponse || result.fullText
727
+ if (responseText.trim() && !isNoMessage(responseText)) {
728
+ // Persist agent response to chatroom
729
+ const agentSource: MessageSource = {
730
+ platform: connector.platform,
731
+ connectorId: connector.id,
732
+ connectorName: connector.name,
733
+ }
734
+ const agentMessage: ChatroomMessage = {
735
+ id: genId(),
736
+ senderId: agent.id,
737
+ senderName: agent.name,
738
+ role: 'assistant',
739
+ text: responseText,
740
+ mentions: parseMentions(responseText, agents, freshChatroom.agentIds),
741
+ reactions: [],
742
+ time: Date.now(),
743
+ source: agentSource,
744
+ }
745
+ const latestChatrooms = loadChatrooms()
746
+ const latestChatroom = latestChatrooms[chatroomId] as Chatroom
747
+ latestChatroom.messages.push(agentMessage)
748
+ latestChatroom.updatedAt = Date.now()
749
+ latestChatrooms[chatroomId] = latestChatroom
750
+ saveChatrooms(latestChatrooms)
751
+ notify(`chatroom:${chatroomId}`)
752
+
753
+ responses.push(`[${agent.name}] ${responseText}`)
754
+ }
755
+ } catch (err: unknown) {
756
+ const errMsg = err instanceof Error ? err.message : String(err)
757
+ console.error(`[connector] Chatroom agent ${agent.name} error:`, errMsg)
758
+ }
759
+ }
760
+
761
+ if (responses.length === 0) return NO_MESSAGE_SENTINEL
762
+
763
+ const joined = responses.join('\n\n')
764
+ // Extract embedded media from agent responses and send them via connector
765
+ const extracted = extractEmbeddedMedia(joined)
766
+ const filesToSend = selectOutboundMediaFiles(extracted.files, msg.text || '')
767
+ if (filesToSend.length > 0) {
768
+ const inst = running.get(connector.id)
769
+ if (inst?.sendMessage) {
770
+ for (const file of filesToSend) {
771
+ try {
772
+ await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
773
+ console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
774
+ } catch (err: unknown) {
775
+ console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
776
+ }
777
+ }
778
+ }
779
+ return extracted.cleanText || '(no response)'
780
+ }
781
+ return joined
782
+ }
783
+
424
784
  /** Route an inbound message through the assigned agent and return the response */
425
785
  async function routeMessage(connector: Connector, msg: InboundMessage): Promise<string> {
426
786
  if (msg?.channelId) {
@@ -428,8 +788,14 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
428
788
  }
429
789
  lastInboundTimeByConnector.set(connector.id, Date.now())
430
790
 
791
+ // Route to chatroom if configured
792
+ if (connector.chatroomId) {
793
+ return routeMessageToChatroom(connector, msg)
794
+ }
795
+
431
796
  const agents = loadAgents()
432
797
  const effectiveAgentId = msg.agentIdOverride || connector.agentId
798
+ if (!effectiveAgentId) return '[Error] Connector has no agent configured.'
433
799
  const agent = agents[effectiveAgentId]
434
800
  if (!agent) return '[Error] Connector agent not found.'
435
801
 
@@ -472,11 +838,16 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
472
838
  }
473
839
  }
474
840
 
475
- // Find or create a session keyed by platform + channel
841
+ // Find a session for this connector message.
842
+ // Prefer the agent's thread session (visible in the agent chat UI) so connector
843
+ // messages appear inline alongside web UI messages.
844
+ // Fall back to a connector-keyed session if the agent has no thread session.
476
845
  const sessionKey = `connector:${connector.id}:${msg.channelId}`
477
846
  const sessions = loadSessions()
478
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
479
- let session = Object.values(sessions).find((s: any) => s.name === sessionKey)
847
+ let session = (agent.threadSessionId && sessions[agent.threadSessionId])
848
+ ? sessions[agent.threadSessionId]
849
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
850
+ : Object.values(sessions).find((s: any) => s.name === sessionKey)
480
851
  if (!session) {
481
852
  const id = genId()
482
853
  session = {
@@ -564,6 +935,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
564
935
  const settings = loadSettings()
565
936
  const promptParts: string[] = []
566
937
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
938
+ promptParts.push(buildCurrentDateTimePromptContext())
567
939
  if (agent.soul) promptParts.push(agent.soul)
568
940
  if (agent.systemPrompt) promptParts.push(agent.systemPrompt)
569
941
  if (agent.skillIds?.length) {
@@ -582,24 +954,49 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
582
954
  // Add connector context
583
955
  promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
584
956
 
957
+ ## Response Style
958
+ Be action-first and autonomous: when the user gives an instruction, execute it instead of asking routine follow-up questions.
959
+ Do not end every reply with a question.
960
+ Only ask a question when a specific missing detail blocks progress.
961
+ When a task is complete, state the result plainly and stop.
962
+
585
963
  ## Knowing When Not to Reply
586
964
  Real conversations have natural pauses — not every message needs a response. Reply with exactly "NO_MESSAGE" (nothing else) to stay silent when replying would feel unnatural or forced.
587
965
  Stay silent for simple acknowledgments ("okay", "alright", "cool", "got it", "sounds good"), conversation closers ("thanks", "bye", "night", "ttyl"), reactions (emoji, "haha", "lol"), and forwarded content with no question attached.
588
966
  Always reply when there's a question, task, instruction, emotional sharing, or something genuinely useful to add.
589
- The test: would a thoughtful friend feel compelled to type something back? If not, NO_MESSAGE.`)
967
+ The test: would a thoughtful friend feel compelled to type something back? If not, NO_MESSAGE.
968
+
969
+ ## Media Delivery Rules
970
+ When the user asks to send media (image, screenshot, PDF, file, or voice note), actually call tools to send it.
971
+ Do not claim "sent" unless a tool call succeeded.
972
+ If voice note is requested, prefer connector_message_tool action=send_voice_note when available.
973
+ If media sending fails, report the exact error and retry with a corrected path/target.`)
590
974
  const systemPrompt = promptParts.join('\n\n')
591
975
 
592
976
  // Add message to session
593
977
  const firstImage = msg.media?.find((m) => m.type === 'image')
594
978
  const firstImageUrl = msg.imageUrl || (firstImage?.url) || undefined
595
979
  const firstImagePath = firstImage?.localPath || undefined
980
+ const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
596
981
  const inboundText = formatInboundUserText(msg)
982
+ const modelInputText = inboundText
983
+ // Store the raw user text for display (source.senderName handles attribution).
984
+ // The formatted text with [SenderName] prefix is only used for LLM history context.
985
+ const rawText = (msg.text || '').trim()
986
+ const messageSource: MessageSource = {
987
+ platform: connector.platform,
988
+ connectorId: connector.id,
989
+ connectorName: connector.name,
990
+ senderName: msg.senderName,
991
+ }
597
992
  session.messages.push({
598
993
  role: 'user',
599
- text: inboundText,
994
+ text: rawText || inboundText,
600
995
  time: Date.now(),
601
996
  imageUrl: firstImageUrl,
602
997
  imagePath: firstImagePath,
998
+ attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
999
+ source: messageSource,
603
1000
  })
604
1001
  session.lastActiveAt = Date.now()
605
1002
  const s1 = loadSessions()
@@ -608,22 +1005,49 @@ The test: would a thoughtful friend feel compelled to type something back? If no
608
1005
 
609
1006
  // Stream the response
610
1007
  let fullText = ''
1008
+ let mediaExtractionText = ''
1009
+ let connectorToolDeliveredCurrentChannel = false
611
1010
  const hasTools = session.tools?.length && session.provider !== 'claude-cli'
612
1011
  console.log(`[connector] Routing message to agent "${agent.name}" (${agent.provider}/${agent.model}), hasTools=${!!hasTools}`)
613
1012
 
614
1013
  if (hasTools) {
615
1014
  try {
1015
+ const toolMediaOutputs: string[] = []
616
1016
  const result = await streamAgentChat({
617
1017
  session,
618
- message: msg.text,
1018
+ message: modelInputText,
619
1019
  imagePath: firstImagePath,
1020
+ attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
620
1021
  apiKey,
621
1022
  systemPrompt,
622
- write: () => {}, // no SSE needed for connectors
1023
+ write: (raw) => {
1024
+ for (const event of parseSseDataEvents(raw)) {
1025
+ if (event.t !== 'tool_result') continue
1026
+ const toolOutput = typeof event.toolOutput === 'string' ? event.toolOutput : ''
1027
+ if (!toolOutput) continue
1028
+ toolMediaOutputs.push(toolOutput)
1029
+ if (event.toolName === 'connector_message_tool') {
1030
+ const parsed = parseConnectorToolResult(toolOutput)
1031
+ if (!parsed?.status || !parsed.to) continue
1032
+ const sentLikeStatus = parsed.status === 'sent' || parsed.status === 'voice_sent'
1033
+ if (!sentLikeStatus) continue
1034
+ const inboundTarget = connector.platform === 'whatsapp'
1035
+ ? normalizeWhatsappTarget(msg.channelId)
1036
+ : msg.channelId
1037
+ const outboundTarget = connector.platform === 'whatsapp'
1038
+ ? normalizeWhatsappTarget(parsed.to)
1039
+ : parsed.to
1040
+ if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
1041
+ connectorToolDeliveredCurrentChannel = true
1042
+ }
1043
+ }
1044
+ }
1045
+ },
623
1046
  history: session.messages.slice(-20),
624
1047
  })
625
1048
  // Use finalResponse for connectors — strips intermediate planning/tool-use text
626
- fullText = result.finalResponse
1049
+ fullText = result.finalResponse || result.fullText
1050
+ mediaExtractionText = [result.fullText || '', ...toolMediaOutputs].filter(Boolean).join('\n\n')
627
1051
  console.log(`[connector] streamAgentChat returned ${result.fullText.length} chars total, ${fullText.length} chars final`)
628
1052
  } catch (err: unknown) {
629
1053
  const message = err instanceof Error ? err.message : String(err)
@@ -638,7 +1062,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
638
1062
 
639
1063
  await provider.handler.streamChat({
640
1064
  session,
641
- message: msg.text,
1065
+ message: modelInputText,
642
1066
  imagePath: firstImagePath,
643
1067
  apiKey,
644
1068
  systemPrompt,
@@ -654,6 +1078,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
654
1078
  active: new Map(),
655
1079
  loadHistory: () => session.messages.slice(-20),
656
1080
  })
1081
+ mediaExtractionText = fullText
657
1082
  }
658
1083
 
659
1084
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
@@ -679,9 +1104,14 @@ The test: would a thoughtful friend feel compelled to type something back? If no
679
1104
  },
680
1105
  })
681
1106
 
682
- // Save assistant response to session
1107
+ // Save assistant response to session (full text with image markdown for web UI rendering)
1108
+ const assistantSource: MessageSource = {
1109
+ platform: connector.platform,
1110
+ connectorId: connector.id,
1111
+ connectorName: connector.name,
1112
+ }
683
1113
  if (fullText.trim()) {
684
- session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now() })
1114
+ session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
685
1115
  session.lastActiveAt = Date.now()
686
1116
  const s2 = loadSessions()
687
1117
  s2[session.id] = session
@@ -689,9 +1119,68 @@ The test: would a thoughtful friend feel compelled to type something back? If no
689
1119
  notify(`messages:${session.id}`)
690
1120
  }
691
1121
 
1122
+ // Extract embedded media (screenshots, uploaded files) and send them as separate
1123
+ // media messages via the connector, then return the cleaned text
1124
+ const extractedFromReply = extractEmbeddedMedia(fullText)
1125
+ const extractedFromTools = mediaExtractionText && mediaExtractionText !== fullText
1126
+ ? extractEmbeddedMedia(mediaExtractionText)
1127
+ : { cleanText: mediaExtractionText || fullText, files: [] as Array<{ path: string; alt: string }> }
1128
+ const filesToSend = selectOutboundMediaFiles(
1129
+ [...extractedFromReply.files, ...extractedFromTools.files],
1130
+ msg.text || '',
1131
+ )
1132
+
1133
+ if (filesToSend.length > 0) {
1134
+ const inst = running.get(connector.id)
1135
+ if (inst?.sendMessage) {
1136
+ for (const file of filesToSend) {
1137
+ try {
1138
+ await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
1139
+ console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
1140
+ logExecution(session.id, 'outbound', 'Connector media sent', {
1141
+ agentId: agent.id,
1142
+ detail: {
1143
+ platform: msg.platform,
1144
+ channelId: msg.channelId,
1145
+ filePath: file.path,
1146
+ fileName: path.basename(file.path),
1147
+ },
1148
+ })
1149
+ } catch (err: unknown) {
1150
+ console.error(`[connector] Failed to send media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
1151
+ logExecution(session.id, 'error', 'Connector media send failed', {
1152
+ agentId: agent.id,
1153
+ detail: {
1154
+ platform: msg.platform,
1155
+ channelId: msg.channelId,
1156
+ filePath: file.path,
1157
+ fileName: path.basename(file.path),
1158
+ error: err instanceof Error ? err.message : String(err),
1159
+ },
1160
+ })
1161
+ }
1162
+ }
1163
+ } else {
1164
+ logExecution(session.id, 'error', 'Connector media skipped: sendMessage unavailable', {
1165
+ agentId: agent.id,
1166
+ detail: {
1167
+ platform: msg.platform,
1168
+ channelId: msg.channelId,
1169
+ fileCount: filesToSend.length,
1170
+ connectorId: connector.id,
1171
+ },
1172
+ })
1173
+ }
1174
+ if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
1175
+ return extractedFromReply.cleanText || '(no response)'
1176
+ }
1177
+
1178
+ if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
692
1179
  return fullText || '(no response)'
693
1180
  }
694
1181
 
1182
+ routeMessageHandlerRef.current = routeMessage
1183
+
695
1184
  /** Start a connector (serialized per ID to prevent concurrent start/stop races) */
696
1185
  export async function startConnector(connectorId: string): Promise<void> {
697
1186
  // Wait for any pending operation on this connector to finish (with timeout)
@@ -756,7 +1245,11 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
756
1245
  generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
757
1246
 
758
1247
  try {
759
- const instance = await platform.start(connector, botToken, (msg) => routeMessage(connector, msg))
1248
+ const instance = await platform.start(
1249
+ connector,
1250
+ botToken,
1251
+ (msg) => dispatchInboundConnectorMessage(connectorId, connector, msg),
1252
+ )
760
1253
  running.set(connectorId, instance)
761
1254
 
762
1255
  // Update status in storage
@@ -789,6 +1282,12 @@ export async function stopConnector(connectorId: string): Promise<void> {
789
1282
  running.delete(connectorId)
790
1283
  }
791
1284
 
1285
+ for (const [followupId, followup] of scheduledFollowups.entries()) {
1286
+ if (followup.connectorId !== connectorId) continue
1287
+ clearTimeout(followup.timer)
1288
+ scheduledFollowups.delete(followupId)
1289
+ }
1290
+
792
1291
  const connectors = loadConnectors()
793
1292
  const connector = connectors[connectorId]
794
1293
  if (connector) {
@@ -873,6 +1372,7 @@ export function listRunningConnectors(platform?: string): Array<{
873
1372
  id: string
874
1373
  name: string
875
1374
  platform: string
1375
+ agentId: string | null
876
1376
  supportsSend: boolean
877
1377
  configuredTargets: string[]
878
1378
  recentChannelId: string | null
@@ -882,6 +1382,7 @@ export function listRunningConnectors(platform?: string): Array<{
882
1382
  id: string
883
1383
  name: string
884
1384
  platform: string
1385
+ agentId: string | null
885
1386
  supportsSend: boolean
886
1387
  configuredTargets: string[]
887
1388
  recentChannelId: string | null
@@ -907,6 +1408,7 @@ export function listRunningConnectors(platform?: string): Array<{
907
1408
  id,
908
1409
  name: connector.name,
909
1410
  platform: connector.platform,
1411
+ agentId: connector.agentId || null,
910
1412
  supportsSend: typeof instance.sendMessage === 'function',
911
1413
  configuredTargets: Array.from(new Set(configuredTargets)),
912
1414
  recentChannelId: lastInboundChannelByConnector.get(id) || null,
@@ -949,6 +1451,7 @@ export async function sendConnectorMessage(params: {
949
1451
  mimeType?: string
950
1452
  fileName?: string
951
1453
  caption?: string
1454
+ ptt?: boolean
952
1455
  }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
953
1456
  const connectors = loadConnectors()
954
1457
  const requestedId = params.connectorId?.trim()
@@ -988,18 +1491,93 @@ export async function sendConnectorMessage(params: {
988
1491
  return { connectorId, platform: connector.platform, channelId: params.channelId }
989
1492
  }
990
1493
 
991
- const result = await instance.sendMessage(params.channelId, params.text, {
1494
+ const hasMedia = !!(params.imageUrl || params.fileUrl || params.mediaPath)
1495
+ const channelId = connector.platform === 'whatsapp'
1496
+ ? normalizeWhatsappTarget(params.channelId)
1497
+ : params.channelId
1498
+
1499
+ let outboundText = params.text || ''
1500
+ let outboundOptions: Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2] | undefined = {
992
1501
  imageUrl: params.imageUrl,
993
1502
  fileUrl: params.fileUrl,
994
1503
  mediaPath: params.mediaPath,
995
1504
  mimeType: params.mimeType,
996
1505
  fileName: params.fileName,
997
1506
  caption: params.caption,
998
- })
1507
+ ptt: params.ptt,
1508
+ }
1509
+
1510
+ if (hasMedia && !connectorSupportsBinaryMedia(connector.platform)) {
1511
+ const mediaLink = params.imageUrl
1512
+ || params.fileUrl
1513
+ || (params.mediaPath ? uploadApiUrlFromPath(params.mediaPath) : null)
1514
+ const fallbackParts = [
1515
+ (params.text || '').trim(),
1516
+ (params.caption || '').trim(),
1517
+ mediaLink ? `Attachment: ${mediaLink}` : '',
1518
+ !mediaLink && params.mediaPath ? `Attachment: ${path.basename(params.mediaPath)}` : '',
1519
+ ].filter(Boolean)
1520
+ outboundText = fallbackParts.join('\n')
1521
+ outboundOptions = undefined
1522
+ }
1523
+
1524
+ const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
999
1525
  return {
1000
1526
  connectorId,
1001
1527
  platform: connector.platform,
1002
- channelId: params.channelId,
1528
+ channelId,
1003
1529
  messageId: result?.messageId,
1004
1530
  }
1005
1531
  }
1532
+
1533
+ export function scheduleConnectorFollowUp(params: {
1534
+ connectorId?: string
1535
+ platform?: string
1536
+ channelId: string
1537
+ text: string
1538
+ delaySec?: number
1539
+ imageUrl?: string
1540
+ fileUrl?: string
1541
+ mediaPath?: string
1542
+ mimeType?: string
1543
+ fileName?: string
1544
+ caption?: string
1545
+ ptt?: boolean
1546
+ }): { followUpId: string; sendAt: number } {
1547
+ const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
1548
+ const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
1549
+ const followUpId = genId()
1550
+ const sendAt = Date.now() + delayMs
1551
+
1552
+ const timer = setTimeout(() => {
1553
+ void sendConnectorMessage({
1554
+ connectorId: params.connectorId,
1555
+ platform: params.platform,
1556
+ channelId: params.channelId,
1557
+ text: params.text,
1558
+ imageUrl: params.imageUrl,
1559
+ fileUrl: params.fileUrl,
1560
+ mediaPath: params.mediaPath,
1561
+ mimeType: params.mimeType,
1562
+ fileName: params.fileName,
1563
+ caption: params.caption,
1564
+ ptt: params.ptt,
1565
+ }).catch((err: unknown) => {
1566
+ const msg = err instanceof Error ? err.message : String(err)
1567
+ console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
1568
+ }).finally(() => {
1569
+ scheduledFollowups.delete(followUpId)
1570
+ })
1571
+ }, delayMs)
1572
+
1573
+ scheduledFollowups.set(followUpId, {
1574
+ id: followUpId,
1575
+ connectorId: params.connectorId,
1576
+ platform: params.platform,
1577
+ channelId: params.channelId,
1578
+ sendAt,
1579
+ timer,
1580
+ })
1581
+
1582
+ return { followUpId, sendAt }
1583
+ }