@swarmclawai/swarmclaw 0.6.0 → 0.6.2
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 +15 -2
- package/bin/server-cmd.js +1 -0
- package/package.json +2 -1
- package/src/app/api/canvas/[sessionId]/route.ts +31 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
- package/src/app/api/connectors/[id]/route.ts +1 -0
- package/src/app/api/connectors/route.ts +2 -1
- package/src/app/api/files/open/route.ts +43 -0
- package/src/app/api/search/route.ts +9 -7
- package/src/app/api/sessions/[id]/messages/route.ts +70 -2
- package/src/app/api/sessions/[id]/route.ts +4 -0
- package/src/app/api/tasks/metrics/route.ts +101 -0
- package/src/app/api/tasks/route.ts +17 -2
- package/src/app/api/tts/route.ts +3 -2
- package/src/app/api/tts/stream/route.ts +3 -2
- package/src/app/api/uploads/[filename]/route.ts +19 -34
- package/src/app/api/uploads/route.ts +94 -0
- package/src/app/globals.css +5 -0
- package/src/cli/index.js +16 -1
- package/src/cli/spec.js +26 -0
- package/src/components/agents/agent-card.tsx +3 -3
- package/src/components/agents/agent-chat-list.tsx +29 -6
- package/src/components/agents/agent-sheet.tsx +66 -4
- package/src/components/agents/inspector-panel.tsx +81 -6
- package/src/components/agents/openclaw-skills-panel.tsx +32 -3
- package/src/components/agents/personality-builder.tsx +42 -14
- package/src/components/agents/soul-library-picker.tsx +89 -0
- package/src/components/canvas/canvas-panel.tsx +96 -0
- package/src/components/chat/activity-moment.tsx +8 -4
- package/src/components/chat/chat-area.tsx +46 -22
- package/src/components/chat/chat-header.tsx +455 -286
- package/src/components/chat/chat-preview-panel.tsx +1 -2
- package/src/components/chat/delegation-banner.tsx +371 -0
- package/src/components/chat/file-path-chip.tsx +23 -2
- package/src/components/chat/heartbeat-history-panel.tsx +269 -0
- package/src/components/chat/message-bubble.tsx +315 -25
- package/src/components/chat/message-list.tsx +180 -7
- package/src/components/chat/streaming-bubble.tsx +68 -1
- package/src/components/chat/tool-call-bubble.tsx +45 -3
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/chatroom-list.tsx +8 -1
- package/src/components/chatrooms/chatroom-message.tsx +8 -3
- package/src/components/chatrooms/chatroom-view.tsx +3 -3
- package/src/components/connectors/connector-list.tsx +168 -90
- package/src/components/connectors/connector-sheet.tsx +68 -16
- package/src/components/home/home-view.tsx +1 -1
- package/src/components/input/chat-input.tsx +28 -2
- package/src/components/layout/app-layout.tsx +19 -2
- package/src/components/projects/project-detail.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +260 -127
- package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/chatroom-picker-list.tsx +61 -0
- package/src/components/shared/connector-platform-icon.tsx +51 -4
- package/src/components/shared/icon-button.tsx +16 -2
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/shared/search-dialog.tsx +17 -10
- package/src/components/shared/settings/section-embedding.tsx +48 -13
- package/src/components/shared/settings/section-orchestrator.tsx +46 -15
- package/src/components/shared/settings/section-storage.tsx +206 -0
- package/src/components/shared/settings/section-user-preferences.tsx +18 -0
- package/src/components/shared/settings/section-voice.tsx +42 -21
- package/src/components/shared/settings/section-web-search.tsx +30 -6
- package/src/components/shared/settings/settings-page.tsx +3 -1
- package/src/components/shared/settings/storage-browser.tsx +259 -0
- package/src/components/tasks/task-card.tsx +14 -1
- package/src/components/tasks/task-sheet.tsx +328 -3
- package/src/components/usage/metrics-dashboard.tsx +90 -6
- package/src/hooks/use-continuous-speech.ts +10 -4
- package/src/hooks/use-voice-conversation.ts +53 -10
- package/src/hooks/use-ws.ts +4 -2
- package/src/lib/providers/anthropic.ts +13 -7
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/providers/openai.ts +13 -7
- package/src/lib/server/chat-execution.ts +51 -11
- package/src/lib/server/chatroom-helpers.ts +146 -0
- package/src/lib/server/connectors/manager.ts +218 -7
- package/src/lib/server/heartbeat-service.ts +8 -1
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/memory-consolidation.ts +15 -2
- package/src/lib/server/memory-db.ts +134 -6
- package/src/lib/server/mime.ts +51 -0
- package/src/lib/server/openclaw-gateway.ts +2 -2
- package/src/lib/server/orchestrator-lg.ts +2 -0
- package/src/lib/server/orchestrator.ts +5 -2
- package/src/lib/server/playwright-proxy.mjs +2 -3
- package/src/lib/server/prompt-runtime-context.ts +53 -0
- package/src/lib/server/queue.ts +52 -7
- package/src/lib/server/session-tools/canvas.ts +67 -0
- package/src/lib/server/session-tools/connector.ts +83 -9
- package/src/lib/server/session-tools/crud.ts +21 -0
- package/src/lib/server/session-tools/delegate.ts +68 -4
- package/src/lib/server/session-tools/git.ts +71 -0
- package/src/lib/server/session-tools/http.ts +57 -0
- package/src/lib/server/session-tools/index.ts +8 -0
- package/src/lib/server/session-tools/memory.ts +1 -0
- package/src/lib/server/session-tools/search-providers.ts +16 -8
- package/src/lib/server/session-tools/subagent.ts +106 -0
- package/src/lib/server/session-tools/web.ts +115 -4
- package/src/lib/server/stream-agent-chat.ts +32 -10
- package/src/lib/server/task-mention.ts +41 -0
- package/src/lib/sessions.ts +10 -0
- package/src/lib/soul-library.ts +103 -0
- package/src/lib/task-dedupe.ts +26 -0
- package/src/lib/tool-definitions.ts +2 -0
- package/src/lib/tts.ts +2 -2
- package/src/stores/use-app-store.ts +5 -1
- package/src/stores/use-chat-store.ts +65 -2
- package/src/types/index.ts +32 -2
|
@@ -1,40 +1,68 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useRef, useState } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { useContinuousSpeech } from './use-continuous-speech'
|
|
5
5
|
import { SentenceAccumulator, AudioChunkQueue, fetchStreamTts } from '@/lib/tts-stream'
|
|
6
6
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
7
7
|
|
|
8
8
|
export type VoiceConversationState = 'idle' | 'listening' | 'processing' | 'speaking'
|
|
9
9
|
|
|
10
|
+
/** Max time to wait in 'processing' before falling back to listening (30s). */
|
|
11
|
+
const PROCESSING_TIMEOUT_MS = 30_000
|
|
12
|
+
|
|
10
13
|
export function useVoiceConversation() {
|
|
11
|
-
const [active, setActive] = useState(false)
|
|
12
14
|
const [voiceState, setVoiceState] = useState<VoiceConversationState>('idle')
|
|
13
15
|
const accumulatorRef = useRef<SentenceAccumulator | null>(null)
|
|
14
16
|
const queueRef = useRef<AudioChunkQueue | null>(null)
|
|
17
|
+
const activeRef = useRef(false)
|
|
18
|
+
const processingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
19
|
+
const [resumeNeeded, setResumeNeeded] = useState(0)
|
|
15
20
|
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
16
21
|
|
|
22
|
+
const clearProcessingTimer = () => {
|
|
23
|
+
if (processingTimerRef.current) {
|
|
24
|
+
clearTimeout(processingTimerRef.current)
|
|
25
|
+
processingTimerRef.current = null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
const speech = useContinuousSpeech({
|
|
18
30
|
onUtterance: useCallback((text: string) => {
|
|
19
31
|
setVoiceState('processing')
|
|
20
|
-
// Send the transcribed text as a chat message
|
|
21
32
|
sendMessage(text)
|
|
33
|
+
// Safety net: if no stream events arrive within timeout, resume listening
|
|
34
|
+
clearProcessingTimer()
|
|
35
|
+
processingTimerRef.current = setTimeout(() => {
|
|
36
|
+
if (activeRef.current) {
|
|
37
|
+
setVoiceState('listening')
|
|
38
|
+
setResumeNeeded((n) => n + 1)
|
|
39
|
+
}
|
|
40
|
+
}, PROCESSING_TIMEOUT_MS)
|
|
22
41
|
}, [sendMessage]),
|
|
23
42
|
})
|
|
24
43
|
|
|
44
|
+
// When resumeNeeded increments, call speech.resume
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (resumeNeeded > 0) speech.resume()
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [resumeNeeded])
|
|
49
|
+
|
|
25
50
|
// Called by the chat store's onStreamEvent callback
|
|
26
51
|
const handleStreamEvent = useCallback((event: { t: string; text?: string }) => {
|
|
27
|
-
if (!
|
|
52
|
+
if (!activeRef.current) return
|
|
28
53
|
|
|
29
54
|
if (event.t === 'd' && event.text) {
|
|
55
|
+
clearProcessingTimer()
|
|
30
56
|
setVoiceState('speaking')
|
|
31
57
|
if (!accumulatorRef.current) {
|
|
32
58
|
const queue = new AudioChunkQueue()
|
|
33
59
|
queueRef.current = queue
|
|
34
60
|
queue.onComplete = () => {
|
|
35
61
|
// Resume listening after TTS playback finishes
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
if (activeRef.current) {
|
|
63
|
+
setVoiceState('listening')
|
|
64
|
+
speech.resume()
|
|
65
|
+
}
|
|
38
66
|
}
|
|
39
67
|
accumulatorRef.current = new SentenceAccumulator((sentence) => {
|
|
40
68
|
queue.enqueue(fetchStreamTts(sentence))
|
|
@@ -42,16 +70,30 @@ export function useVoiceConversation() {
|
|
|
42
70
|
}
|
|
43
71
|
accumulatorRef.current.push(event.text)
|
|
44
72
|
} else if (event.t === 'done') {
|
|
73
|
+
clearProcessingTimer()
|
|
45
74
|
// Flush remaining text to TTS
|
|
46
75
|
if (accumulatorRef.current) {
|
|
47
76
|
accumulatorRef.current.flush()
|
|
48
77
|
accumulatorRef.current = null
|
|
78
|
+
} else {
|
|
79
|
+
// No text was streamed (empty response or error) — resume listening
|
|
80
|
+
if (activeRef.current) {
|
|
81
|
+
setVoiceState('listening')
|
|
82
|
+
speech.resume()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} else if (event.t === 'err') {
|
|
86
|
+
// Error from the LLM — resume listening instead of staying stuck
|
|
87
|
+
clearProcessingTimer()
|
|
88
|
+
if (activeRef.current) {
|
|
89
|
+
setVoiceState('listening')
|
|
90
|
+
speech.resume()
|
|
49
91
|
}
|
|
50
92
|
}
|
|
51
|
-
}, [
|
|
93
|
+
}, [speech])
|
|
52
94
|
|
|
53
95
|
const start = useCallback(() => {
|
|
54
|
-
|
|
96
|
+
activeRef.current = true
|
|
55
97
|
setVoiceState('listening')
|
|
56
98
|
// Register the stream event handler on the chat store
|
|
57
99
|
useChatStore.setState({ onStreamEvent: handleStreamEvent, voiceConversationActive: true })
|
|
@@ -59,8 +101,9 @@ export function useVoiceConversation() {
|
|
|
59
101
|
}, [speech, handleStreamEvent])
|
|
60
102
|
|
|
61
103
|
const stop = useCallback(() => {
|
|
62
|
-
|
|
104
|
+
activeRef.current = false
|
|
63
105
|
setVoiceState('idle')
|
|
106
|
+
clearProcessingTimer()
|
|
64
107
|
speech.stop()
|
|
65
108
|
queueRef.current?.stop()
|
|
66
109
|
queueRef.current = null
|
|
@@ -69,7 +112,7 @@ export function useVoiceConversation() {
|
|
|
69
112
|
}, [speech])
|
|
70
113
|
|
|
71
114
|
return {
|
|
72
|
-
active,
|
|
115
|
+
active: activeRef.current || voiceState !== 'idle',
|
|
73
116
|
state: voiceState,
|
|
74
117
|
interimText: speech.interimText,
|
|
75
118
|
transcript: speech.transcript,
|
package/src/hooks/use-ws.ts
CHANGED
|
@@ -32,8 +32,10 @@ export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
|
32
32
|
let fallbackId: ReturnType<typeof setInterval> | null = null
|
|
33
33
|
const cb = () => handlerRef.current()
|
|
34
34
|
|
|
35
|
-
// When page becomes visible again, fire an immediate refresh
|
|
36
|
-
|
|
35
|
+
// When page becomes visible again, fire an immediate refresh —
|
|
36
|
+
// but only for topics that use fallback polling (i.e. data-fetch topics).
|
|
37
|
+
// Event-only topics (like heartbeat pulses) should never fire from this effect.
|
|
38
|
+
if (isActive && fallbackMsRef.current && fallbackMsRef.current > 0) {
|
|
37
39
|
cb()
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -23,9 +23,9 @@ function fileToContentBlocks(filePath: string): any[] {
|
|
|
23
23
|
return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
26
|
+
export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
27
27
|
return new Promise((resolve) => {
|
|
28
|
-
const messages = buildMessages(session, message, imagePath, loadHistory)
|
|
28
|
+
const messages = buildMessages(session, message, imagePath, loadHistory, imageUrl)
|
|
29
29
|
const model = session.model || 'claude-sonnet-4-6'
|
|
30
30
|
let usageInput = 0
|
|
31
31
|
let usageOutput = 0
|
|
@@ -122,14 +122,19 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
122
122
|
})
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function
|
|
125
|
+
function urlToImageBlock(url: string): { type: string; source: { type: string; url: string } } {
|
|
126
|
+
return { type: 'image', source: { type: 'url', url } }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildMessages(session: any, message: string, imagePath: string | undefined, loadHistory: (id: string) => any[], imageUrl?: string) {
|
|
126
130
|
const msgs: Array<{ role: string; content: any }> = []
|
|
127
131
|
|
|
128
132
|
if (loadHistory) {
|
|
129
133
|
const history = loadHistory(session.id).slice(-40)
|
|
130
134
|
for (const m of history) {
|
|
131
|
-
if (m.role === 'user' && m.imagePath) {
|
|
132
|
-
const blocks = fileToContentBlocks(m.imagePath)
|
|
135
|
+
if (m.role === 'user' && (m.imagePath || m.imageUrl)) {
|
|
136
|
+
const blocks = m.imagePath ? fileToContentBlocks(m.imagePath) : []
|
|
137
|
+
if (m.imageUrl) blocks.push(urlToImageBlock(m.imageUrl))
|
|
133
138
|
msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: m.text }] })
|
|
134
139
|
} else {
|
|
135
140
|
msgs.push({ role: m.role, content: m.text })
|
|
@@ -138,8 +143,9 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
|
|
|
138
143
|
}
|
|
139
144
|
|
|
140
145
|
// Current message with optional attachment
|
|
141
|
-
if (imagePath) {
|
|
142
|
-
const blocks = fileToContentBlocks(imagePath)
|
|
146
|
+
if (imagePath || imageUrl) {
|
|
147
|
+
const blocks = imagePath ? fileToContentBlocks(imagePath) : []
|
|
148
|
+
if (imageUrl) blocks.push(urlToImageBlock(imageUrl))
|
|
143
149
|
msgs.push({ role: 'user', content: [...blocks, { type: 'text', text: message }] })
|
|
144
150
|
} else {
|
|
145
151
|
msgs.push({ role: 'user', content: message })
|
|
@@ -43,9 +43,9 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
|
|
|
43
43
|
return [{ type: 'text', text: `[Attached file: ${name}]` }]
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
46
|
+
export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
|
|
47
47
|
return new Promise(async (resolve) => {
|
|
48
|
-
const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
|
|
48
|
+
const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory, imageUrl)
|
|
49
49
|
const model = session.model || 'gpt-4o'
|
|
50
50
|
|
|
51
51
|
const payload = JSON.stringify({
|
|
@@ -163,7 +163,11 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
|
|
|
163
163
|
})
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
function urlToImagePart(url: string): { type: string; image_url: { url: string; detail: string } } {
|
|
167
|
+
return { type: 'image_url', image_url: { url, detail: 'auto' } }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[], imageUrl?: string) {
|
|
167
171
|
const msgs: Array<{ role: string; content: any }> = []
|
|
168
172
|
|
|
169
173
|
if (systemPrompt) {
|
|
@@ -173,8 +177,9 @@ async function buildMessages(session: any, message: string, imagePath: string |
|
|
|
173
177
|
if (loadHistory) {
|
|
174
178
|
const history = loadHistory(session.id).slice(-40)
|
|
175
179
|
for (const m of history) {
|
|
176
|
-
if (m.role === 'user' && m.imagePath) {
|
|
177
|
-
const parts = await fileToContentParts(m.imagePath)
|
|
180
|
+
if (m.role === 'user' && (m.imagePath || m.imageUrl)) {
|
|
181
|
+
const parts = m.imagePath ? await fileToContentParts(m.imagePath) : []
|
|
182
|
+
if (m.imageUrl) parts.push(urlToImagePart(m.imageUrl))
|
|
178
183
|
msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
|
|
179
184
|
} else {
|
|
180
185
|
msgs.push({ role: m.role, content: m.text })
|
|
@@ -183,8 +188,9 @@ async function buildMessages(session: any, message: string, imagePath: string |
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
// Current message with optional attachment
|
|
186
|
-
if (imagePath) {
|
|
187
|
-
const parts = await fileToContentParts(imagePath)
|
|
191
|
+
if (imagePath || imageUrl) {
|
|
192
|
+
const parts = imagePath ? await fileToContentParts(imagePath) : []
|
|
193
|
+
if (imageUrl) parts.push(urlToImagePart(imageUrl))
|
|
188
194
|
msgs.push({ role: 'user', content: [...parts, { type: 'text', text: message }] })
|
|
189
195
|
} else {
|
|
190
196
|
msgs.push({ role: 'user', content: message })
|
|
@@ -24,11 +24,20 @@ import { getMemoryDb } from './memory-db'
|
|
|
24
24
|
import { routeTaskIntent } from './capability-router'
|
|
25
25
|
import { notify } from './ws-hub'
|
|
26
26
|
import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
|
|
27
|
-
import
|
|
27
|
+
import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
|
|
28
|
+
import type { Message, MessageToolEvent, SSEEvent, UsageRecord } from '@/types'
|
|
28
29
|
import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
|
|
29
30
|
import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
30
31
|
type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
|
|
31
32
|
|
|
33
|
+
/** Slice history from the most recent context-clear marker forward */
|
|
34
|
+
function applyContextClearBoundary(messages: Message[]): Message[] {
|
|
35
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
36
|
+
if (messages[i].kind === 'context-clear') return messages.slice(i + 1)
|
|
37
|
+
}
|
|
38
|
+
return messages
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
interface SessionWithTools {
|
|
33
42
|
tools?: string[] | null
|
|
34
43
|
}
|
|
@@ -307,11 +316,11 @@ function buildAgentSystemPrompt(session: any): string | undefined {
|
|
|
307
316
|
if (!session.agentId) return undefined
|
|
308
317
|
const agents = loadAgents()
|
|
309
318
|
const agent = agents[session.agentId]
|
|
310
|
-
if (!agent?.systemPrompt && !agent?.soul) return undefined
|
|
311
319
|
|
|
312
320
|
const settings = loadSettings()
|
|
313
321
|
const parts: string[] = []
|
|
314
322
|
if (settings.userPrompt) parts.push(settings.userPrompt)
|
|
323
|
+
parts.push(buildCurrentDateTimePromptContext())
|
|
315
324
|
if (agent.soul) parts.push(agent.soul)
|
|
316
325
|
if (agent.systemPrompt) parts.push(agent.systemPrompt)
|
|
317
326
|
if (agent.skillIds?.length) {
|
|
@@ -321,6 +330,7 @@ function buildAgentSystemPrompt(session: any): string | undefined {
|
|
|
321
330
|
if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
|
|
322
331
|
}
|
|
323
332
|
}
|
|
333
|
+
if (!parts.length) return undefined
|
|
324
334
|
return parts.join('\n\n')
|
|
325
335
|
}
|
|
326
336
|
|
|
@@ -354,13 +364,13 @@ function stripMarkupForHeartbeat(text: string): string {
|
|
|
354
364
|
const HEARTBEAT_OK_RE = /HEARTBEAT_OK[^\w]{0,4}$/
|
|
355
365
|
const NO_MESSAGE_RE = /NO_MESSAGE[^\w]{0,4}$/
|
|
356
366
|
|
|
357
|
-
function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
|
|
367
|
+
function classifyHeartbeatResponse(text: string, ackMaxChars: number, hadToolCalls: boolean): 'suppress' | 'strip' | 'keep' {
|
|
358
368
|
const cleaned = stripMarkupForHeartbeat(text)
|
|
359
369
|
if (cleaned === 'HEARTBEAT_OK' || cleaned === 'NO_MESSAGE') return 'suppress'
|
|
360
370
|
if (HEARTBEAT_OK_RE.test(cleaned) || NO_MESSAGE_RE.test(cleaned)) return 'suppress'
|
|
361
371
|
const stripped = cleaned.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
|
|
362
372
|
if (!stripped) return 'suppress'
|
|
363
|
-
if (stripped.length <= ackMaxChars) return 'suppress'
|
|
373
|
+
if (!hadToolCalls && stripped.length <= ackMaxChars) return 'suppress'
|
|
364
374
|
return stripped.length < cleaned.length ? 'strip' : 'keep'
|
|
365
375
|
}
|
|
366
376
|
|
|
@@ -566,6 +576,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
566
576
|
const toolEvents: MessageToolEvent[] = []
|
|
567
577
|
const streamErrors: string[] = []
|
|
568
578
|
|
|
579
|
+
let thinkingText = ''
|
|
569
580
|
const emit = (ev: SSEEvent) => {
|
|
570
581
|
if (ev.t === 'err' && typeof ev.text === 'string') {
|
|
571
582
|
const trimmed = ev.text.trim()
|
|
@@ -574,6 +585,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
574
585
|
if (streamErrors.length > 8) streamErrors.shift()
|
|
575
586
|
}
|
|
576
587
|
}
|
|
588
|
+
if (ev.t === 'thinking' && ev.text) {
|
|
589
|
+
thinkingText += ev.text
|
|
590
|
+
}
|
|
577
591
|
collectToolEvent(ev, toolEvents)
|
|
578
592
|
onEvent?.(ev)
|
|
579
593
|
}
|
|
@@ -608,9 +622,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
608
622
|
const hasTools = !!sessionForRun.tools?.length && !NON_LANGGRAPH_PROVIDER_IDS.has(providerType)
|
|
609
623
|
|
|
610
624
|
try {
|
|
611
|
-
// Heartbeat runs
|
|
612
|
-
//
|
|
613
|
-
|
|
625
|
+
// Heartbeat runs get a small tail of recent messages so the agent can see
|
|
626
|
+
// prior findings and avoid repeating the same searches. Full history is
|
|
627
|
+
// skipped to avoid blowing the context window on long-lived sessions.
|
|
628
|
+
const heartbeatHistory = isAutoRunNoHistory
|
|
629
|
+
? getSessionMessages(sessionId).slice(-6)
|
|
630
|
+
: undefined
|
|
614
631
|
|
|
615
632
|
console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
|
|
616
633
|
|
|
@@ -623,7 +640,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
623
640
|
apiKey,
|
|
624
641
|
systemPrompt,
|
|
625
642
|
write: (raw) => parseAndEmit(raw),
|
|
626
|
-
history: heartbeatHistory ?? getSessionMessages(sessionId),
|
|
643
|
+
history: heartbeatHistory ?? applyContextClearBoundary(getSessionMessages(sessionId)),
|
|
627
644
|
signal: abortController.signal,
|
|
628
645
|
})).fullText
|
|
629
646
|
: await provider.handler.streamChat({
|
|
@@ -634,7 +651,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
634
651
|
systemPrompt,
|
|
635
652
|
write: (raw: string) => parseAndEmit(raw),
|
|
636
653
|
active,
|
|
637
|
-
loadHistory: isAutoRunNoHistory ? () =>
|
|
654
|
+
loadHistory: isAutoRunNoHistory ? () => getSessionMessages(sessionId).slice(-6) : (sid: string) => applyContextClearBoundary(getSessionMessages(sid)),
|
|
638
655
|
onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
|
|
639
656
|
})
|
|
640
657
|
} catch (err: any) {
|
|
@@ -723,7 +740,13 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
723
740
|
const toolOutput = await selectedTool.invoke(args)
|
|
724
741
|
const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput)
|
|
725
742
|
emit({ t: 'tool_result', toolName, toolOutput: outputText })
|
|
726
|
-
|
|
743
|
+
// Don't overwrite fullResponse with raw tool output — it's already captured
|
|
744
|
+
// in toolEvents. Only set a brief notice when the LLM produced no text,
|
|
745
|
+
// so the message bubble isn't empty.
|
|
746
|
+
if (!fullResponse.trim() && outputText?.trim()) {
|
|
747
|
+
const label = toolName.replace(/_/g, ' ')
|
|
748
|
+
fullResponse = `Used **${label}** — see tool output above for details.`
|
|
749
|
+
}
|
|
727
750
|
calledNames.add(toolName)
|
|
728
751
|
return true
|
|
729
752
|
} catch (forceErr: any) {
|
|
@@ -869,7 +892,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
869
892
|
const heartbeatConfig = input.heartbeatConfig
|
|
870
893
|
let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
|
|
871
894
|
if (isHeartbeatRun && textForPersistence.length > 0) {
|
|
872
|
-
heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300)
|
|
895
|
+
heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300, toolEvents.length > 0)
|
|
873
896
|
}
|
|
874
897
|
|
|
875
898
|
// Emit WS notification for every heartbeat completion so UI can show pulse
|
|
@@ -924,6 +947,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
924
947
|
role: 'assistant',
|
|
925
948
|
text: persistedText,
|
|
926
949
|
time: Date.now(),
|
|
950
|
+
thinking: thinkingText || undefined,
|
|
927
951
|
toolEvents: toolEvents.length ? toolEvents : undefined,
|
|
928
952
|
kind: persistedKind,
|
|
929
953
|
})
|
|
@@ -964,6 +988,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
964
988
|
// Best effort — connector manager may not be loaded
|
|
965
989
|
}
|
|
966
990
|
}
|
|
991
|
+
|
|
992
|
+
// Auto-discover connectors linked to this agent when no explicit target is set
|
|
993
|
+
if (isHeartbeatRun && !heartbeatConfig?.target && heartbeatConfig?.showAlerts !== false && session.agentId) {
|
|
994
|
+
try {
|
|
995
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
996
|
+
const { listRunningConnectors: listRunning, sendConnectorMessage: sendMsg } = require('./connectors/manager')
|
|
997
|
+
const agentConnectors = listRunning().filter((c: { agentId: string | null; recentChannelId: string | null; supportsSend: boolean }) =>
|
|
998
|
+
c.agentId === session.agentId && c.recentChannelId && c.supportsSend
|
|
999
|
+
)
|
|
1000
|
+
for (const conn of agentConnectors) {
|
|
1001
|
+
sendMsg({ connectorId: conn.id, channelId: conn.recentChannelId, text: persistedText }).catch(() => {})
|
|
1002
|
+
}
|
|
1003
|
+
} catch {
|
|
1004
|
+
// Best effort — connector manager may not be loaded
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
967
1007
|
}
|
|
968
1008
|
|
|
969
1009
|
const autoMemoryEligible = shouldStoreAutoMemoryNote({
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { loadSettings, loadSkills, loadCredentials, decryptKey } from './storage'
|
|
2
|
+
import { buildCurrentDateTimePromptContext } from './prompt-runtime-context'
|
|
3
|
+
import type { Chatroom, Agent, Session, Message } from '@/types'
|
|
4
|
+
|
|
5
|
+
/** Resolve API key from an agent's credentialId */
|
|
6
|
+
export function resolveApiKey(credentialId: string | null | undefined): string | null {
|
|
7
|
+
if (!credentialId) return null
|
|
8
|
+
const creds = loadCredentials()
|
|
9
|
+
const cred = creds[credentialId]
|
|
10
|
+
if (!cred?.encryptedKey) return null
|
|
11
|
+
try { return decryptKey(cred.encryptedKey) } catch { return null }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Parse @mentions from message text, returns matching agentIds */
|
|
15
|
+
export function parseMentions(text: string, agents: Record<string, Agent>, memberIds: string[]): string[] {
|
|
16
|
+
if (/@all\b/i.test(text)) return [...memberIds]
|
|
17
|
+
const mentionPattern = /@(\S+)/g
|
|
18
|
+
const mentioned: string[] = []
|
|
19
|
+
let match: RegExpExecArray | null
|
|
20
|
+
while ((match = mentionPattern.exec(text)) !== null) {
|
|
21
|
+
const name = match[1].toLowerCase()
|
|
22
|
+
for (const id of memberIds) {
|
|
23
|
+
const agent = agents[id]
|
|
24
|
+
if (agent && agent.name.toLowerCase().replace(/\s+/g, '') === name) {
|
|
25
|
+
if (!mentioned.includes(id)) mentioned.push(id)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return mentioned
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build chatroom context as a system prompt addendum with agent profiles and collaboration guidelines */
|
|
33
|
+
export function buildChatroomSystemPrompt(chatroom: Chatroom, agents: Record<string, Agent>, agentId: string): string {
|
|
34
|
+
const selfAgent = agents[agentId]
|
|
35
|
+
const selfName = selfAgent?.name || agentId
|
|
36
|
+
|
|
37
|
+
// Build team profiles with capabilities
|
|
38
|
+
const teamProfiles = chatroom.agentIds
|
|
39
|
+
.filter((id) => id !== agentId)
|
|
40
|
+
.map((id) => {
|
|
41
|
+
const a = agents[id]
|
|
42
|
+
if (!a) return null
|
|
43
|
+
const tools = a.tools?.length ? `Tools: ${a.tools.join(', ')}` : 'No specialized tools'
|
|
44
|
+
const desc = a.description || a.soul || 'No description'
|
|
45
|
+
return `- **${a.name}**: ${desc}\n ${tools}`
|
|
46
|
+
})
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join('\n')
|
|
49
|
+
|
|
50
|
+
const recentMessages = chatroom.messages.slice(-30).map((m) => {
|
|
51
|
+
return `[${m.senderName}]: ${m.text}`
|
|
52
|
+
}).join('\n')
|
|
53
|
+
|
|
54
|
+
const memberCount = chatroom.agentIds.length
|
|
55
|
+
const otherNames = chatroom.agentIds
|
|
56
|
+
.filter((id) => id !== agentId)
|
|
57
|
+
.map((id) => agents[id]?.name)
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
|
|
60
|
+
return [
|
|
61
|
+
`## Chatroom Context`,
|
|
62
|
+
`You are **${selfName}** in a group chatroom called "${chatroom.name}" with ${memberCount} participants (you, ${otherNames.join(', ') || 'others'}, and the user).`,
|
|
63
|
+
selfAgent?.description ? `Your role: ${selfAgent.description}` : '',
|
|
64
|
+
selfAgent?.tools?.length ? `Your available tools: ${selfAgent.tools.join(', ')}` : '',
|
|
65
|
+
'',
|
|
66
|
+
'## Team Members',
|
|
67
|
+
teamProfiles || '(no other agents)',
|
|
68
|
+
'',
|
|
69
|
+
'## How to Behave in This Chatroom',
|
|
70
|
+
'- **You are in a group chat.** Talk like you are in a real-time conversation with teammates — be direct, casual, and concise.',
|
|
71
|
+
'- **Be yourself.** Respond with personality. Don\'t give generic "let me know if you need anything" responses. Actually engage with what was said.',
|
|
72
|
+
'- **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.',
|
|
73
|
+
'- **Keep responses short** unless depth is needed. A few sentences is usually enough. This is a chat, not an essay.',
|
|
74
|
+
'- **@mention teammates** only when you genuinely need their specific expertise. Don\'t tag people just to be polite.',
|
|
75
|
+
'- **Don\'t narrate your capabilities** unless asked. Just demonstrate them by doing things.',
|
|
76
|
+
'- **Read the room.** Look at recent messages to understand context. Don\'t repeat what others already said.',
|
|
77
|
+
'',
|
|
78
|
+
'## Recent Messages',
|
|
79
|
+
recentMessages || '(no messages yet)',
|
|
80
|
+
].filter((line) => line !== undefined).join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build a synthetic session object for an agent in a chatroom */
|
|
84
|
+
export function buildSyntheticSession(agent: Agent, chatroomId: string): Session {
|
|
85
|
+
return {
|
|
86
|
+
id: `chatroom-${chatroomId}-${agent.id}`,
|
|
87
|
+
name: `Chatroom session for ${agent.name}`,
|
|
88
|
+
cwd: process.cwd(),
|
|
89
|
+
user: 'chatroom',
|
|
90
|
+
provider: agent.provider,
|
|
91
|
+
model: agent.model,
|
|
92
|
+
credentialId: agent.credentialId ?? null,
|
|
93
|
+
fallbackCredentialIds: agent.fallbackCredentialIds,
|
|
94
|
+
apiEndpoint: agent.apiEndpoint ?? null,
|
|
95
|
+
claudeSessionId: null,
|
|
96
|
+
messages: [],
|
|
97
|
+
createdAt: Date.now(),
|
|
98
|
+
lastActiveAt: Date.now(),
|
|
99
|
+
tools: agent.tools || [],
|
|
100
|
+
agentId: agent.id,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build agent's system prompt including skills */
|
|
105
|
+
export function buildAgentSystemPromptForChatroom(agent: Agent): string {
|
|
106
|
+
const settings = loadSettings()
|
|
107
|
+
const parts: string[] = []
|
|
108
|
+
if (settings.userPrompt) parts.push(settings.userPrompt)
|
|
109
|
+
parts.push(buildCurrentDateTimePromptContext())
|
|
110
|
+
if (agent.soul) parts.push(agent.soul)
|
|
111
|
+
if (agent.systemPrompt) parts.push(agent.systemPrompt)
|
|
112
|
+
if (agent.skillIds?.length) {
|
|
113
|
+
const allSkills = loadSkills()
|
|
114
|
+
for (const skillId of agent.skillIds) {
|
|
115
|
+
const skill = allSkills[skillId]
|
|
116
|
+
if (skill?.content) parts.push(`## Skill: ${skill.name}\n${skill.content}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return parts.join('\n\n')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Convert chatroom messages to Message history format for LLM */
|
|
123
|
+
export function buildHistoryForAgent(chatroom: Chatroom, agentId: string, imagePath?: string, attachedFiles?: string[]): Message[] {
|
|
124
|
+
const history = chatroom.messages.slice(-50).map((m) => {
|
|
125
|
+
let msgText = `[${m.senderName}]: ${m.text}`
|
|
126
|
+
// Include attachment info in history
|
|
127
|
+
if (m.attachedFiles?.length) {
|
|
128
|
+
const names = m.attachedFiles.map((f) => f.split('/').pop()).join(', ')
|
|
129
|
+
msgText += `\n[Attached: ${names}]`
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
role: m.senderId === agentId ? 'assistant' as const : 'user' as const,
|
|
133
|
+
text: msgText,
|
|
134
|
+
time: m.time,
|
|
135
|
+
...(m.imagePath ? { imagePath: m.imagePath } : {}),
|
|
136
|
+
...(m.attachedFiles ? { attachedFiles: m.attachedFiles } : {}),
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
// Pass through imagePath/attachedFiles from the current message to the last history entry
|
|
140
|
+
if (history.length > 0 && (imagePath || attachedFiles)) {
|
|
141
|
+
const last = history[history.length - 1]
|
|
142
|
+
if (imagePath && !last.imagePath) last.imagePath = imagePath
|
|
143
|
+
if (attachedFiles && !last.attachedFiles) last.attachedFiles = attachedFiles
|
|
144
|
+
}
|
|
145
|
+
return history
|
|
146
|
+
}
|