@swarmclawai/swarmclaw 0.6.4 → 0.6.6
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 +5 -3
- package/package.json +5 -1
- package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
- package/src/app/api/chatrooms/[id]/route.ts +15 -1
- package/src/app/api/chatrooms/route.ts +15 -2
- package/src/app/api/schedules/[id]/run/route.ts +3 -0
- package/src/app/api/tasks/route.ts +24 -0
- 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 +15 -0
- package/src/cli/spec.js +14 -0
- package/src/components/agents/agent-avatar.tsx +15 -1
- package/src/components/agents/agent-card.tsx +1 -0
- package/src/components/agents/agent-chat-list.tsx +1 -1
- package/src/components/agents/agent-sheet.tsx +112 -26
- package/src/components/chat/chat-area.tsx +2 -2
- 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 +3 -2
- package/src/components/chat/message-list.tsx +5 -4
- 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 +1 -1
- package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
- package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
- package/src/components/chatrooms/chatroom-view.tsx +1 -1
- package/src/components/connectors/connector-list.tsx +1 -1
- package/src/components/home/home-view.tsx +2 -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 +18 -3
- 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/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/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-sheet.tsx +21 -1
- 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/lib/server/agent-registry.ts +2 -2
- package/src/lib/server/chat-execution.ts +35 -3
- 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 +64 -11
- 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 +80 -2
- 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 +8 -5
- 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 +226 -24
- package/src/lib/server/scheduler.ts +3 -0
- 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 +6 -2
- package/src/lib/server/session-tools/memory.ts +1 -1
- 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 +38 -0
- 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/tool-definitions.ts +1 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/stores/use-app-store.ts +8 -0
- package/src/types/index.ts +60 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
4
|
+
import { api } from '@/lib/api-client'
|
|
5
|
+
import type { AgentWallet, WalletChain } from '@/types'
|
|
6
|
+
|
|
7
|
+
interface WalletSectionProps {
|
|
8
|
+
agentId: string
|
|
9
|
+
wallet: (Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }) | null
|
|
10
|
+
onWalletCreated: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function WalletSection({ agentId, wallet, onWalletCreated }: WalletSectionProps) {
|
|
14
|
+
const [creating, setCreating] = useState(false)
|
|
15
|
+
const [error, setError] = useState<string | null>(null)
|
|
16
|
+
const [copied, setCopied] = useState(false)
|
|
17
|
+
|
|
18
|
+
const createWallet = useCallback(async () => {
|
|
19
|
+
setCreating(true)
|
|
20
|
+
setError(null)
|
|
21
|
+
try {
|
|
22
|
+
await api('POST', '/wallets', { agentId, chain: 'solana' as WalletChain })
|
|
23
|
+
onWalletCreated()
|
|
24
|
+
} catch (err: unknown) {
|
|
25
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
26
|
+
} finally {
|
|
27
|
+
setCreating(false)
|
|
28
|
+
}
|
|
29
|
+
}, [agentId, onWalletCreated])
|
|
30
|
+
|
|
31
|
+
const copyAddress = useCallback(() => {
|
|
32
|
+
if (!wallet) return
|
|
33
|
+
navigator.clipboard.writeText(wallet.publicKey)
|
|
34
|
+
setCopied(true)
|
|
35
|
+
setTimeout(() => setCopied(false), 2000)
|
|
36
|
+
}, [wallet])
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="mb-8">
|
|
40
|
+
<div className="flex items-center gap-2 mb-3">
|
|
41
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">
|
|
42
|
+
Wallet
|
|
43
|
+
</label>
|
|
44
|
+
<span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-400 text-[9px] font-600 uppercase tracking-wide">
|
|
45
|
+
Experimental
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{!wallet ? (
|
|
50
|
+
<div className="p-4 rounded-[12px] border border-white/[0.06] bg-surface-2/50">
|
|
51
|
+
<p className="text-[12px] text-text-3/70 mb-3">
|
|
52
|
+
Create a Solana wallet for this agent to hold funds, pay for services, and trade autonomously.
|
|
53
|
+
</p>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={createWallet}
|
|
57
|
+
disabled={creating}
|
|
58
|
+
className="px-3 py-1.5 rounded-[8px] bg-accent-soft text-accent-bright text-[11px] font-600 hover:bg-accent-bright/15 transition-all cursor-pointer disabled:opacity-50 border border-accent-bright/20"
|
|
59
|
+
style={{ fontFamily: 'inherit' }}
|
|
60
|
+
>
|
|
61
|
+
{creating ? 'Creating...' : 'Create Wallet'}
|
|
62
|
+
</button>
|
|
63
|
+
{error && <p className="text-[11px] text-red-400 mt-2">{error}</p>}
|
|
64
|
+
</div>
|
|
65
|
+
) : (
|
|
66
|
+
<div className="p-4 rounded-[12px] border border-white/[0.06] bg-surface-2/50 space-y-3">
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<span className="text-[10px] text-text-3/60 uppercase tracking-wide font-600">
|
|
69
|
+
{wallet.chain}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="flex-1" />
|
|
72
|
+
{typeof wallet.balanceSol === 'number' && (
|
|
73
|
+
<span className="text-[13px] font-600 text-text-1">
|
|
74
|
+
{wallet.balanceSol.toFixed(4)} SOL
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<code className="text-[11px] text-text-3 bg-black/20 px-2 py-1 rounded-[6px] font-mono truncate flex-1">
|
|
80
|
+
{wallet.publicKey}
|
|
81
|
+
</code>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={copyAddress}
|
|
85
|
+
className="shrink-0 px-2 py-1 rounded-[6px] text-[10px] text-text-3 hover:text-text-2 border border-white/[0.08] bg-surface transition-colors cursor-pointer"
|
|
86
|
+
style={{ fontFamily: 'inherit' }}
|
|
87
|
+
>
|
|
88
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex items-center gap-3 text-[10px] text-text-3/60">
|
|
92
|
+
<span>Limit: {((wallet.spendingLimitLamports ?? 100_000_000) / 1e9).toFixed(2)} SOL/tx</span>
|
|
93
|
+
<span>Daily: {((wallet.dailyLimitLamports ?? 1_000_000_000) / 1e9).toFixed(1)} SOL</span>
|
|
94
|
+
<span>{wallet.requireApproval ? 'Approval required' : 'Auto-send'}</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -63,8 +63,8 @@ export function buildAgentAwarenessBlock(excludeId: string): string {
|
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
return [
|
|
66
|
-
'##
|
|
66
|
+
'## My Colleagues',
|
|
67
|
+
'These are the other agents I work alongside. I can hand off tasks to any of them if their skills are a better fit:',
|
|
67
68
|
...lines,
|
|
68
|
-
'You can delegate tasks to any agent using the delegate_to_agent tool.',
|
|
69
69
|
].join('\n')
|
|
70
70
|
}
|
|
@@ -109,6 +109,21 @@ function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function shouldReplaceRecentAssistantMessage(params: {
|
|
113
|
+
previous: Message | null | undefined
|
|
114
|
+
nextToolEvents: MessageToolEvent[]
|
|
115
|
+
nextKind: Message['kind']
|
|
116
|
+
now: number
|
|
117
|
+
}): boolean {
|
|
118
|
+
const { previous, nextToolEvents, nextKind, now } = params
|
|
119
|
+
if (!previous || previous.role !== 'assistant') return false
|
|
120
|
+
if (nextToolEvents.length === 0) return false
|
|
121
|
+
if (previous.kind && nextKind && previous.kind !== nextKind) return false
|
|
122
|
+
if (typeof previous.time === 'number' && now - previous.time > 45_000) return false
|
|
123
|
+
const prevTools = Array.isArray(previous.toolEvents) ? previous.toolEvents.length : 0
|
|
124
|
+
return prevTools === 0
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
function requestedToolNamesFromMessage(message: string): string[] {
|
|
113
128
|
const lower = message.toLowerCase()
|
|
114
129
|
const candidates = [
|
|
@@ -377,6 +392,11 @@ function buildAgentSystemPrompt(session: any): string | undefined {
|
|
|
377
392
|
|
|
378
393
|
const settings = loadSettings()
|
|
379
394
|
const parts: string[] = []
|
|
395
|
+
// Identity block — agent needs to know who it is
|
|
396
|
+
const identityLines = [`## My Identity`, `My name is ${agent.name}.`]
|
|
397
|
+
if (agent.description) identityLines.push(agent.description)
|
|
398
|
+
identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
|
|
399
|
+
parts.push(identityLines.join(' '))
|
|
380
400
|
if (settings.userPrompt) parts.push(settings.userPrompt)
|
|
381
401
|
parts.push(buildCurrentDateTimePromptContext())
|
|
382
402
|
if (agent.soul) parts.push(agent.soul)
|
|
@@ -1014,14 +1034,26 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
1014
1034
|
const persistedText = heartbeatClassification === 'strip'
|
|
1015
1035
|
? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
|
|
1016
1036
|
: textForPersistence
|
|
1017
|
-
|
|
1037
|
+
const nowTs = Date.now()
|
|
1038
|
+
const nextAssistantMessage: Message = {
|
|
1018
1039
|
role: 'assistant',
|
|
1019
1040
|
text: persistedText,
|
|
1020
|
-
time:
|
|
1041
|
+
time: nowTs,
|
|
1021
1042
|
thinking: thinkingText || undefined,
|
|
1022
1043
|
toolEvents: toolEvents.length ? toolEvents : undefined,
|
|
1023
1044
|
kind: persistedKind,
|
|
1024
|
-
}
|
|
1045
|
+
}
|
|
1046
|
+
const previous = current.messages.at(-1)
|
|
1047
|
+
if (shouldReplaceRecentAssistantMessage({
|
|
1048
|
+
previous,
|
|
1049
|
+
nextToolEvents: toolEvents,
|
|
1050
|
+
nextKind: persistedKind,
|
|
1051
|
+
now: nowTs,
|
|
1052
|
+
})) {
|
|
1053
|
+
current.messages[current.messages.length - 1] = nextAssistantMessage
|
|
1054
|
+
} else {
|
|
1055
|
+
current.messages.push(nextAssistantMessage)
|
|
1056
|
+
}
|
|
1025
1057
|
changed = true
|
|
1026
1058
|
|
|
1027
1059
|
// Conversation tone detection
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
|
|
2
2
|
import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
|
|
3
|
+
import { genId } from '@/lib/id'
|
|
3
4
|
import type { Chatroom, Agent, Session, Message } from '@/types'
|
|
4
5
|
|
|
5
6
|
/** Resolve API key from an agent's credentialId */
|
|
@@ -11,17 +12,36 @@ export function resolveApiKey(credentialId: string | null | undefined): string |
|
|
|
11
12
|
try { return decryptKey(cred.encryptedKey) } catch { return null }
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
const COMPACTION_PREFIX = '[Conversation summary]'
|
|
16
|
+
|
|
17
|
+
function normalizeMentionToken(raw: string): string {
|
|
18
|
+
return raw
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[.,!?;:]+$/g, '')
|
|
21
|
+
.replace(/\s+/g, '')
|
|
22
|
+
.trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function truncateText(text: string, max: number): string {
|
|
26
|
+
const compact = String(text || '').replace(/\s+/g, ' ').trim()
|
|
27
|
+
if (compact.length <= max) return compact
|
|
28
|
+
return `${compact.slice(0, Math.max(0, max - 3))}...`
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
/** Parse @mentions from message text, returns matching agentIds */
|
|
15
32
|
export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
|
|
16
33
|
if (/@all\b/i.test(text)) return [...memberIds]
|
|
17
|
-
const mentionPattern =
|
|
34
|
+
const mentionPattern = /(?:^|[\s(])@([a-zA-Z0-9._-]+)/g
|
|
18
35
|
const mentioned: string[] = []
|
|
19
36
|
let match: RegExpExecArray | null
|
|
20
37
|
while ((match = mentionPattern.exec(text)) !== null) {
|
|
21
|
-
const
|
|
38
|
+
const token = normalizeMentionToken(match[1] || '')
|
|
39
|
+
if (!token) continue
|
|
22
40
|
for (const id of memberIds) {
|
|
23
41
|
const agent = agents[id]
|
|
24
|
-
|
|
42
|
+
const normalizedName = normalizeMentionToken(agent?.name || '')
|
|
43
|
+
const normalizedId = normalizeMentionToken(id)
|
|
44
|
+
if (agent && (normalizedName === token || normalizedId === token)) {
|
|
25
45
|
if (!mentioned.includes(id)) mentioned.push(id)
|
|
26
46
|
}
|
|
27
47
|
}
|
|
@@ -29,6 +49,34 @@ export function parseMentions(text: string, agents: Record<string, Agent>, membe
|
|
|
29
49
|
return mentioned
|
|
30
50
|
}
|
|
31
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Persisted chatroom compaction so long-lived rooms stay inside context budgets.
|
|
54
|
+
* Returns true when the message list was compacted.
|
|
55
|
+
*/
|
|
56
|
+
export function compactChatroomMessages(chatroom: Chatroom, keepLast = 90): boolean {
|
|
57
|
+
const maxKeep = Math.max(20, keepLast)
|
|
58
|
+
if (!Array.isArray(chatroom.messages) || chatroom.messages.length <= maxKeep) return false
|
|
59
|
+
|
|
60
|
+
const dropped = chatroom.messages.length - maxKeep
|
|
61
|
+
const kept = chatroom.messages.slice(-maxKeep).filter((msg, idx) => {
|
|
62
|
+
if (idx !== 0) return true
|
|
63
|
+
return !(msg.senderId === 'system' && typeof msg.text === 'string' && msg.text.startsWith(COMPACTION_PREFIX))
|
|
64
|
+
})
|
|
65
|
+
const summaryMessage = {
|
|
66
|
+
id: genId(),
|
|
67
|
+
senderId: 'system',
|
|
68
|
+
senderName: 'System',
|
|
69
|
+
role: 'assistant' as const,
|
|
70
|
+
text: `${COMPACTION_PREFIX} ${dropped} earlier chat message(s) were condensed to keep the room responsive.`,
|
|
71
|
+
mentions: [],
|
|
72
|
+
reactions: [],
|
|
73
|
+
time: Date.now(),
|
|
74
|
+
}
|
|
75
|
+
chatroom.messages = [summaryMessage, ...kept]
|
|
76
|
+
chatroom.updatedAt = Date.now()
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
32
80
|
/** Build chatroom context as a system prompt addendum with agent profiles and collaboration guidelines */
|
|
33
81
|
export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<string, Agent>, agentId: string): string {
|
|
34
82
|
const selfAgent = agents[agentId]
|
|
@@ -47,9 +95,10 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
|
|
|
47
95
|
.filter(Boolean)
|
|
48
96
|
.join('\n')
|
|
49
97
|
|
|
50
|
-
const recentMessages = chatroom.messages
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
const recentMessages = chatroom.messages
|
|
99
|
+
.slice(-8)
|
|
100
|
+
.map((m) => `[${m.senderName}]: ${truncateText(m.text, 180)}`)
|
|
101
|
+
.join('\n')
|
|
53
102
|
|
|
54
103
|
const memberCount = chatroom.agentIds.length
|
|
55
104
|
const otherNames = chatroom.agentIds
|
|
@@ -70,6 +119,8 @@ export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<str
|
|
|
70
119
|
'- **You are in a group chat.** Talk like you are in a real-time conversation with teammates — be direct, casual, and concise.',
|
|
71
120
|
'- **Be yourself.** Respond with personality. Don\'t give generic "let me know if you need anything" responses. Actually engage with what was said.',
|
|
72
121
|
'- **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.',
|
|
122
|
+
'- **Do not meta-narrate user intent.** Avoid phrases like "it seems like you\'re trying to..." — respond directly to what they said.',
|
|
123
|
+
'- **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
124
|
'- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
|
|
74
125
|
'- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
|
|
75
126
|
'- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
|
|
@@ -121,10 +172,12 @@ export function buildAgentSystemPromptForChatroom(agent: Agent): string {
|
|
|
121
172
|
|
|
122
173
|
/** Convert chatroom messages to Message history format for LLM */
|
|
123
174
|
export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imagePath?: string, attachedFiles?: string[]): Message[] {
|
|
124
|
-
const
|
|
175
|
+
const recentMessages = chatroom.messages.slice(-24)
|
|
176
|
+
const includeAttachmentsFrom = Math.max(0, recentMessages.length - 6)
|
|
177
|
+
const history = recentMessages.map((m, idx) => {
|
|
125
178
|
let msgText = `[${m.senderName}]: ${m.text}`
|
|
126
|
-
|
|
127
|
-
if (m.attachedFiles?.length) {
|
|
179
|
+
const includeAttachments = idx >= includeAttachmentsFrom
|
|
180
|
+
if (includeAttachments && m.attachedFiles?.length) {
|
|
128
181
|
const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
|
|
129
182
|
msgText += `\n[Attached: ${names}]`
|
|
130
183
|
}
|
|
@@ -132,8 +185,8 @@ export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imageP
|
|
|
132
185
|
role: m.senderId === agentId ? 'assistant' as const : 'user' as const,
|
|
133
186
|
text: msgText,
|
|
134
187
|
time: m.time,
|
|
135
|
-
...(m.imagePath ? { imagePath: m.imagePath } : {}),
|
|
136
|
-
...(m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
|
|
188
|
+
...(includeAttachments && m.imagePath ? { imagePath: m.imagePath } : {}),
|
|
189
|
+
...(includeAttachments && m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
|
|
137
190
|
}
|
|
138
191
|
})
|
|
139
192
|
// Pass through imagePath/attachedFiles from the current message to the last history entry
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { enrichInboundMessageWithAudioTranscript } from './inbound-audio-transcription'
|
|
6
|
+
import type { InboundMessage } from './types'
|
|
7
|
+
import { UPLOAD_DIR, loadSettings, saveSettings } from '../storage'
|
|
8
|
+
|
|
9
|
+
const ENV_KEYS = [
|
|
10
|
+
'OPENAI_API_KEY',
|
|
11
|
+
'SWARMCLAW_OPENAI_STT_API_KEY',
|
|
12
|
+
'SWARMCLAW_OPENAI_STT_BASE_URL',
|
|
13
|
+
'SWARMCLAW_OPENAI_STT_MODEL',
|
|
14
|
+
'SWARMCLAW_ELEVENLABS_STT_MODEL',
|
|
15
|
+
'SWARMCLAW_CONNECTOR_AUDIO_TRANSCRIBE',
|
|
16
|
+
'SWARMCLAW_CONNECTOR_AUDIO_TRANSCRIBE_TIMEOUT_MS',
|
|
17
|
+
'SWARMCLAW_CONNECTOR_AUDIO_TRANSCRIBE_MAX_BYTES',
|
|
18
|
+
'ELEVENLABS_API_KEY',
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
type EnvSnapshot = Record<(typeof ENV_KEYS)[number], string | undefined>
|
|
22
|
+
|
|
23
|
+
let originalFetch: typeof fetch
|
|
24
|
+
let originalSettings: Record<string, unknown>
|
|
25
|
+
let originalEnv: EnvSnapshot
|
|
26
|
+
let tempFiles: string[] = []
|
|
27
|
+
|
|
28
|
+
function setEnv(name: (typeof ENV_KEYS)[number], value: string | undefined): void {
|
|
29
|
+
if (value === undefined) delete process.env[name]
|
|
30
|
+
else process.env[name] = value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createAudioFixture(name: string): string {
|
|
34
|
+
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
35
|
+
const filePath = path.join(UPLOAD_DIR, `${Date.now()}-${name}.ogg`)
|
|
36
|
+
fs.writeFileSync(filePath, Buffer.from('voice-note-bytes'))
|
|
37
|
+
tempFiles.push(filePath)
|
|
38
|
+
return filePath
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildInboundMessage(localPath: string, text = '(media message)'): InboundMessage {
|
|
42
|
+
return {
|
|
43
|
+
platform: 'whatsapp',
|
|
44
|
+
channelId: '15550001111@s.whatsapp.net',
|
|
45
|
+
senderId: '15550001111@s.whatsapp.net',
|
|
46
|
+
senderName: 'Tester',
|
|
47
|
+
text,
|
|
48
|
+
media: [{ type: 'audio', localPath, mimeType: 'audio/ogg', fileName: 'voice.ogg' }],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
originalFetch = global.fetch
|
|
54
|
+
originalSettings = loadSettings()
|
|
55
|
+
originalEnv = Object.fromEntries(
|
|
56
|
+
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
57
|
+
) as EnvSnapshot
|
|
58
|
+
tempFiles = []
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
global.fetch = originalFetch
|
|
63
|
+
saveSettings(originalSettings)
|
|
64
|
+
for (const key of ENV_KEYS) setEnv(key, originalEnv[key])
|
|
65
|
+
for (const filePath of tempFiles) fs.rmSync(filePath, { force: true })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('enrichInboundMessageWithAudioTranscript', () => {
|
|
69
|
+
it('transcribes placeholder audio messages with OpenAI STT', async () => {
|
|
70
|
+
const audioPath = createAudioFixture('openai')
|
|
71
|
+
setEnv('OPENAI_API_KEY', 'openai-test-key')
|
|
72
|
+
setEnv('SWARMCLAW_CONNECTOR_AUDIO_TRANSCRIBE_TIMEOUT_MS', '5000')
|
|
73
|
+
saveSettings({
|
|
74
|
+
...originalSettings,
|
|
75
|
+
elevenLabsEnabled: false,
|
|
76
|
+
elevenLabsApiKey: null,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
let called = 0
|
|
80
|
+
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
81
|
+
called += 1
|
|
82
|
+
const url = String(input)
|
|
83
|
+
assert.ok(url.endsWith('/audio/transcriptions'))
|
|
84
|
+
assert.equal(init?.method, 'POST')
|
|
85
|
+
assert.equal((init?.headers as Record<string, string>)?.Authorization, 'Bearer openai-test-key')
|
|
86
|
+
return new Response(JSON.stringify({ text: 'Please move this task to tomorrow morning.' }), {
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
})
|
|
90
|
+
}) as typeof fetch
|
|
91
|
+
|
|
92
|
+
const inbound = buildInboundMessage(audioPath)
|
|
93
|
+
const enriched = await enrichInboundMessageWithAudioTranscript({ msg: inbound })
|
|
94
|
+
|
|
95
|
+
assert.equal(called, 1)
|
|
96
|
+
assert.equal(enriched.text, 'Please move this task to tomorrow morning.')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('tries ElevenLabs first and falls back to OpenAI when ElevenLabs fails', async () => {
|
|
100
|
+
const audioPath = createAudioFixture('fallback')
|
|
101
|
+
setEnv('OPENAI_API_KEY', 'openai-fallback-key')
|
|
102
|
+
saveSettings({
|
|
103
|
+
...originalSettings,
|
|
104
|
+
elevenLabsEnabled: true,
|
|
105
|
+
elevenLabsApiKey: 'el-test-key',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const calledUrls: string[] = []
|
|
109
|
+
global.fetch = (async (input: RequestInfo | URL) => {
|
|
110
|
+
const url = String(input)
|
|
111
|
+
calledUrls.push(url)
|
|
112
|
+
if (url.includes('api.elevenlabs.io/v1/speech-to-text')) {
|
|
113
|
+
return new Response(JSON.stringify({ detail: 'upstream unavailable' }), {
|
|
114
|
+
status: 503,
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
if (url.endsWith('/audio/transcriptions')) {
|
|
119
|
+
return new Response(JSON.stringify({ text: 'Fallback transcription succeeded.' }), {
|
|
120
|
+
status: 200,
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
return new Response('unexpected url', { status: 404 })
|
|
125
|
+
}) as typeof fetch
|
|
126
|
+
|
|
127
|
+
const inbound = buildInboundMessage(audioPath)
|
|
128
|
+
const enriched = await enrichInboundMessageWithAudioTranscript({ msg: inbound })
|
|
129
|
+
|
|
130
|
+
assert.equal(enriched.text, 'Fallback transcription succeeded.')
|
|
131
|
+
assert.equal(calledUrls.length, 2)
|
|
132
|
+
assert.ok(calledUrls[0].includes('api.elevenlabs.io/v1/speech-to-text'))
|
|
133
|
+
assert.ok(calledUrls[1].endsWith('/audio/transcriptions'))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('skips transcription when the inbound message already has non-placeholder text', async () => {
|
|
137
|
+
const audioPath = createAudioFixture('skip')
|
|
138
|
+
setEnv('OPENAI_API_KEY', 'openai-test-key')
|
|
139
|
+
|
|
140
|
+
let called = false
|
|
141
|
+
global.fetch = (async () => {
|
|
142
|
+
called = true
|
|
143
|
+
return new Response(JSON.stringify({ text: 'should not be used' }), {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
})
|
|
147
|
+
}) as typeof fetch
|
|
148
|
+
|
|
149
|
+
const inbound = buildInboundMessage(audioPath, 'Already typed this manually')
|
|
150
|
+
const enriched = await enrichInboundMessageWithAudioTranscript({ msg: inbound })
|
|
151
|
+
|
|
152
|
+
assert.equal(enriched.text, 'Already typed this manually')
|
|
153
|
+
assert.equal(called, false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns a clear failure note when STT providers error out', async () => {
|
|
157
|
+
const audioPath = createAudioFixture('provider-error')
|
|
158
|
+
setEnv('OPENAI_API_KEY', 'openai-error-key')
|
|
159
|
+
saveSettings({
|
|
160
|
+
...originalSettings,
|
|
161
|
+
elevenLabsEnabled: true,
|
|
162
|
+
elevenLabsApiKey: 'el-error-key',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
global.fetch = (async () => {
|
|
166
|
+
return new Response(JSON.stringify({ error: 'upstream down' }), {
|
|
167
|
+
status: 500,
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
})
|
|
170
|
+
}) as typeof fetch
|
|
171
|
+
|
|
172
|
+
const inbound = buildInboundMessage(audioPath)
|
|
173
|
+
const enriched = await enrichInboundMessageWithAudioTranscript({ msg: inbound })
|
|
174
|
+
|
|
175
|
+
assert.ok(enriched.text.toLowerCase().includes('automatic transcription failed'))
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('returns a clear note when inbound audio cannot be loaded from disk', async () => {
|
|
179
|
+
const inbound: InboundMessage = {
|
|
180
|
+
platform: 'whatsapp',
|
|
181
|
+
channelId: '15550001111@s.whatsapp.net',
|
|
182
|
+
senderId: '15550001111@s.whatsapp.net',
|
|
183
|
+
senderName: 'Tester',
|
|
184
|
+
text: '(media message)',
|
|
185
|
+
media: [{ type: 'audio', localPath: '/tmp/nonexistent-voice-note.ogg', mimeType: 'audio/ogg', fileName: 'voice.ogg' }],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const enriched = await enrichInboundMessageWithAudioTranscript({ msg: inbound })
|
|
189
|
+
assert.ok(enriched.text.toLowerCase().includes('audio attachment could not be loaded'))
|
|
190
|
+
})
|
|
191
|
+
})
|