@swarmclawai/swarmclaw 0.6.4 → 0.6.7

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 (143) hide show
  1. package/README.md +62 -30
  2. package/package.json +10 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +34 -2
  8. package/src/app/api/chatrooms/route.ts +26 -3
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  14. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  15. package/src/app/api/sessions/route.ts +11 -2
  16. package/src/app/api/tasks/[id]/route.ts +18 -13
  17. package/src/app/api/tasks/route.ts +44 -1
  18. package/src/app/api/usage/route.ts +16 -7
  19. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  20. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  21. package/src/app/api/wallets/[id]/route.ts +118 -0
  22. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  23. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  24. package/src/app/api/wallets/route.ts +74 -0
  25. package/src/app/globals.css +8 -0
  26. package/src/cli/index.js +20 -0
  27. package/src/cli/index.ts +223 -39
  28. package/src/cli/spec.js +14 -0
  29. package/src/components/agents/agent-avatar.tsx +15 -1
  30. package/src/components/agents/agent-card.tsx +38 -6
  31. package/src/components/agents/agent-chat-list.tsx +79 -3
  32. package/src/components/agents/agent-sheet.tsx +191 -26
  33. package/src/components/auth/setup-wizard.tsx +268 -353
  34. package/src/components/chat/chat-area.tsx +24 -9
  35. package/src/components/chat/chat-header.tsx +48 -19
  36. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  37. package/src/components/chat/delegation-banner.test.ts +27 -0
  38. package/src/components/chat/delegation-banner.tsx +109 -23
  39. package/src/components/chat/message-bubble.tsx +17 -16
  40. package/src/components/chat/message-list.tsx +6 -5
  41. package/src/components/chat/streaming-bubble.tsx +3 -2
  42. package/src/components/chat/thinking-indicator.tsx +3 -2
  43. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  44. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  45. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  46. package/src/components/chatrooms/chatroom-message.tsx +165 -23
  47. package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
  48. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  49. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  50. package/src/components/connectors/connector-health.tsx +120 -0
  51. package/src/components/connectors/connector-list.tsx +1 -1
  52. package/src/components/connectors/connector-sheet.tsx +9 -0
  53. package/src/components/home/home-view.tsx +25 -3
  54. package/src/components/input/chat-input.tsx +8 -1
  55. package/src/components/knowledge/knowledge-list.tsx +1 -1
  56. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  57. package/src/components/layout/app-layout.tsx +35 -4
  58. package/src/components/memory/memory-agent-list.tsx +1 -1
  59. package/src/components/memory/memory-browser.tsx +1 -0
  60. package/src/components/memory/memory-card.tsx +3 -2
  61. package/src/components/memory/memory-detail.tsx +3 -3
  62. package/src/components/memory/memory-sheet.tsx +2 -2
  63. package/src/components/projects/project-detail.tsx +4 -4
  64. package/src/components/schedules/schedule-list.tsx +55 -9
  65. package/src/components/schedules/schedule-sheet.tsx +134 -23
  66. package/src/components/secrets/secret-sheet.tsx +1 -1
  67. package/src/components/secrets/secrets-list.tsx +1 -1
  68. package/src/components/sessions/session-card.tsx +1 -1
  69. package/src/components/shared/agent-picker-list.tsx +1 -1
  70. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  71. package/src/components/shared/command-palette.tsx +237 -0
  72. package/src/components/shared/connector-platform-icon.tsx +1 -0
  73. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  74. package/src/components/skills/skill-list.tsx +1 -1
  75. package/src/components/skills/skill-sheet.tsx +1 -1
  76. package/src/components/tasks/task-board.tsx +3 -3
  77. package/src/components/tasks/task-card.tsx +22 -2
  78. package/src/components/tasks/task-sheet.tsx +112 -17
  79. package/src/components/usage/metrics-dashboard.tsx +13 -25
  80. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  81. package/src/components/wallets/wallet-panel.tsx +616 -0
  82. package/src/components/wallets/wallet-section.tsx +100 -0
  83. package/src/hooks/use-swipe.ts +49 -0
  84. package/src/lib/providers/anthropic.ts +16 -2
  85. package/src/lib/providers/claude-cli.ts +7 -1
  86. package/src/lib/providers/index.ts +7 -0
  87. package/src/lib/providers/ollama.ts +16 -2
  88. package/src/lib/providers/openai.ts +7 -2
  89. package/src/lib/providers/openclaw.ts +6 -1
  90. package/src/lib/providers/provider-defaults.ts +7 -0
  91. package/src/lib/schedule-templates.ts +115 -0
  92. package/src/lib/server/agent-registry.ts +2 -2
  93. package/src/lib/server/alert-dispatch.ts +64 -0
  94. package/src/lib/server/chat-execution.ts +76 -4
  95. package/src/lib/server/chatroom-health.ts +60 -0
  96. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  97. package/src/lib/server/chatroom-helpers.ts +86 -12
  98. package/src/lib/server/chatroom-routing.ts +65 -0
  99. package/src/lib/server/connectors/discord.ts +3 -0
  100. package/src/lib/server/connectors/email.ts +267 -0
  101. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  102. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  103. package/src/lib/server/connectors/manager.ts +239 -5
  104. package/src/lib/server/connectors/openclaw.ts +3 -0
  105. package/src/lib/server/connectors/slack.ts +6 -0
  106. package/src/lib/server/connectors/telegram.ts +18 -0
  107. package/src/lib/server/connectors/types.ts +2 -0
  108. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  109. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  110. package/src/lib/server/connectors/whatsapp.ts +17 -5
  111. package/src/lib/server/cost.ts +70 -0
  112. package/src/lib/server/create-notification.ts +2 -0
  113. package/src/lib/server/daemon-state.ts +124 -0
  114. package/src/lib/server/dag-validation.ts +115 -0
  115. package/src/lib/server/memory-db.ts +12 -7
  116. package/src/lib/server/openclaw-doctor.ts +48 -0
  117. package/src/lib/server/orchestrator-lg.ts +12 -2
  118. package/src/lib/server/orchestrator.ts +6 -1
  119. package/src/lib/server/queue-followups.test.ts +224 -0
  120. package/src/lib/server/queue.ts +238 -24
  121. package/src/lib/server/scheduler.ts +3 -0
  122. package/src/lib/server/session-run-manager.ts +22 -1
  123. package/src/lib/server/session-tools/chatroom.ts +11 -2
  124. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  125. package/src/lib/server/session-tools/index.ts +8 -2
  126. package/src/lib/server/session-tools/memory.ts +23 -4
  127. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  128. package/src/lib/server/session-tools/shell.ts +1 -1
  129. package/src/lib/server/session-tools/wallet.ts +124 -0
  130. package/src/lib/server/session-tools/web.ts +2 -2
  131. package/src/lib/server/solana.ts +122 -0
  132. package/src/lib/server/storage.ts +158 -6
  133. package/src/lib/server/stream-agent-chat.ts +126 -63
  134. package/src/lib/server/task-mention.test.ts +41 -0
  135. package/src/lib/server/task-mention.ts +3 -2
  136. package/src/lib/setup-defaults.ts +277 -0
  137. package/src/lib/tool-definitions.ts +1 -0
  138. package/src/lib/validation/schemas.ts +69 -0
  139. package/src/lib/view-routes.ts +1 -0
  140. package/src/stores/use-app-store.ts +15 -3
  141. package/src/stores/use-chatroom-store.ts +52 -2
  142. package/src/types/index.ts +98 -2
  143. package/tsconfig.json +2 -1
