@swarmclawai/swarmclaw 0.6.3 → 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 (106) 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/app/page.tsx +7 -3
  16. package/src/cli/index.js +15 -0
  17. package/src/cli/spec.js +14 -0
  18. package/src/components/agents/agent-avatar.tsx +15 -1
  19. package/src/components/agents/agent-card.tsx +1 -0
  20. package/src/components/agents/agent-chat-list.tsx +1 -1
  21. package/src/components/agents/agent-sheet.tsx +112 -26
  22. package/src/components/auth/access-key-gate.tsx +22 -11
  23. package/src/components/chat/chat-area.tsx +2 -2
  24. package/src/components/chat/chat-header.tsx +48 -19
  25. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  26. package/src/components/chat/delegation-banner.test.ts +27 -0
  27. package/src/components/chat/delegation-banner.tsx +109 -23
  28. package/src/components/chat/message-bubble.tsx +14 -3
  29. package/src/components/chat/message-list.tsx +5 -4
  30. package/src/components/chat/streaming-bubble.tsx +3 -2
  31. package/src/components/chat/thinking-indicator.tsx +3 -2
  32. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  33. package/src/components/chat/tool-call-bubble.tsx +13 -1
  34. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  35. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  36. package/src/components/chatrooms/chatroom-input.tsx +7 -6
  37. package/src/components/chatrooms/chatroom-message.tsx +1 -1
  38. package/src/components/chatrooms/chatroom-sheet.tsx +1 -1
  39. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  40. package/src/components/chatrooms/chatroom-view.tsx +1 -1
  41. package/src/components/connectors/connector-list.tsx +1 -1
  42. package/src/components/home/home-view.tsx +2 -1
  43. package/src/components/input/chat-input.tsx +5 -4
  44. package/src/components/knowledge/knowledge-list.tsx +1 -1
  45. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  46. package/src/components/layout/app-layout.tsx +23 -9
  47. package/src/components/logs/log-list.tsx +7 -7
  48. package/src/components/memory/memory-agent-list.tsx +1 -1
  49. package/src/components/memory/memory-browser.tsx +1 -0
  50. package/src/components/memory/memory-card.tsx +3 -2
  51. package/src/components/memory/memory-detail.tsx +3 -3
  52. package/src/components/memory/memory-sheet.tsx +2 -2
  53. package/src/components/projects/project-detail.tsx +4 -4
  54. package/src/components/secrets/secret-sheet.tsx +1 -1
  55. package/src/components/secrets/secrets-list.tsx +1 -1
  56. package/src/components/sessions/new-session-sheet.tsx +4 -3
  57. package/src/components/sessions/session-card.tsx +1 -1
  58. package/src/components/shared/agent-picker-list.tsx +1 -1
  59. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  60. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  61. package/src/components/skills/skill-list.tsx +1 -1
  62. package/src/components/skills/skill-sheet.tsx +1 -1
  63. package/src/components/tasks/task-board.tsx +3 -3
  64. package/src/components/tasks/task-sheet.tsx +21 -1
  65. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  66. package/src/components/wallets/wallet-panel.tsx +616 -0
  67. package/src/components/wallets/wallet-section.tsx +100 -0
  68. package/src/hooks/use-media-query.ts +30 -4
  69. package/src/lib/api-client.ts +6 -18
  70. package/src/lib/fetch-timeout.ts +17 -0
  71. package/src/lib/notification-sounds.ts +4 -4
  72. package/src/lib/safe-storage.ts +42 -0
  73. package/src/lib/server/agent-registry.ts +2 -2
  74. package/src/lib/server/chat-execution.ts +35 -3
  75. package/src/lib/server/chatroom-health.ts +60 -0
  76. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  77. package/src/lib/server/chatroom-helpers.ts +64 -11
  78. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  79. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  80. package/src/lib/server/connectors/manager.ts +80 -2
  81. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  82. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  83. package/src/lib/server/connectors/whatsapp.ts +8 -5
  84. package/src/lib/server/orchestrator-lg.ts +12 -2
  85. package/src/lib/server/orchestrator.ts +6 -1
  86. package/src/lib/server/queue-followups.test.ts +224 -0
  87. package/src/lib/server/queue.ts +226 -24
  88. package/src/lib/server/scheduler.ts +3 -0
  89. package/src/lib/server/session-tools/chatroom.ts +11 -2
  90. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  91. package/src/lib/server/session-tools/index.ts +6 -2
  92. package/src/lib/server/session-tools/memory.ts +1 -1
  93. package/src/lib/server/session-tools/shell.ts +1 -1
  94. package/src/lib/server/session-tools/wallet.ts +124 -0
  95. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  96. package/src/lib/server/session-tools/web-output.ts +16 -0
  97. package/src/lib/server/session-tools/web.ts +7 -3
  98. package/src/lib/server/solana.ts +122 -0
  99. package/src/lib/server/storage.ts +38 -0
  100. package/src/lib/server/stream-agent-chat.ts +126 -63
  101. package/src/lib/server/task-mention.test.ts +41 -0
  102. package/src/lib/server/task-mention.ts +3 -2
  103. package/src/lib/tool-definitions.ts +1 -0
  104. package/src/lib/view-routes.ts +6 -1
  105. package/src/stores/use-app-store.ts +17 -11
  106. 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
+ }
@@ -2,17 +2,43 @@
2
2
 
3
3
  import { useCallback, useSyncExternalStore } from 'react'
4
4
 
