@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
@@ -1,107 +1,619 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
- import { loadConnectors, loadSettings } from '../storage'
3
+ import path from 'path'
4
+ import fs from 'fs'
5
+ import { loadConnectors, loadSettings, UPLOAD_DIR } from '../storage'
6
+ import { genId } from '@/lib/id'
7
+ import { synthesizeElevenLabsMp3 } from '../elevenlabs'
4
8
  import type { ToolBuildContext } from './context'
5
9
 
10
+ const CONNECTOR_ACTION_DEDUPE_TTL_MS = 30_000
11
+ const CONNECTOR_TURN_SEND_TTL_MS = 180_000
12
+ const AUTONOMOUS_OUTREACH_COOLDOWN_MS = 2 * 60 * 60 * 1000
13
+ const recentConnectorActionCache = new Map<string, { at: number; result: string }>()
14
+ const connectorTurnSendBudget = new Map<string, { count: number; at: number; lastResult?: string }>()
15
+ const autonomousOutreachBudget = new Map<string, { at: number; result?: string }>()
16
+
17
+ function pruneOldConnectorToolState(now: number): void {
18
+ for (const [key, entry] of recentConnectorActionCache.entries()) {
19
+ if (now - entry.at > CONNECTOR_ACTION_DEDUPE_TTL_MS) recentConnectorActionCache.delete(key)
20
+ }
21
+ for (const [key, entry] of connectorTurnSendBudget.entries()) {
22
+ if (now - entry.at > CONNECTOR_TURN_SEND_TTL_MS) connectorTurnSendBudget.delete(key)
23
+ }
24
+ for (const [key, entry] of autonomousOutreachBudget.entries()) {
25
+ if (now - entry.at > AUTONOMOUS_OUTREACH_COOLDOWN_MS) autonomousOutreachBudget.delete(key)
26
+ }
27
+ }
28
+
29
+ function parseLatestUserTurn(
30
+ session: { messages?: Array<Record<string, unknown>> } | null | undefined,
31
+ ): { text: string; time: number } {
32
+ const msgs = Array.isArray(session?.messages) ? session.messages : []
33
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
34
+ const msg = msgs[i]
35
+ if (String(msg?.role || '') !== 'user') continue
36
+ const text = typeof msg.text === 'string' ? msg.text.trim() : ''
37
+ const time = typeof msg.time === 'number' ? msg.time : 0
38
+ return { text, time }
39
+ }
40
+ return { text: '', time: 0 }
41
+ }
42
+
43
+ function userExplicitlyWantsMultipleOutbound(userText: string): boolean {
44
+ if (!userText) return false
45
+ const text = userText.toLowerCase()
46
+ return /\b(both|multiple|all of them|all numbers|two messages|three messages|each number|every number|and also|plus also|send again|resend)\b/.test(text)
47
+ }
48
+
49
+ function userExplicitlyRequestedFollowup(userText: string): boolean {
50
+ if (!userText) return false
51
+ const text = userText.toLowerCase()
52
+ if (/connector_message_tool/.test(text) && /(schedule_followup|followupmessage|followup|delaysec|follow.?up)/.test(text)) return true
53
+ return /\b(follow[ -]?up|check[ -]?in|remind(?: me)?|later|tomorrow|in \d+\s*(sec|secs|second|seconds|min|mins|minute|minutes|hour|hours|day|days))\b/.test(text)
54
+ }
55
+
56
+ function isAutonomousSystemTurn(userText: string): boolean {
57
+ if (!userText) return false
58
+ const text = userText.toUpperCase()
59
+ return text.includes('AGENT_HEARTBEAT_WAKE')
60
+ || text.includes('SWARM_MAIN_MISSION_TICK')
61
+ || text.includes('SWARM_MAIN_AUTO_FOLLOWUP')
62
+ || text.includes('SWARM_HEARTBEAT_CHECK')
63
+ }
64
+
65
+ function isSignificantOutreachText(raw: string): boolean {
66
+ const text = (raw || '').trim().toLowerCase()
67
+ if (text.length < 12) return false
68
+ if (/\b(just checking in|checking in|touching base|quick check-in|hope you'?re well|any updates\??)\b/.test(text)) {
69
+ return false
70
+ }
71
+ return /\b(completed|complete|done|finished|failed|failure|error|blocked|urgent|important|deadline|overdue|incident|warning|reminder|birthday|anniversary|milestone|congrats|congratulations|celebrate|payment|invoice|appointment|meeting)\b/.test(text)
72
+ }
73
+
74
+ function isUrgentOutreachText(raw: string): boolean {
75
+ const text = (raw || '').toLowerCase()
76
+ return /\b(urgent|immediately|asap|critical|incident|outage|failed|failure|blocked|overdue|deadline)\b/.test(text)
77
+ }
78
+
79
+ function buildConnectorActionKey(parts: Array<string | number | boolean | null | undefined>): string {
80
+ return parts.map((part) => String(part ?? '')).join('|')
81
+ }
82
+
83
+ function normalizeDedupedReplayResult(raw: string, fallback: { connectorId: string; platform: string; to: string }): string {
84
+ try {
85
+ const parsed = JSON.parse(raw)
86
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid')
87
+ const record = parsed as Record<string, unknown>
88
+ if (String(record.status || '') === 'deduped') {
89
+ return JSON.stringify({
90
+ status: 'sent',
91
+ connectorId: String(record.connectorId || fallback.connectorId),
92
+ platform: String(record.platform || fallback.platform),
93
+ to: String(record.to || fallback.to),
94
+ deduped: true,
95
+ })
96
+ }
97
+ return raw
98
+ } catch {
99
+ return JSON.stringify({
100
+ status: 'sent',
101
+ connectorId: fallback.connectorId,
102
+ platform: fallback.platform,
103
+ to: fallback.to,
104
+ deduped: true,
105
+ })
106
+ }
107
+ }
108
+
109
+ /** Resolve /api/uploads/filename URLs to actual disk paths */
110
+ function resolveUploadUrl(url: string | undefined): { mediaPath: string; mimeType?: string } | null {
111
+ if (!url) return null
112
+ const match = url.match(/^\/api\/uploads\/([^?#]+)/)
113
+ if (!match) return null
114
+ // Decode URL-encoded filenames (e.g. from encodeURIComponent) before sanitizing
115
+ let decoded: string
116
+ try { decoded = decodeURIComponent(match[1]) } catch { decoded = match[1] }
117
+ const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
118
+ const filePath = path.join(UPLOAD_DIR, safeName)
119
+ if (!fs.existsSync(filePath)) return null
120
+ return { mediaPath: filePath }
121
+ }
122
+
123
+ function normalizeWhatsAppTarget(input: string): string {
124
+ const raw = input.trim()
125
+ if (!raw) return raw
126
+ if (raw.includes('@')) return raw
127
+ let cleaned = raw.replace(/[^\d+]/g, '')
128
+ if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
129
+ if (cleaned.startsWith('0') && cleaned.length >= 10) {
130
+ cleaned = `44${cleaned.slice(1)}`
131
+ }
132
+ cleaned = cleaned.replace(/[^\d]/g, '')
133
+ return cleaned ? `${cleaned}@s.whatsapp.net` : raw
134
+ }
135
+
136
+ function parseCsv(raw: string | undefined): string[] {
137
+ if (!raw) return []
138
+ return raw.split(',').map((s) => s.trim()).filter(Boolean)
139
+ }
140
+
141
+ function pickChannelTarget(params: {
142
+ connector: { config?: Record<string, string> }
143
+ to?: string
144
+ recentChannelId: string | null
145
+ }): { channelId: string; error?: string } {
146
+ let channelId = params.to?.trim() || ''
147
+ const connector = params.connector
148
+
149
+ if (!channelId) {
150
+ const outbound = connector.config?.outboundJid?.trim()
151
+ if (outbound) channelId = outbound
152
+ }
153
+ if (!channelId) {
154
+ const outbound = connector.config?.outboundTarget?.trim()
155
+ if (outbound) channelId = outbound
156
+ }
157
+ if (!channelId && params.recentChannelId) {
158
+ channelId = params.recentChannelId
159
+ }
160
+ if (!channelId) {
161
+ const allowed = parseCsv(connector.config?.allowedJids)
162
+ if (allowed.length) channelId = allowed[0]
163
+ }
164
+ if (!channelId) {
165
+ const allowed = parseCsv(connector.config?.allowFrom)
166
+ if (allowed.length) channelId = allowed[0]
167
+ }
168
+ if (!channelId) {
169
+ const knownTargets = [
170
+ connector.config?.outboundJid?.trim(),
171
+ connector.config?.outboundTarget?.trim(),
172
+ ...parseCsv(connector.config?.allowedJids),
173
+ ...parseCsv(connector.config?.allowFrom),
174
+ ].filter(Boolean) as string[]
175
+ const unique = [...new Set(knownTargets)]
176
+ if (unique.length) {
177
+ return {
178
+ channelId: '',
179
+ error: `Error: no default outbound target is set, but the connector has ${unique.length} configured number(s)/target(s): ${JSON.stringify(unique)}. Ask the user which one to send to, then re-call with the "to" parameter set to their choice.`,
180
+ }
181
+ }
182
+ return {
183
+ channelId: '',
184
+ error: 'Error: no target recipient configured and no known contacts on this connector. Ask the user for the recipient number/ID, then re-call with the "to" parameter. They can also configure "allowedJids" or "outboundJid" in the connector settings.',
185
+ }
186
+ }
187
+ return { channelId }
188
+ }
189
+
190
+ function resolveConnectorMediaInput(params: {
191
+ cwd: string
192
+ mediaPath?: string
193
+ imageUrl?: string
194
+ fileUrl?: string
195
+ }): { mediaPath?: string; imageUrl?: string; fileUrl?: string; error?: string } {
196
+ let resolvedMediaPath = params.mediaPath?.trim() || undefined
197
+ let resolvedImageUrl = params.imageUrl?.trim() || undefined
198
+ let resolvedFileUrl = params.fileUrl?.trim() || undefined
199
+
200
+ if (resolvedMediaPath && !path.isAbsolute(resolvedMediaPath) && !resolvedMediaPath.startsWith('/api/uploads/')) {
201
+ const candidatePaths = [
202
+ path.resolve(params.cwd, resolvedMediaPath),
203
+ path.resolve(params.cwd, 'uploads', resolvedMediaPath),
204
+ path.join(UPLOAD_DIR, resolvedMediaPath),
205
+ path.join(UPLOAD_DIR, path.basename(resolvedMediaPath)),
206
+ ]
207
+ const found = candidatePaths.find((p) => fs.existsSync(p))
208
+ if (found) {
209
+ resolvedMediaPath = found
210
+ } else {
211
+ return {
212
+ error: `Error: File not found. Tried: ${candidatePaths.join(', ')}. Use an absolute path or ensure the file exists in the session workspace.`,
213
+ }
214
+ }
215
+ }
216
+
217
+ if (!resolvedMediaPath) {
218
+ const fromImage = resolveUploadUrl(resolvedImageUrl)
219
+ if (fromImage) {
220
+ resolvedMediaPath = fromImage.mediaPath
221
+ resolvedImageUrl = undefined
222
+ }
223
+ const fromFile = resolveUploadUrl(resolvedFileUrl)
224
+ if (fromFile) {
225
+ resolvedMediaPath = fromFile.mediaPath
226
+ resolvedFileUrl = undefined
227
+ }
228
+ }
229
+
230
+ return {
231
+ mediaPath: resolvedMediaPath,
232
+ imageUrl: resolvedImageUrl,
233
+ fileUrl: resolvedFileUrl,
234
+ }
235
+ }
236
+
6
237
  export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInterface[] {
7
238
  const tools: StructuredToolInterface[] = []
8
- const { ctx, hasTool } = bctx
239
+ const { hasTool } = bctx
9
240
 
10
241
  if (hasTool('manage_connectors')) {
242
+ const settings = loadSettings()
243
+ const hasElevenLabsKey = !!String(settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '').trim()
244
+ const voiceNoteToolEnabled = settings.elevenLabsEnabled === true && hasElevenLabsKey
245
+ const actionSchema = voiceNoteToolEnabled
246
+ ? z.enum([
247
+ 'list_running',
248
+ 'list_targets',
249
+ 'start',
250
+ 'stop',
251
+ 'send',
252
+ 'send_voice_note',
253
+ 'schedule_followup',
254
+ 'message_react',
255
+ 'message_edit',
256
+ 'message_delete',
257
+ 'message_pin',
258
+ ] as const)
259
+ : z.enum([
260
+ 'list_running',
261
+ 'list_targets',
262
+ 'start',
263
+ 'stop',
264
+ 'send',
265
+ 'schedule_followup',
266
+ 'message_react',
267
+ 'message_edit',
268
+ 'message_delete',
269
+ 'message_pin',
270
+ ] as const)
11
271
  tools.push(
12
272
  tool(
13
- async ({ action, connectorId, platform, to, message, imageUrl, fileUrl, mediaPath, mimeType, fileName, caption, approved }) => {
273
+ async ({
274
+ action,
275
+ connectorId,
276
+ platform,
277
+ to,
278
+ message,
279
+ voiceText,
280
+ voiceId,
281
+ imageUrl,
282
+ fileUrl,
283
+ mediaPath,
284
+ mimeType,
285
+ fileName,
286
+ caption,
287
+ delaySec,
288
+ followUpMessage,
289
+ followUpDelaySec,
290
+ approved,
291
+ ptt,
292
+ }) => {
14
293
  try {
15
- const normalizeWhatsAppTarget = (input: string): string => {
16
- const raw = input.trim()
17
- if (!raw) return raw
18
- if (raw.includes('@')) return raw
19
- let cleaned = raw.replace(/[^\d+]/g, '')
20
- if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
21
- if (cleaned.startsWith('0') && cleaned.length >= 10) {
22
- cleaned = '44' + cleaned.slice(1)
23
- }
24
- cleaned = cleaned.replace(/[^\d]/g, '')
25
- return cleaned ? `${cleaned}@s.whatsapp.net` : raw
26
- }
27
-
28
- const { listRunningConnectors, sendConnectorMessage, getConnectorRecentChannelId } = await import('../connectors/manager')
294
+ const actionName = String(action)
295
+ const { listRunningConnectors, sendConnectorMessage, getConnectorRecentChannelId, scheduleConnectorFollowUp } = await import('../connectors/manager')
29
296
  const running = listRunningConnectors(platform || undefined)
30
297
 
31
- if (action === 'list_running' || action === 'list_targets') {
298
+ if (actionName === 'list_running' || actionName === 'list_targets') {
32
299
  return JSON.stringify(running)
33
300
  }
34
301
 
35
- if (action === 'send') {
36
- const settings = loadSettings()
37
- if (settings.safetyRequireApprovalForOutbound === true && approved !== true) {
38
- return 'Error: outbound connector sends require explicit approval. Re-run with approved=true after user confirmation.'
302
+ if (actionName === 'start') {
303
+ if (!connectorId) {
304
+ // If no ID given, list available connectors to start
305
+ const allConnectors = loadConnectors()
306
+ const stopped = Object.values(allConnectors)
307
+ .filter((c) => !platform || c.platform === platform)
308
+ .filter((c) => !running.find((r) => r.id === c.id))
309
+ .map((c) => ({ id: c.id, name: c.name, platform: c.platform }))
310
+ if (!stopped.length) return 'All connectors are already running.'
311
+ return `Error: connectorId is required. Stopped connectors available to start: ${JSON.stringify(stopped)}`
39
312
  }
40
- const hasText = !!message?.trim()
41
- const hasMedia = !!imageUrl?.trim() || !!fileUrl?.trim()
42
- if (!hasText && !hasMedia) return 'Error: message or media URL is required for send action.'
313
+ const { startConnector: doStart } = await import('../connectors/manager')
314
+ await doStart(connectorId)
315
+ return JSON.stringify({ status: 'started', connectorId })
316
+ }
317
+
318
+ if (actionName === 'stop') {
319
+ if (!connectorId) return 'Error: connectorId is required for stop action.'
320
+ const { stopConnector: doStop } = await import('../connectors/manager')
321
+ await doStop(connectorId)
322
+ return JSON.stringify({ status: 'stopped', connectorId })
323
+ }
324
+
325
+ const resolveSelectedConnector = () => {
43
326
  if (!running.length) {
44
- return `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}.`
327
+ const allConnectors = loadConnectors()
328
+ const configured = Object.values(allConnectors)
329
+ .filter((c) => !platform || c.platform === platform)
330
+ .map((c) => ({ id: c.id, name: c.name, platform: c.platform, agentId: c.agentId || null }))
331
+ if (configured.length) {
332
+ return {
333
+ error: `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}, but ${configured.length} configured connector(s) found: ${JSON.stringify(configured)}. These connectors exist but are not currently started. Ask the user if they'd like you to start one (use action "start" with the connectorId), then retry the send.`,
334
+ }
335
+ }
336
+ return {
337
+ error: `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}. No connectors are configured for this platform either — the user needs to set one up in the Connectors panel first.`,
338
+ }
45
339
  }
46
-
47
340
  const selected = connectorId
48
341
  ? running.find((c) => c.id === connectorId)
49
342
  : running[0]
50
- if (!selected) return `Error: running connector not found: ${connectorId}`
51
-
343
+ if (!selected) return { error: `Error: running connector not found: ${connectorId}` }
52
344
  const connectors = loadConnectors()
53
345
  const connector = connectors[selected.id]
54
- if (!connector) return `Error: connector not found: ${selected.id}`
346
+ if (!connector) return { error: `Error: connector not found: ${selected.id}` }
347
+ return { selected, connector }
348
+ }
349
+
350
+ if (actionName === 'send' || actionName === 'send_voice_note' || actionName === 'schedule_followup') {
351
+ const settings = loadSettings()
352
+ if (settings.safetyRequireApprovalForOutbound === true && approved !== true) {
353
+ return 'Error: outbound connector sends require explicit approval. Re-run with approved=true after user confirmation.'
354
+ }
355
+ const now = Date.now()
356
+ pruneOldConnectorToolState(now)
357
+ const resolved = resolveSelectedConnector()
358
+ if ('error' in resolved) return resolved.error
359
+ const { selected, connector } = resolved
360
+
361
+ const target = pickChannelTarget({
362
+ connector,
363
+ to,
364
+ recentChannelId: getConnectorRecentChannelId(selected.id),
365
+ })
366
+ if (target.error) return target.error
367
+
368
+ let channelId = target.channelId
369
+ if (connector.platform === 'whatsapp') channelId = normalizeWhatsAppTarget(channelId)
55
370
 
56
- let channelId = to?.trim() || ''
57
- if (!channelId) {
58
- const outbound = connector.config?.outboundJid?.trim()
59
- if (outbound) channelId = outbound
371
+ const currentSession = bctx.resolveCurrentSession()
372
+ const latestUserTurn = parseLatestUserTurn(currentSession)
373
+ const sessionId = bctx.ctx?.sessionId || currentSession?.id || 'unknown-session'
374
+ const turnKey = buildConnectorActionKey([sessionId, latestUserTurn.time || 'no-user-turn'])
375
+ const multiOutboundAllowed = userExplicitlyWantsMultipleOutbound(latestUserTurn.text)
376
+ const followupExplicitlyRequested = userExplicitlyRequestedFollowup(latestUserTurn.text)
377
+ const autonomousTurn = isAutonomousSystemTurn(latestUserTurn.text)
378
+ const existingBudget = connectorTurnSendBudget.get(turnKey)
379
+ if (
380
+ !multiOutboundAllowed
381
+ && existingBudget
382
+ && now - existingBudget.at <= CONNECTOR_TURN_SEND_TTL_MS
383
+ && existingBudget.count >= 1
384
+ ) {
385
+ if (existingBudget.lastResult) {
386
+ return normalizeDedupedReplayResult(existingBudget.lastResult, {
387
+ connectorId: selected.id,
388
+ platform: selected.platform,
389
+ to: channelId,
390
+ })
391
+ }
392
+ return JSON.stringify({
393
+ status: 'sent',
394
+ connectorId: selected.id,
395
+ platform: selected.platform,
396
+ to: channelId,
397
+ deduped: true,
398
+ })
60
399
  }
61
- if (!channelId) {
62
- const outbound = connector.config?.outboundTarget?.trim()
63
- if (outbound) channelId = outbound
400
+
401
+ if (actionName === 'send_voice_note') {
402
+ if (!voiceNoteToolEnabled) {
403
+ return 'Error: send_voice_note is unavailable. Enable ElevenLabs in Settings > Voice and set a valid API key.'
404
+ }
405
+ const ttsText = (voiceText || message || '').trim()
406
+ if (!ttsText) return 'Error: voiceText or message is required for send_voice_note action.'
407
+ const voiceActionKey = buildConnectorActionKey([
408
+ sessionId,
409
+ actionName,
410
+ selected.id,
411
+ channelId,
412
+ ttsText,
413
+ voiceId?.trim() || '',
414
+ fileName?.trim() || '',
415
+ caption?.trim() || '',
416
+ ptt ?? true,
417
+ ])
418
+ const cachedVoice = recentConnectorActionCache.get(voiceActionKey)
419
+ if (cachedVoice && now - cachedVoice.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
420
+ return cachedVoice.result
421
+ }
422
+ const audioBuffer = await synthesizeElevenLabsMp3({ text: ttsText, voiceId: voiceId?.trim() || undefined })
423
+ const voiceFileName = `${Date.now()}-${genId()}-voicenote.mp3`
424
+ const voicePath = path.join(UPLOAD_DIR, voiceFileName)
425
+ fs.writeFileSync(voicePath, audioBuffer)
426
+
427
+ const sent = await sendConnectorMessage({
428
+ connectorId: selected.id,
429
+ channelId,
430
+ text: '',
431
+ mediaPath: voicePath,
432
+ mimeType: 'audio/mpeg',
433
+ fileName: fileName?.trim() || 'voicenote.mp3',
434
+ caption: caption?.trim() || undefined,
435
+ ptt: ptt ?? true,
436
+ })
437
+ const result = JSON.stringify({
438
+ status: 'voice_sent',
439
+ connectorId: sent.connectorId,
440
+ platform: sent.platform,
441
+ to: sent.channelId,
442
+ messageId: sent.messageId || null,
443
+ voiceFile: voicePath,
444
+ })
445
+ connectorTurnSendBudget.set(turnKey, {
446
+ count: (existingBudget?.count || 0) + 1,
447
+ at: now,
448
+ lastResult: result,
449
+ })
450
+ recentConnectorActionCache.set(voiceActionKey, { at: now, result })
451
+ return result
64
452
  }
65
- if (!channelId) {
66
- const recentChannelId = getConnectorRecentChannelId(selected.id)
67
- if (recentChannelId) channelId = recentChannelId
453
+
454
+ const media = resolveConnectorMediaInput({
455
+ cwd: bctx.cwd,
456
+ mediaPath,
457
+ imageUrl,
458
+ fileUrl,
459
+ })
460
+ if (media.error) return media.error
461
+
462
+ const hasText = !!message?.trim()
463
+ const hasMedia = !!media.mediaPath || !!media.imageUrl || !!media.fileUrl
464
+ if (actionName === 'send' && !hasText && !hasMedia) {
465
+ return 'Error: message, media URL, or mediaPath is required for send action.'
68
466
  }
69
- if (!channelId) {
70
- const allowed = connector.config?.allowedJids?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
71
- if (allowed.length) channelId = allowed[0]
467
+
468
+ let followUpText = followUpMessage?.trim() || ''
469
+ const followDelaySec = Number.isFinite(followUpDelaySec) ? Number(followUpDelaySec) : 300
470
+
471
+ const proactivePayload = followUpText || message?.trim() || ''
472
+ const significantAutonomousOutreach = autonomousTurn && isSignificantOutreachText(proactivePayload)
473
+ const urgentAutonomousOutreach = autonomousTurn && isUrgentOutreachText(proactivePayload)
474
+ const outreachBudgetKey = buildConnectorActionKey([selected.id, channelId])
475
+ const priorAutonomousOutreach = autonomousOutreachBudget.get(outreachBudgetKey)
476
+ if (
477
+ autonomousTurn
478
+ && significantAutonomousOutreach
479
+ && priorAutonomousOutreach
480
+ && !urgentAutonomousOutreach
481
+ && now - priorAutonomousOutreach.at <= AUTONOMOUS_OUTREACH_COOLDOWN_MS
482
+ ) {
483
+ if (priorAutonomousOutreach.result) {
484
+ return normalizeDedupedReplayResult(priorAutonomousOutreach.result, {
485
+ connectorId: selected.id,
486
+ platform: selected.platform,
487
+ to: channelId,
488
+ })
489
+ }
490
+ return JSON.stringify({
491
+ status: 'sent',
492
+ connectorId: selected.id,
493
+ platform: selected.platform,
494
+ to: channelId,
495
+ deduped: true,
496
+ })
72
497
  }
73
- if (!channelId) {
74
- const allowed = connector.config?.allowFrom?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
75
- if (allowed.length) channelId = allowed[0]
498
+
499
+ if (followUpText && !followupExplicitlyRequested && !significantAutonomousOutreach) {
500
+ followUpText = ''
76
501
  }
77
- if (!channelId) {
78
- return `Error: no target recipient configured. Provide "to", or set connector config "outboundJid"/"allowedJids"/"outboundTarget"/"allowFrom".`
502
+
503
+ if (actionName === 'schedule_followup') {
504
+ if (!followupExplicitlyRequested && !significantAutonomousOutreach) {
505
+ return 'Error: schedule_followup requires either an explicit user request or a significant autonomous event.'
506
+ }
507
+ const payload = followUpText || message?.trim() || ''
508
+ if (!payload) return 'Error: followUpMessage or message is required for schedule_followup action.'
509
+ const scheduleActionKey = buildConnectorActionKey([
510
+ sessionId,
511
+ actionName,
512
+ selected.id,
513
+ channelId,
514
+ payload,
515
+ Number.isFinite(delaySec) ? Number(delaySec) : followDelaySec,
516
+ ])
517
+ const cachedSchedule = recentConnectorActionCache.get(scheduleActionKey)
518
+ if (cachedSchedule && now - cachedSchedule.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
519
+ return cachedSchedule.result
520
+ }
521
+ const scheduled = scheduleConnectorFollowUp({
522
+ connectorId: selected.id,
523
+ channelId,
524
+ text: payload,
525
+ delaySec: Number.isFinite(delaySec) ? Number(delaySec) : followDelaySec,
526
+ })
527
+ const result = JSON.stringify({
528
+ status: 'followup_scheduled',
529
+ connectorId: selected.id,
530
+ platform: selected.platform,
531
+ to: channelId,
532
+ followUpId: scheduled.followUpId,
533
+ sendAt: scheduled.sendAt,
534
+ })
535
+ connectorTurnSendBudget.set(turnKey, {
536
+ count: (existingBudget?.count || 0) + 1,
537
+ at: now,
538
+ lastResult: result,
539
+ })
540
+ if (autonomousTurn && significantAutonomousOutreach) {
541
+ autonomousOutreachBudget.set(outreachBudgetKey, { at: now, result })
542
+ }
543
+ recentConnectorActionCache.set(scheduleActionKey, { at: now, result })
544
+ return result
79
545
  }
80
- if (connector.platform === 'whatsapp') {
81
- channelId = normalizeWhatsAppTarget(channelId)
546
+
547
+ const sendActionKey = buildConnectorActionKey([
548
+ sessionId,
549
+ actionName,
550
+ selected.id,
551
+ channelId,
552
+ message?.trim() || '',
553
+ media.mediaPath || '',
554
+ media.imageUrl || '',
555
+ media.fileUrl || '',
556
+ mimeType?.trim() || '',
557
+ fileName?.trim() || '',
558
+ caption?.trim() || '',
559
+ ptt ?? '',
560
+ followUpText,
561
+ followDelaySec,
562
+ ])
563
+ const cachedSend = recentConnectorActionCache.get(sendActionKey)
564
+ if (cachedSend && now - cachedSend.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
565
+ return cachedSend.result
82
566
  }
83
567
 
84
568
  const sent = await sendConnectorMessage({
85
569
  connectorId: selected.id,
86
570
  channelId,
87
571
  text: message?.trim() || '',
88
- imageUrl: imageUrl?.trim() || undefined,
89
- fileUrl: fileUrl?.trim() || undefined,
90
- mediaPath: mediaPath?.trim() || undefined,
572
+ imageUrl: media.imageUrl,
573
+ fileUrl: media.fileUrl,
574
+ mediaPath: media.mediaPath,
91
575
  mimeType: mimeType?.trim() || undefined,
92
576
  fileName: fileName?.trim() || undefined,
93
577
  caption: caption?.trim() || undefined,
578
+ ptt: ptt ?? undefined,
94
579
  })
95
- return JSON.stringify({
580
+
581
+ let followup: { followUpId: string; sendAt: number } | null = null
582
+ if (followUpText) {
583
+ followup = scheduleConnectorFollowUp({
584
+ connectorId: selected.id,
585
+ channelId,
586
+ text: followUpText,
587
+ delaySec: followDelaySec,
588
+ })
589
+ }
590
+
591
+ const result = JSON.stringify({
96
592
  status: 'sent',
97
593
  connectorId: sent.connectorId,
98
594
  platform: sent.platform,
99
595
  to: sent.channelId,
100
596
  messageId: sent.messageId || null,
597
+ ...(followup
598
+ ? {
599
+ followUpId: followup.followUpId,
600
+ followUpSendAt: followup.sendAt,
601
+ }
602
+ : {}),
603
+ })
604
+ connectorTurnSendBudget.set(turnKey, {
605
+ count: (existingBudget?.count || 0) + 1,
606
+ at: now,
607
+ lastResult: result,
101
608
  })
609
+ if (autonomousTurn && significantAutonomousOutreach) {
610
+ autonomousOutreachBudget.set(outreachBudgetKey, { at: now, result })
611
+ }
612
+ recentConnectorActionCache.set(sendActionKey, { at: now, result })
613
+ return result
102
614
  }
103
615
 
104
- if (action === 'message_react' || action === 'message_edit' || action === 'message_pin' || action === 'message_delete') {
616
+ if (actionName === 'message_react' || actionName === 'message_edit' || actionName === 'message_pin' || actionName === 'message_delete') {
105
617
  if (!connectorId) return 'Error: connectorId is required for rich messaging actions.'
106
618
  const { getRunningInstance } = await import('../connectors/manager')
107
619
  const inst = getRunningInstance(connectorId)
@@ -112,25 +624,25 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
112
624
  if (!targetMessageId) return 'Error: message parameter (used as messageId) is required for rich messaging actions.'
113
625
 
114
626
  try {
115
- if (action === 'message_react') {
627
+ if (actionName === 'message_react') {
116
628
  if (!inst.sendReaction) return 'Error: this connector does not support reactions.'
117
629
  const emoji = caption?.trim() || '👍'
118
630
  await inst.sendReaction(targetChannel, targetMessageId, emoji)
119
631
  return JSON.stringify({ status: 'reacted', connectorId, messageId: targetMessageId, emoji })
120
632
  }
121
- if (action === 'message_edit') {
633
+ if (actionName === 'message_edit') {
122
634
  if (!inst.editMessage) return 'Error: this connector does not support message editing.'
123
635
  const newText = caption?.trim() || ''
124
636
  if (!newText) return 'Error: caption (new text) is required for message_edit.'
125
637
  await inst.editMessage(targetChannel, targetMessageId, newText)
126
638
  return JSON.stringify({ status: 'edited', connectorId, messageId: targetMessageId })
127
639
  }
128
- if (action === 'message_delete') {
640
+ if (actionName === 'message_delete') {
129
641
  if (!inst.deleteMessage) return 'Error: this connector does not support message deletion.'
130
642
  await inst.deleteMessage(targetChannel, targetMessageId)
131
643
  return JSON.stringify({ status: 'deleted', connectorId, messageId: targetMessageId })
132
644
  }
133
- if (action === 'message_pin') {
645
+ if (actionName === 'message_pin') {
134
646
  if (!inst.pinMessage) return 'Error: this connector does not support message pinning.'
135
647
  await inst.pinMessage(targetChannel, targetMessageId)
136
648
  return JSON.stringify({ status: 'pinned', connectorId, messageId: targetMessageId })
@@ -140,26 +652,34 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
140
652
  }
141
653
  }
142
654
 
143
- return 'Unknown action. Use list_running, list_targets, or send.'
655
+ return 'Unknown action. Use list_running, list_targets, start, stop, send, send_voice_note, schedule_followup, or message_* actions.'
144
656
  } catch (err: unknown) {
145
657
  return `Error: ${err instanceof Error ? err.message : String(err)}`
146
658
  }
147
659
  },
148
660
  {
149
661
  name: 'connector_message_tool',
150
- description: 'Send proactive outbound messages and perform rich messaging actions through running connectors. Supports listing running connectors/targets, sending text/media, and rich messaging (react, edit, delete, pin). For rich actions: connectorId + message (as messageId) required; caption carries emoji for react or new text for edit.',
662
+ description: voiceNoteToolEnabled
663
+ ? 'Manage and send messages through chat platform connectors (WhatsApp, Telegram, Slack, Discord, etc.). Use "start"/"stop" to manage connector lifecycle, "list_running"/"list_targets" to discover available connectors and recipients, "send" to deliver text/media, "send_voice_note" to synthesize and send audio via ElevenLabs, "schedule_followup" for delayed check-ins, and rich actions (react, edit, delete, pin) for message management. When a send fails because no connector is running, check if one is configured and offer to start it. When no target is set, list available configured numbers and ask the user which to send to.'
664
+ : 'Manage and send messages through chat platform connectors (WhatsApp, Telegram, Slack, Discord, etc.). Use "start"/"stop" to manage connector lifecycle, "list_running"/"list_targets" to discover available connectors and recipients, "send" to deliver text/media, "schedule_followup" for delayed check-ins, and rich actions (react, edit, delete, pin) for message management. Voice-note sending appears only when ElevenLabs is enabled with an API key in Settings > Voice. When a send fails because no connector is running, check if one is configured and offer to start it. When no target is set, list available configured numbers and ask the user which to send to.',
151
665
  schema: z.object({
152
- action: z.enum(['list_running', 'list_targets', 'send', 'message_react', 'message_edit', 'message_delete', 'message_pin']).describe('connector messaging action'),
666
+ action: actionSchema.describe('connector messaging action'),
153
667
  connectorId: z.string().optional().describe('Optional connector id. Defaults to the first running connector (or first for selected platform).'),
154
668
  platform: z.string().optional().describe('Optional platform filter (whatsapp, telegram, slack, discord, bluebubbles, etc.).'),
155
669
  to: z.string().optional().describe('Target channel id / recipient. For WhatsApp, phone number or full JID.'),
156
- message: z.string().optional().describe('Message text to send (required for send action).'),
670
+ message: z.string().optional().describe('Message text to send. Required for send unless media is provided. Used as fallback for send_voice_note/schedule_followup when voiceText/followUpMessage are omitted.'),
671
+ voiceText: z.string().optional().describe('Text to synthesize for send_voice_note. Uses message when omitted.'),
672
+ voiceId: z.string().optional().describe('Optional ElevenLabs voice override for send_voice_note.'),
157
673
  imageUrl: z.string().optional().describe('Optional public image URL to attach/send where platform supports media.'),
158
674
  fileUrl: z.string().optional().describe('Optional public file URL to attach/send where platform supports documents.'),
159
675
  mediaPath: z.string().optional().describe('Absolute local file path to send (e.g. a screenshot). Auto-detects mime type from extension. Takes priority over imageUrl/fileUrl.'),
160
676
  mimeType: z.string().optional().describe('Optional MIME type for mediaPath or fileUrl.'),
161
677
  fileName: z.string().optional().describe('Optional display file name for mediaPath or fileUrl.'),
162
678
  caption: z.string().optional().describe('Optional caption used with image/file sends.'),
679
+ delaySec: z.number().optional().describe('Delay in seconds for schedule_followup.'),
680
+ followUpMessage: z.string().optional().describe('Optional delayed follow-up text (for send) or primary text for schedule_followup.'),
681
+ followUpDelaySec: z.number().optional().describe('Delay in seconds for followUpMessage when action=send. Default 300 seconds.'),
682
+ ptt: z.boolean().optional().describe('Send audio as a WhatsApp voice note (push-to-talk). Defaults to true for audio files.'),
163
683
  approved: z.boolean().optional().describe('Set true to explicitly confirm outbound send when safetyRequireApprovalForOutbound is enabled.'),
164
684
  }),
165
685
  },