@@ -0,0 +1,60 @@
1
+ import { getProvider } from '@/lib/providers'
2
+ import type { Agent } from '@/types'
3
+ import { resolveApiKey } from './chatroom-helpers'
4
+ import { isProviderCoolingDown } from './provider-health'
5
+
6
+ export interface ChatroomAgentHealthSkip {
7
+ agentId: string
8
+ reason: string
9
+ }
10
+
11
+ export interface ChatroomAgentHealthResult {
12
+ healthyAgentIds: string[]
13
+ skipped: ChatroomAgentHealthSkip[]
14
+ }
15
+
16
+ /**
17
+ * Filter chatroom participants to agents that are currently executable.
18
+ * This should never enforce model diversity rules; it only gates hard runtime blockers.
19
+ */
20
+ export function filterHealthyChatroomAgents(
21
+ agentIds: string[],
22
+ agents: Record<string, Agent>,
23
+ ): ChatroomAgentHealthResult {
24
+ const healthyAgentIds: string[] = []
25
+ const skipped: ChatroomAgentHealthSkip[] = []
26
+
27
+ for (const agentId of agentIds) {
28
+ const agent = agents[agentId]
29
+ if (!agent) {
30
+ skipped.push({ agentId, reason: 'agent_not_found' })
31
+ continue
32
+ }
33
+
34
+ if (isProviderCoolingDown(agent.provider)) {
35
+ skipped.push({ agentId, reason: `provider_cooling_down:${agent.provider}` })
36
+ continue
37
+ }
38
+
39
+ const providerInfo = getProvider(agent.provider)
40
+ if (!providerInfo) {
41
+ skipped.push({ agentId, reason: `provider_not_configured:${agent.provider}` })
42
+ continue
43
+ }
44
+
45
+ const apiKey = resolveApiKey(agent.credentialId)
46
+ if (providerInfo.requiresApiKey && !apiKey) {
47
+ skipped.push({ agentId, reason: 'missing_api_credentials' })
48
+ continue
49
+ }
50
+ if (providerInfo.requiresEndpoint && !agent.apiEndpoint) {
51
+ skipped.push({ agentId, reason: 'missing_api_endpoint' })
52
+ continue
53
+ }
54
+
55
+ healthyAgentIds.push(agentId)
56
+ }
57
+
58
+ return { healthyAgentIds, skipped }
59
+ }
60
+
@@ -0,0 +1,94 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { Agent, Chatroom } from '@/types'
4
+ import { parseMentions, compactChatroomMessages, buildHistoryForAgent } from './chatroom-helpers'
5
+
6
+ function makeAgents(): Record<string, Agent> {
7
+ const now = Date.now()
8
+ return {
9
+ default: {
10
+ id: 'default',
11
+ name: 'Assistant',
12
+ description: '',
13
+ systemPrompt: '',
14
+ provider: 'openai',
15
+ model: 'gpt-4o',
16
+ createdAt: now,
17
+ updatedAt: now,
18
+ },
19
+ agent_analyst: {
20
+ id: 'agent_analyst',
21
+ name: 'Analyst',
22
+ description: '',
23
+ systemPrompt: '',
24
+ provider: 'openai',
25
+ model: 'gpt-4o',
26
+ createdAt: now,
27
+ updatedAt: now,
28
+ },
29
+ }
30
+ }
31
+
32
+ describe('chatroom-helpers', () => {
33
+ it('parses mentions with punctuation and agent ids', () => {
34
+ const agents = makeAgents()
35
+ const memberIds = ['default', 'agent_analyst']
36
+ const mentions = parseMentions('Hey @Assistant, can @agent_analyst review this?', agents, memberIds)
37
+ assert.deepEqual(mentions, ['default', 'agent_analyst'])
38
+ })
39
+
40
+ it('compacts long chatrooms with a persisted summary message', () => {
41
+ const now = Date.now()
42
+ const chatroom: Chatroom = {
43
+ id: 'room-1',
44
+ name: 'Room',
45
+ description: '',
46
+ agentIds: ['default'],
47
+ messages: Array.from({ length: 120 }, (_, idx) => ({
48
+ id: `m-${idx}`,
49
+ senderId: idx % 2 === 0 ? 'user' : 'default',
50
+ senderName: idx % 2 === 0 ? 'You' : 'Assistant',
51
+ role: idx % 2 === 0 ? 'user' : 'assistant',
52
+ text: `message ${idx}`,
53
+ mentions: [],
54
+ reactions: [],
55
+ time: now + idx,
56
+ })),
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ }
60
+
61
+ const changed = compactChatroomMessages(chatroom, 90)
62
+ assert.equal(changed, true)
63
+ assert.equal(chatroom.messages.length, 91)
64
+ assert.equal(chatroom.messages[0].senderId, 'system')
65
+ assert.match(chatroom.messages[0].text, /^\[Conversation summary\]/)
66
+ })
67
+
68
+ it('only includes attachment-heavy context for recent history entries', () => {
69
+ const now = Date.now()
70
+ const chatroom: Chatroom = {
71
+ id: 'room-2',
72
+ name: 'Room',
73
+ agentIds: ['default'],
74
+ messages: Array.from({ length: 24 }, (_, idx) => ({
75
+ id: `x-${idx}`,
76
+ senderId: idx % 2 === 0 ? 'user' : 'default',
77
+ senderName: idx % 2 === 0 ? 'You' : 'Assistant',
78
+ role: idx % 2 === 0 ? 'user' : 'assistant',
79
+ text: `line ${idx}`,
80
+ mentions: [],
81
+ reactions: [],
82
+ time: now + idx,
83
+ ...(idx < 10 ? { attachedFiles: [`/tmp/file-${idx}.txt`] } : {}),
84
+ })),
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ }
88
+
89
+ const history = buildHistoryForAgent(chatroom, 'default')
90
+ const attachmentMarkers = history.filter((msg) => msg.text.includes('[Attached:')).length
91
+ assert.ok(attachmentMarkers <= 6)
92
+ })
93
+ })
94
+
@@ -1,6 +1,7 @@
1
1
  import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