5
+ function supportsMatchMedia(): boolean {
6
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
7
+ }
8
+
9
+ function getMatch(query: string): boolean {
10
+ if (!supportsMatchMedia()) return false
11
+ try {
12
+ return window.matchMedia(query).matches
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
5
18
  export function useMediaQuery(query: string): boolean {
6
19
  const subscribe = useCallback(
7
20
  (callback: () => void) => {
8
- const mql = window.matchMedia(query)
9
- mql.addEventListener('change', callback)
10
- return () => mql.removeEventListener('change', callback)
21
+ if (!supportsMatchMedia()) return () => {}
22
+
23
+ let mql: MediaQueryList
24
+ try {
25
+ mql = window.matchMedia(query)
26
+ } catch {
27
+ return () => {}
28
+ }
29
+
30
+ if (typeof mql.addEventListener === 'function') {
31
+ mql.addEventListener('change', callback)
32
+ return () => mql.removeEventListener('change', callback)
33
+ }
34
+
35
+ mql.addListener(callback)
36
+ return () => mql.removeListener(callback)
11
37
  },
12
38
  [query],
13
39
  )
14
40
 
15
- const getSnapshot = () => window.matchMedia(query).matches
41
+ const getSnapshot = () => getMatch(query)
16
42
 
17
43
  // Return false during SSR — matches initial client render before hydration
18
44
  const getServerSnapshot = () => false
@@ -1,39 +1,27 @@
1
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
2
+ import { safeStorageGet, safeStorageSet, safeStorageRemove } from '@/lib/safe-storage'
3
+
1
4
  const ACCESS_KEY_STORAGE = 'sc_access_key'
2
5
  const DEFAULT_API_TIMEOUT_MS = 12_000
3
6
  const DEFAULT_GET_RETRIES = 2
4
7
  const RETRY_DELAY_BASE_MS = 300
5
8
 
6
9
  export function getStoredAccessKey(): string {
7
- if (typeof window === 'undefined') return ''
8
- return localStorage.getItem(ACCESS_KEY_STORAGE) || ''
10
+ return safeStorageGet(ACCESS_KEY_STORAGE) || ''
9
11
  }
10
12
 
11
13
  export function setStoredAccessKey(key: string) {
12
- localStorage.setItem(ACCESS_KEY_STORAGE, key)
14
+ safeStorageSet(ACCESS_KEY_STORAGE, key)
13
15
  }
14
16
 
15
17
  export function clearStoredAccessKey() {
16
- localStorage.removeItem(ACCESS_KEY_STORAGE)
18
+ safeStorageRemove(ACCESS_KEY_STORAGE)
17
19
  }
18
20
 
19
21
  function sleep(ms: number): Promise<void> {
20
22
  return new Promise((resolve) => setTimeout(resolve, ms))
21
23
  }
22
24
 
23
- async function fetchWithTimeout(
24
- input: RequestInfo | URL,
25
- init: RequestInit,
26
- timeoutMs: number,
27
- ): Promise<Response> {
28
- const controller = new AbortController()
29
- const timer = setTimeout(() => controller.abort(), timeoutMs)
30
- try {
31
- return await fetch(input, { ...init, signal: controller.signal })
32
- } finally {
33
- clearTimeout(timer)
34
- }
35
- }
36
-
37
25
  function isAbortError(err: unknown): boolean {
38
26
  if (!err || typeof err !== 'object') return false
39
27
  return (err as { name?: string }).name === 'AbortError'
@@ -0,0 +1,17 @@
1
+ const MIN_TIMEOUT_MS = 1_000
2
+
3
+ export async function fetchWithTimeout(
4
+ input: RequestInfo | URL,
5
+ init: RequestInit = {},
6
+ timeoutMs: number,
7
+ ): Promise<Response> {
8
+ const boundedTimeout = Math.max(MIN_TIMEOUT_MS, Math.trunc(timeoutMs))
9
+ const controller = new AbortController()
10
+ const timer = setTimeout(() => controller.abort(), boundedTimeout)
11
+
12
+ try {
13
+ return await fetch(input, { ...init, signal: controller.signal })
14
+ } finally {
15
+ clearTimeout(timer)
16
+ }
17
+ }
@@ -1,3 +1,5 @@
1
+ import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
2
+
1
3
  let ctx: AudioContext | null = null
2
4
 
3
5
  function ensureCtx(): AudioContext | null {
@@ -48,11 +50,9 @@ export function playError() {
48
50
  const LS_KEY = 'sc_sound_notifications'
49
51
 
50
52
  export function getSoundEnabled(): boolean {
51
- if (typeof window === 'undefined') return false
52
- return localStorage.getItem(LS_KEY) === '1'
53
+ return safeStorageGet(LS_KEY) === '1'
53
54
  }
54
55
 
55
56
  export function setSoundEnabled(v: boolean) {
56
- if (typeof window === 'undefined') return
57
- localStorage.setItem(LS_KEY, v ? '1' : '0')
57
+ safeStorageSet(LS_KEY, v ? '1' : '0')
58
58
  }
@@ -0,0 +1,42 @@
1
+ function canUseLocalStorage(): boolean {
2
+ return typeof window !== 'undefined' && !!window.localStorage
3
+ }
4
+
5
+ export function safeStorageGet(key: string): string | null {
6
+ if (!canUseLocalStorage()) return null
7
+ try {
8
+ return window.localStorage.getItem(key)
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ export function safeStorageSet(key: string, value: string): boolean {
15
+ if (!canUseLocalStorage()) return false
16
+ try {
17
+ window.localStorage.setItem(key, value)
18
+ return true
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ export function safeStorageRemove(key: string): boolean {
25
+ if (!canUseLocalStorage()) return false
26
+ try {
27
+ window.localStorage.removeItem(key)
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ export function safeStorageGetJson<T>(key: string, fallback: T): T {
35
+ const raw = safeStorageGet(key)
36
+ if (!raw) return fallback
37
+ try {
38
+ return JSON.parse(raw) as T
39
+ } catch {
40
+ return fallback
41
+ }
42
+ }
@@ -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