@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.
- 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/app/page.tsx +7 -3
- 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/auth/access-key-gate.tsx +22 -11
- 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 +14 -3
- 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/tool-call-bubble.test.ts +28 -0
- package/src/components/chat/tool-call-bubble.tsx +13 -1
- 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 +7 -6
- 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/input/chat-input.tsx +5 -4
- 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 +23 -9
- package/src/components/logs/log-list.tsx +7 -7
- 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/new-session-sheet.tsx +4 -3
- 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/hooks/use-media-query.ts +30 -4
- package/src/lib/api-client.ts +6 -18
- package/src/lib/fetch-timeout.ts +17 -0
- package/src/lib/notification-sounds.ts +4 -4
- package/src/lib/safe-storage.ts +42 -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-output.test.ts +29 -0
- package/src/lib/server/session-tools/web-output.ts +16 -0
- package/src/lib/server/session-tools/web.ts +7 -3
- 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 +6 -1
- package/src/stores/use-app-store.ts +17 -11
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 = () =>
|
|
41
|
+
const getSnapshot = () => getMatch(query)
|
|
16
42
|
|
|
17
43
|
// Return false during SSR — matches initial client render before hydration
|
|
18
44
|
const getServerSnapshot = () => false
|
package/src/lib/api-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
+
safeStorageSet(ACCESS_KEY_STORAGE, key)
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function clearStoredAccessKey() {
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'##
|
|
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
|