2
2
  import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
3
- import type { Chatroom, Agent, Session, Message } from '@/types'
3
+ import { genId } from '@/lib/id'
4
+ import type { Chatroom, ChatroomMember, Agent, Session, Message } from '@/types'
4
5
 
5
6
  /** Resolve API key from an agent's credentialId */
6
7
  export function resolveApiKey(credentialId: string | null | undefined): string | null {
@@ -11,17 +12,57 @@ export function resolveApiKey(credentialId: string | null | undefined): string |
11
12
  try { return decryptKey(cred.encryptedKey) } catch { return null }
12
13
  }
13
14
 
15
+ /** Derive chatroom members from the `members` array if present, otherwise fallback to `agentIds` with default 'member' role. */
16
+ export function getMembers(chatroom: Chatroom): ChatroomMember[] {
17
+ if (chatroom.members?.length) return chatroom.members
18
+ return chatroom.agentIds.map((agentId) => ({ agentId, role: 'member' as const }))
19
+ }
20
+
21
+ /** Return the role of an agent in a chatroom, defaulting to 'member'. */
22
+ export function getMemberRole(chatroom: Chatroom, agentId: string): string {
23
+ const members = getMembers(chatroom)
24
+ const member = members.find((m) => m.agentId === agentId)
25
+ return member?.role || 'member'
26
+ }
27
+
28
+ /** Check if an agent is currently muted in the chatroom. */
29
+ export function isMuted(chatroom: Chatroom, agentId: string): boolean {
30
+ const members = getMembers(chatroom)
31
+ const member = members.find((m) => m.agentId === agentId)
32
+ if (!member?.mutedUntil) return false
33
+ return new Date(member.mutedUntil).getTime() > Date.now()
34
+ }
35
+
36
+ const COMPACTION_PREFIX = '[Conversation summary]'
37
+
38
+ function normalizeMentionToken(raw: string): string {
39
+ return raw
40
+ .toLowerCase()
41
+ .replace(/[.,!?;:]+$/g, '')
42
+ .replace(/\s+/g, '')
43
+ .trim()
44
+ }
45
+
46
+ function truncateText(text: string, max: number): string {
47
+ const compact = String(text || '').replace(/\s+/g, ' ').trim()
48
+ if (compact.length <= max) return compact
49
+ return `${compact.slice(0, Math.max(0, max - 3))}...`
50
+ }
51
+
14
52
  /** Parse @mentions from message text, returns matching agentIds */
