@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.
Files changed (92) hide show
  1. package/README.md +5 -3
  2. package/package.json +5 -1
  3. package/src/app/api/chatrooms/[id]/chat/route.ts +41 -2
  4. package/src/app/api/chatrooms/[id]/route.ts +15 -1
  5. package/src/app/api/chatrooms/route.ts +15 -2
  6. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  7. package/src/app/api/tasks/route.ts +24 -0
  8. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  9. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  10. package/src/app/api/wallets/[id]/route.ts +118 -0
  11. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  12. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  13. package/src/app/api/wallets/route.ts +74 -0
  14. package/src/app/globals.css +8 -0
  15. package/src/cli/index.js +15 -0
  16. package/src/cli/spec.js +14 -0
  17. package/src/components/agents/agent-avatar.tsx +15 -1
  18. package/src/components/agents/agent-card.tsx +1 -0
  19. package/src/components/agents/agent-chat-list.tsx +1 -1
  20. package/src/components/agents/agent-sheet.tsx +112 -26
  21. package/src/components/chat/chat-area.tsx +2 -2
  22. package/src/components/chat/chat-header.tsx +48 -19
  23. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  24. package/src/components/chat/delegation-banner.test.ts +27 -0
  25. package/src/components/chat/delegation-banner.tsx +109 -23
  26. package/src/components/chat/message-bubble.tsx +3 -2
  27. package/src/components/chat/message-list.tsx +5 -4
  28. package/src/components/chat/streaming-bubble.tsx +3 -2
  29. package/src/components/chat/thinking-indicator.tsx +3 -2
  30. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  31. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  32. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  33. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  34. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  35. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  37. package/src/components/connectors/connector-list.tsx +1 -1
  38. package/src/components/home/home-view.tsx +2 -1
  39. package/src/components/knowledge/knowledge-list.tsx +1 -1
  40. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  41. package/src/components/layout/app-layout.tsx +18 -3
  42. package/src/components/memory/memory-agent-list.tsx +1 -1
  43. package/src/components/memory/memory-browser.tsx +1 -0
  44. package/src/components/memory/memory-card.tsx +3 -2
  45. package/src/components/memory/memory-detail.tsx +3 -3
  46. package/src/components/memory/memory-sheet.tsx +2 -2
  47. package/src/components/projects/project-detail.tsx +4 -4
  48. package/src/components/secrets/secret-sheet.tsx +1 -1
  49. package/src/components/secrets/secrets-list.tsx +1 -1
  50. package/src/components/sessions/session-card.tsx +1 -1
  51. package/src/components/shared/agent-picker-list.tsx +1 -1
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  54. package/src/components/skills/skill-list.tsx +1 -1
  55. package/src/components/skills/skill-sheet.tsx +1 -1
  56. package/src/components/tasks/task-board.tsx +3 -3
  57. package/src/components/tasks/task-sheet.tsx +21 -1
  58. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  59. package/src/components/wallets/wallet-panel.tsx +616 -0
  60. package/src/components/wallets/wallet-section.tsx +100 -0
  61. package/src/lib/server/agent-registry.ts +2 -2
  62. package/src/lib/server/chat-execution.ts +35 -3
  63. package/src/lib/server/chatroom-health.ts +60 -0
  64. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  65. package/src/lib/server/chatroom-helpers.ts +64 -11
  66. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  67. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  68. package/src/lib/server/connectors/manager.ts +80 -2
  69. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  70. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  71. package/src/lib/server/connectors/whatsapp.ts +8 -5
  72. package/src/lib/server/orchestrator-lg.ts +12 -2
  73. package/src/lib/server/orchestrator.ts +6 -1
  74. package/src/lib/server/queue-followups.test.ts +224 -0
  75. package/src/lib/server/queue.ts +226 -24
  76. package/src/lib/server/scheduler.ts +3 -0
  77. package/src/lib/server/session-tools/chatroom.ts +11 -2
  78. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  79. package/src/lib/server/session-tools/index.ts +6 -2
  80. package/src/lib/server/session-tools/memory.ts +1 -1
  81. package/src/lib/server/session-tools/shell.ts +1 -1
  82. package/src/lib/server/session-tools/wallet.ts +124 -0
  83. package/src/lib/server/session-tools/web.ts +2 -2
  84. package/src/lib/server/solana.ts +122 -0
  85. package/src/lib/server/storage.ts +38 -0
  86. package/src/lib/server/stream-agent-chat.ts +126 -63
  87. package/src/lib/server/task-mention.test.ts +41 -0
  88. package/src/lib/server/task-mention.ts +3 -2
  89. package/src/lib/tool-definitions.ts +1 -0
  90. package/src/lib/view-routes.ts +1 -0
  91. package/src/stores/use-app-store.ts +8 -0
  92. 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
- '## Available Agents',
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
- current.messages.push({
1037
+ const nowTs = Date.now()
1038
+ const nextAssistantMessage: Message = {
1018
1039
  role: 'assistant',
1019
1040
  text: persistedText,
1020
- time: Date.now(),
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 = /@(\S+)/g
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 name = match[1].toLowerCase()
38
+ const token = normalizeMentionToken(match[1] || '')
39
+ if (!token) continue
22
40
  for (const id of memberIds) {
23
41
  const agent = agents[id]
24
- if (agent && agent.name.toLowerCase().replace(/\s+/g, '') === name) {
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.slice(-30).map((m) => {
51
- return `[${m.senderName}]: ${m.text}`
52
- }).join('\n')
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 history = chatroom.messages.slice(-50).map((m) => {
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
- // Include attachment info in history
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
+ })