@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.
- package/README.md +62 -30
- package/package.json +10 -1
- package/src/app/api/agents/[id]/clone/route.ts +40 -0
- package/src/app/api/agents/route.ts +39 -14
- package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
- package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
- package/src/app/api/chatrooms/[id]/route.ts +34 -2
- package/src/app/api/chatrooms/route.ts +26 -3
- package/src/app/api/connectors/[id]/health/route.ts +64 -0
- package/src/app/api/connectors/route.ts +17 -2
- package/src/app/api/knowledge/route.ts +6 -1
- package/src/app/api/openclaw/doctor/route.ts +17 -0
- package/src/app/api/schedules/[id]/run/route.ts +3 -0
- package/src/app/api/sessions/[id]/chat/route.ts +5 -1
- package/src/app/api/sessions/route.ts +11 -2
- package/src/app/api/tasks/[id]/route.ts +18 -13
- package/src/app/api/tasks/route.ts +44 -1
- package/src/app/api/usage/route.ts +16 -7
- package/src/app/api/wallets/[id]/approve/route.ts +62 -0
- package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
- package/src/app/api/wallets/[id]/route.ts +118 -0
- package/src/app/api/wallets/[id]/send/route.ts +118 -0
- package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
- package/src/app/api/wallets/route.ts +74 -0
- package/src/app/globals.css +8 -0
- package/src/cli/index.js +20 -0
- package/src/cli/index.ts +223 -39
- package/src/cli/spec.js +14 -0
- package/src/components/agents/agent-avatar.tsx +15 -1
- package/src/components/agents/agent-card.tsx +38 -6
- package/src/components/agents/agent-chat-list.tsx +79 -3
- package/src/components/agents/agent-sheet.tsx +191 -26
- package/src/components/auth/setup-wizard.tsx +268 -353
- package/src/components/chat/chat-area.tsx +24 -9
- package/src/components/chat/chat-header.tsx +48 -19
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/delegation-banner.test.ts +27 -0
- package/src/components/chat/delegation-banner.tsx +109 -23
- package/src/components/chat/message-bubble.tsx +17 -16
- package/src/components/chat/message-list.tsx +6 -5
- package/src/components/chat/streaming-bubble.tsx +3 -2
- package/src/components/chat/thinking-indicator.tsx +3 -2
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/agent-hover-card.tsx +1 -1
- package/src/components/chatrooms/chatroom-input.tsx +1 -1
- package/src/components/chatrooms/chatroom-message.tsx +165 -23
- package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
- package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
- package/src/components/chatrooms/chatroom-view.tsx +62 -17
- package/src/components/connectors/connector-health.tsx +120 -0
- package/src/components/connectors/connector-list.tsx +1 -1
- package/src/components/connectors/connector-sheet.tsx +9 -0
- package/src/components/home/home-view.tsx +25 -3
- package/src/components/input/chat-input.tsx +8 -1
- package/src/components/knowledge/knowledge-list.tsx +1 -1
- package/src/components/knowledge/knowledge-sheet.tsx +1 -1
- package/src/components/layout/app-layout.tsx +35 -4
- package/src/components/memory/memory-agent-list.tsx +1 -1
- package/src/components/memory/memory-browser.tsx +1 -0
- package/src/components/memory/memory-card.tsx +3 -2
- package/src/components/memory/memory-detail.tsx +3 -3
- package/src/components/memory/memory-sheet.tsx +2 -2
- package/src/components/projects/project-detail.tsx +4 -4
- package/src/components/schedules/schedule-list.tsx +55 -9
- package/src/components/schedules/schedule-sheet.tsx +134 -23
- package/src/components/secrets/secret-sheet.tsx +1 -1
- package/src/components/secrets/secrets-list.tsx +1 -1
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/shared/agent-picker-list.tsx +1 -1
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/command-palette.tsx +237 -0
- package/src/components/shared/connector-platform-icon.tsx +1 -0
- package/src/components/shared/settings/section-user-preferences.tsx +4 -4
- package/src/components/skills/skill-list.tsx +1 -1
- package/src/components/skills/skill-sheet.tsx +1 -1
- package/src/components/tasks/task-board.tsx +3 -3
- package/src/components/tasks/task-card.tsx +22 -2
- package/src/components/tasks/task-sheet.tsx +112 -17
- package/src/components/usage/metrics-dashboard.tsx +13 -25
- package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
- package/src/components/wallets/wallet-panel.tsx +616 -0
- package/src/components/wallets/wallet-section.tsx +100 -0
- package/src/hooks/use-swipe.ts +49 -0
- package/src/lib/providers/anthropic.ts +16 -2
- package/src/lib/providers/claude-cli.ts +7 -1
- package/src/lib/providers/index.ts +7 -0
- package/src/lib/providers/ollama.ts +16 -2
- package/src/lib/providers/openai.ts +7 -2
- package/src/lib/providers/openclaw.ts +6 -1
- package/src/lib/providers/provider-defaults.ts +7 -0
- package/src/lib/schedule-templates.ts +115 -0
- package/src/lib/server/agent-registry.ts +2 -2
- package/src/lib/server/alert-dispatch.ts +64 -0
- package/src/lib/server/chat-execution.ts +76 -4
- package/src/lib/server/chatroom-health.ts +60 -0
- package/src/lib/server/chatroom-helpers.test.ts +94 -0
- package/src/lib/server/chatroom-helpers.ts +86 -12
- package/src/lib/server/chatroom-routing.ts +65 -0
- package/src/lib/server/connectors/discord.ts +3 -0
- package/src/lib/server/connectors/email.ts +267 -0
- package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
- package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
- package/src/lib/server/connectors/manager.ts +239 -5
- package/src/lib/server/connectors/openclaw.ts +3 -0
- package/src/lib/server/connectors/slack.ts +6 -0
- package/src/lib/server/connectors/telegram.ts +18 -0
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
- package/src/lib/server/connectors/whatsapp-text.ts +26 -0
- package/src/lib/server/connectors/whatsapp.ts +17 -5
- package/src/lib/server/cost.ts +70 -0
- package/src/lib/server/create-notification.ts +2 -0
- package/src/lib/server/daemon-state.ts +124 -0
- package/src/lib/server/dag-validation.ts +115 -0
- package/src/lib/server/memory-db.ts +12 -7
- package/src/lib/server/openclaw-doctor.ts +48 -0
- package/src/lib/server/orchestrator-lg.ts +12 -2
- package/src/lib/server/orchestrator.ts +6 -1
- package/src/lib/server/queue-followups.test.ts +224 -0
- package/src/lib/server/queue.ts +238 -24
- package/src/lib/server/scheduler.ts +3 -0
- package/src/lib/server/session-run-manager.ts +22 -1
- package/src/lib/server/session-tools/chatroom.ts +11 -2
- package/src/lib/server/session-tools/context-mgmt.ts +2 -2
- package/src/lib/server/session-tools/index.ts +8 -2
- package/src/lib/server/session-tools/memory.ts +23 -4
- package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
- package/src/lib/server/session-tools/shell.ts +1 -1
- package/src/lib/server/session-tools/wallet.ts +124 -0
- package/src/lib/server/session-tools/web.ts +2 -2
- package/src/lib/server/solana.ts +122 -0
- package/src/lib/server/storage.ts +158 -6
- package/src/lib/server/stream-agent-chat.ts +126 -63
- package/src/lib/server/task-mention.test.ts +41 -0
- package/src/lib/server/task-mention.ts +3 -2
- package/src/lib/setup-defaults.ts +277 -0
- package/src/lib/tool-definitions.ts +1 -0
- package/src/lib/validation/schemas.ts +69 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/stores/use-app-store.ts +15 -3
- package/src/stores/use-chatroom-store.ts +52 -2
- package/src/types/index.ts +98 -2
- 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
|
|
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 =
|
|
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
|
|
59
|
+
const token = normalizeMentionToken(match[1] || '')
|
|
60
|
+
if (!token) continue
|
|
22
61
|
for (const id of memberIds) {
|
|
23
62
|
const agent = agents[id]
|
|
24
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|