15
53
  export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
16
54
  if (/@all\b/i.test(text)) return [...memberIds]
17
- const mentionPattern = /@(\S+)/g
55
+ const mentionPattern = /(?:^|[\s(])@([a-zA-Z0-9._-]+)/g
18
56
  const mentioned: string[] = []
19
57
  let match: RegExpExecArray | null
20
58
  while ((match = mentionPattern.exec(text)) !== null) {
21
- const name = match[1].toLowerCase()
59
+ const token = normalizeMentionToken(match[1] || '')
60
+ if (!token) continue
22
61
  for (const id of memberIds) {
23
62
  const agent = agents[id]
24
- if (agent && agent.name.toLowerCase().replace(/\s+/g, '') === name) {
63
+ const normalizedName = normalizeMentionToken(agent?.name || '')
64
+ const normalizedId = normalizeMentionToken(id)
65
+ if (agent && (normalizedName === token || normalizedId === token)) {
25
66
  if (!mentioned.includes(id)) mentioned.push(id)
26
67
  }
27
68
  }
@@ -29,6 +70,34 @@ export function parseMentions(text: string, agents: Record<string, Agent>, membe
29
70
  return mentioned
30
71
  }
31
72
 
73
+ /**
74
+ * Persisted chatroom compaction so long-lived rooms stay inside context budgets.
75
+ * Returns true when the message list was compacted.
76
+ */
77
+ export function compactChatroomMessages(chatroom: Chatroom, keepLast = 90): boolean {
78
+ const maxKeep = Math.max(20, keepLast)
79
+ if (!Array.isArray(chatroom.messages) || chatroom.messages.length <= maxKeep) return false
80
+
81
+ const dropped = chatroom.messages.length - maxKeep
82
+ const kept = chatroom.messages.slice(-maxKeep).filter((msg, idx) => {
83
+ if (idx !== 0) return true
84
+ return !(msg.senderId === 'system' && typeof msg.text === 'string' && msg.text.startsWith(COMPACTION_PREFIX))
85
+ })
86
+ const summaryMessage = {
87
+ id: genId(),
88
+ senderId: 'system',
89
+ senderName: 'System',
90
+ role: 'assistant' as const,
91
+ text: `${COMPACTION_PREFIX} ${dropped} earlier chat message(s) were condensed to keep the room responsive.`,
92
+ mentions: [],
93
+ reactions: [],
94
+ time: Date.now(),
95
+ }
96
+ chatroom.messages = [summaryMessage, ...kept]
97
+ chatroom.updatedAt = Date.now()
98
+ return true
99
+ }
100
+
32
101
  /** Build chatroom context as a system prompt addendum with agent profiles and collaboration guidelines */
33
102
  export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<string, Agent>, agentId: string): string {
34
103
  const selfAgent = agents[agentId]
@@ -47,9 +116,10 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
47
116
  .filter(Boolean)
48
117
  .join('\n')
49
118
 
50
- const recentMessages = chatroom.messages.slice(-30).map((m) => {
51
- return `[${m.senderName}]: ${m.text}`
52
- }).join('\n')
119
+ const recentMessages = chatroom.messages
120
+ .slice(-8)
121
+ .map((m) => `[${m.senderName}]: ${truncateText(m.text, 180)}`)
122
+ .join('\n')
53
123
 
54
124
  const memberCount = chatroom.agentIds.length
55
125
  const otherNames = chatroom.agentIds
@@ -70,6 +140,8 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
70
140
  '- **You are in a group chat.** Talk like you are in a real-time conversation with teammates — be direct, casual, and concise.',
71
141
  '- **Be yourself.** Respond with personality. Don\'t give generic "let me know if you need anything" responses. Actually engage with what was said.',
72
142
  '- **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.',
143
+ '- **Do not meta-narrate user intent.** Avoid phrases like "it seems like you\'re trying to..." — respond directly to what they said.',
144
+ '- **Handle greetings like a human.** For "hello", "how are you", or light check-ins, give a normal conversational reply instead of tool/process commentary.',
73
145
  '- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
74
146
  '- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
75
147
  '- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
@@ -121,10 +193,12 @@ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
121
193
 
122
194
  /** Convert chatroom messages to Message history format for LLM */
123
195
  export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imagePath?: string, attachedFiles?: string[]): Message[] {
124
- const history = chatroom.messages.slice(-50).map((m) => {
196
+ const recentMessages = chatroom.messages.slice(-24)
197
+ const includeAttachmentsFrom = Math.max(0, recentMessages.length - 6)
198
+ const history = recentMessages.map((m, idx) => {
125
199
  let msgText = `[${m.senderName}]: ${m.text}`
126
- // Include attachment info in history
127
- if (m.attachedFiles?.length) {
200
+ const includeAttachments = idx >= includeAttachmentsFrom
201
+ if (includeAttachments && m.attachedFiles?.length) {
128
202
  const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
129
203
  msgText += `\n[Attached: ${names}]`
130
204
  }
@@ -132,8 +206,8 @@ export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imageP
132
206
  role: m.senderId === agentId ? 'assistant' as const : 'user' as const,
133
207
  text: msgText,
134
208
  time: m.time,
135
- ...(m.imagePath ? { imagePath: m.imagePath } : {}),
136
- ...(m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
209
+ ...(includeAttachments && m.imagePath ? { imagePath: m.imagePath } : {}),
210
+ ...(includeAttachments && m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
137
211
  }
138
212
  })
139
213
  // Pass through imagePath/attachedFiles from the current message to the last history entry
@@ -0,0 +1,65 @@
1
+ import type { ChatroomRoutingRule, Agent } from '@/types'
2
+
3
+ /**
4
+ * Evaluate routing rules against inbound message text.
5
+ *
6
+ * Rules are evaluated in priority order (lower number = higher priority).
7
+ * First match wins — returns the matched agentIds.
8
+ *
9
+ * - 'keyword' rules: case-insensitive substring match against `keywords[]`,
10
+ * or regex match against `pattern`.
11
+ * - 'capability' rules: match `pattern` against each agent's `capabilities[]`.
12
+ */
13
+ export function evaluateRoutingRules(
14
+ text: string,
15
+ rules: ChatroomRoutingRule[],
16
+ agents: Agent[],
17
+ ): string[] {
18
+ if (!rules.length) return []
19
+
20
+ const sorted = [...rules].sort((a, b) => a.priority - b.priority)
21
+ const lowerText = text.toLowerCase()
22
+
23
+ for (const rule of sorted) {
24
+ if (rule.type === 'keyword') {
25
+ let matched = false
26
+
27
+ // Check keywords (case-insensitive substring)
28
+ if (rule.keywords?.length) {
29
+ matched = rule.keywords.some((kw) => lowerText.includes(kw.toLowerCase()))
30
+ }
31
+
32
+ // Check pattern (regex)
33
+ if (!matched && rule.pattern) {
34
+ try {
35
+ const re = new RegExp(rule.pattern, 'i')
36
+ matched = re.test(text)
37
+ } catch {
38
+ // Invalid regex — skip
39
+ }
40
+ }
41
+
42
+ if (matched) return [rule.agentId]
43
+ }
44
+
45
+ if (rule.type === 'capability') {
46
+ if (!rule.pattern) continue
47
+ const patternLower = rule.pattern.toLowerCase()
48
+
49
+ // Check if the specific agent has a matching capability
50
+ const agent = agents.find((a) => a.id === rule.agentId)
51
+ if (agent?.capabilities?.some((cap) => cap.toLowerCase().includes(patternLower))) {
52
+ // Only match if the message text is relevant to the capability
53
+ // Use the pattern as a keyword match against the message text too
54
+ try {
55
+ const re = new RegExp(rule.pattern, 'i')
56
+ if (re.test(text)) return [rule.agentId]
57
+ } catch {
58
+ if (lowerText.includes(patternLower)) return [rule.agentId]
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ return []
65
+ }
@@ -105,6 +105,9 @@ const discord: PlatformConnector = {
105
105
 
106
106
  return {
107
107
  connector,
108
+ isAlive() {
109
+ return client.isReady()
110
+ },
108
111
  async sendMessage(channelId, text, options) {
109
112
  const channel = await client.channels.fetch(channelId)
110
113
  if (!channel || !('send' in channel) || typeof (channel as any).send !== 'function') {
@@ -0,0 +1,267 @@
1
+ import { ImapFlow } from 'imapflow'
2
+ import { createTransport, type Transporter } from 'nodemailer'
3
+ import { simpleParser } from 'mailparser'
4
+ import type { Connector } from '@/types'
5
+ import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
6
+ import { isNoMessage } from './manager'
7
+
8
+ interface EmailConfig {
9
+ imapHost: string
10
+ imapPort?: number
11
+ smtpHost: string
12
+ smtpPort?: number
13
+ user: string
14
+ password: string
15
+ folder?: string
16
+ pollIntervalSec?: number
17
+ subjectPrefix?: string
18
+ }
19
+
20
+ function getConfig(connector: Connector): EmailConfig {
21
+ const c = connector.config as Record<string, unknown>
22
+ return {
23
+ imapHost: String(c.imapHost ?? ''),
24
+ imapPort: Number(c.imapPort ?? 993),
25
+ smtpHost: String(c.smtpHost ?? ''),
26
+ smtpPort: Number(c.smtpPort ?? 587),
27
+ user: String(c.user ?? ''),
28
+ password: String(c.password ?? ''),
29
+ folder: String(c.folder ?? 'INBOX'),
30
+ pollIntervalSec: Number(c.pollIntervalSec ?? 60),
31
+ subjectPrefix: c.subjectPrefix ? String(c.subjectPrefix) : undefined,
32
+ }
33
+ }
34
+
35
+ const email: PlatformConnector = {
36
+ async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
37
+ const config = getConfig(connector)
38
+
39
+ if (!config.imapHost || !config.smtpHost || !config.user || !config.password) {
40
+ throw new Error('Email connector requires imapHost, smtpHost, user, and password')
41
+ }
42
+
43
+ const folder = config.folder || 'INBOX'
44
+ const pollMs = (config.pollIntervalSec || 60) * 1000
45
+
46
+ // IMAP client for inbound
47
+ const imap = new ImapFlow({
48
+ host: config.imapHost,
49
+ port: config.imapPort || 993,
50
+ secure: (config.imapPort || 993) === 993,
51
+ auth: {
52
+ user: config.user,
53
+ pass: config.password,
54
+ },
55
+ logger: false,
56
+ })
57
+
58
+ // SMTP transport for outbound
59
+ const smtp: Transporter = createTransport({
60
+ host: config.smtpHost,
61
+ port: config.smtpPort || 587,
62
+ secure: (config.smtpPort || 587) === 465,
63
+ auth: {
64
+ user: config.user,
65
+ pass: config.password,
66
+ },
67
+ })
68
+
69
+ // Track last seen UID to only process new messages
70
+ let highwaterUid = 0
71
+ let connected = false
72
+ let pollTimer: ReturnType<typeof setInterval> | null = null
73
+
74
+ // Map to track original sender per channelId (email address) for replies
75
+ const senderMap = new Map<string, { address: string; subject: string; messageId?: string }>()
76
+
77
+ async function connectImap(): Promise<void> {
78
+ try {
79
+ await imap.connect()
80
+ connected = true
81
+ console.log(`[email] IMAP connected to ${config.imapHost}`)
82
+
83
+ // Get the current highest UID as highwater mark (don't process old messages)
84
+ const lock = await imap.getMailboxLock(folder)
85
+ try {
86
+ const status = await imap.status(folder, { uidNext: true })
87
+ // uidNext is the next UID that will be assigned; current highest is uidNext - 1
88
+ highwaterUid = typeof status.uidNext === 'number' ? status.uidNext - 1 : 0
89
+ console.log(`[email] Initial highwater UID: ${highwaterUid} in ${folder}`)
90
+ } finally {
91
+ lock.release()
92
+ }
93
+ } catch (err: unknown) {
94
+ connected = false
95
+ const msg = err instanceof Error ? err.message : String(err)
96
+ console.error(`[email] IMAP connection failed: ${msg}`)
97
+ throw err
98
+ }
99
+ }
100
+
101
+ async function pollForNewMessages(): Promise<void> {
102
+ if (!connected) return
103
+
104
+ let lock
105
+ try {
106
+ lock = await imap.getMailboxLock(folder)
107
+ } catch (err: unknown) {
108
+ const msg = err instanceof Error ? err.message : String(err)
109
+ console.error(`[email] Failed to acquire mailbox lock: ${msg}`)
110
+ connected = false
111
+ return
112
+ }
113
+
114
+ try {
115
+ // Fetch messages with UID > highwaterUid
116
+ const range = `${highwaterUid + 1}:*`
117
+ const messages = []
118
+
119
+ for await (const msg of imap.fetch(range, { envelope: true, source: true, uid: true }, { uid: true })) {
120
+ if (msg.uid <= highwaterUid) continue
121
+ messages.push(msg)
122
+ }
123
+
124
+ for (const msg of messages) {
125
+ try {
126
+ await processMessage(msg)
127
+ } catch (err: unknown) {
128
+ const errMsg = err instanceof Error ? err.message : String(err)
129
+ console.error(`[email] Error processing message UID ${msg.uid}: ${errMsg}`)
130
+ }
131
+ if (msg.uid > highwaterUid) {
132
+ highwaterUid = msg.uid
133
+ }
134
+ }
135
+ } catch (err: unknown) {
136
+ const errMsg = err instanceof Error ? err.message : String(err)
137
+ // A fetch on an empty range can throw; that's normal
138
+ if (!errMsg.includes('Nothing to fetch')) {
139
+ console.error(`[email] Poll error: ${errMsg}`)
140
+ }
141
+ } finally {
142
+ lock.release()
143
+ }
144
+ }
145
+
146
+ async function processMessage(msg: { uid: number; envelope?: { from?: Array<{ name?: string; address?: string }>; subject?: string; messageId?: string }; source?: Buffer }): Promise<void> {
147
+ const envelope = msg.envelope
148
+ if (!envelope) return
149
+
150
+ const fromAddr = envelope.from?.[0]?.address || 'unknown'
151
+ const fromName = envelope.from?.[0]?.name || fromAddr
152
+ const subject = envelope.subject || '(no subject)'
153
+
154
+ // Filter by subject prefix if configured
155
+ if (config.subjectPrefix && !subject.startsWith(config.subjectPrefix)) {
156
+ console.log(`[email] Skipping message from ${fromAddr} — subject "${subject}" doesn't match prefix "${config.subjectPrefix}"`)
157
+ return
158
+ }
159
+
160
+ // Parse the email body for text content
161
+ let bodyText = ''
162
+ if (msg.source) {
163
+ const parsed = await simpleParser(msg.source)
164
+ bodyText = parsed.text || ''
165
+ }
166
+
167
+ if (!bodyText.trim()) {
168
+ console.log(`[email] Skipping empty message from ${fromAddr}`)
169
+ return
170
+ }
171
+
172
+ console.log(`[email] New message from ${fromName} <${fromAddr}>: ${subject}`)
173
+
174
+ // Use the sender's email as channelId
175
+ const channelId = fromAddr
176
+
177
+ // Store sender info for replies
178
+ senderMap.set(channelId, {
179
+ address: fromAddr,
180
+ subject,
181
+ messageId: envelope.messageId,
182
+ })
183
+
184
+ const inbound: InboundMessage = {
185
+ platform: 'email',
186
+ channelId,
187
+ channelName: `Email: ${fromName}`,
188
+ senderId: fromAddr,
189
+ senderName: fromName,
190
+ text: bodyText.trim(),
191
+ }
192
+
193
+ try {
194
+ const response = await onMessage(inbound)
195
+ if (isNoMessage(response)) return
196
+
197
+ // Reply via SMTP
198
+ await sendReply(channelId, response)
199
+ } catch (err: unknown) {
200
+ const errMsg = err instanceof Error ? err.message : String(err)
201
+ console.error(`[email] Error handling message from ${fromAddr}: ${errMsg}`)
202
+ }
203
+ }
204
+
205
+ async function sendReply(channelId: string, text: string): Promise<void> {
206
+ const sender = senderMap.get(channelId)
207
+ const to = sender?.address || channelId
208
+ const subject = sender?.subject ? `Re: ${sender.subject.replace(/^Re:\s*/i, '')}` : 'Re: SwarmClaw'
209
+
210
+ const mailOptions: Record<string, unknown> = {
211
+ from: config.user,
212
+ to,
213
+ subject,
214
+ text,
215
+ }
216
+
217
+ // Thread the reply using In-Reply-To header
218
+ if (sender?.messageId) {
219
+ mailOptions['inReplyTo'] = sender.messageId
220
+ mailOptions['references'] = sender.messageId
221
+ }
222
+
223
+ await smtp.sendMail(mailOptions)
224
+ console.log(`[email] Reply sent to ${to}`)
225
+ }
226
+
227
+ // Connect and start polling
228
+ await connectImap()
229
+
230
+ pollTimer = setInterval(() => {
231
+ pollForNewMessages().catch((err: unknown) => {
232
+ const msg = err instanceof Error ? err.message : String(err)
233
+ console.error(`[email] Poll interval error: ${msg}`)
234
+ })
235
+ }, pollMs)
236
+
237
+ console.log(`[email] Connector started — polling every ${config.pollIntervalSec || 60}s`)
238
+
239
+ return {
240
+ connector,
241
+
242
+ isAlive() {
243
+ return connected && imap.usable
244
+ },
245
+
246
+ async sendMessage(channelId, text) {
247
+ await sendReply(channelId, text)
248
+ },
249
+
250
+ async stop() {
251
+ if (pollTimer) {
252
+ clearInterval(pollTimer)
253
+ pollTimer = null
254
+ }
255
+ try {
256
+ await imap.logout()
257
+ } catch {
258
+ // Connection may already be closed
259
+ }
260
+ connected = false
261
+ console.log(`[email] Connector stopped`)
262
+ },
263
+ }
264
+ },
265
+ }
266
+
267
+ export default email