@swarmclawai/swarmclaw 0.3.0 → 0.4.0
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 +20 -11
- package/bin/server-cmd.js +14 -7
- package/bin/swarmclaw.js +3 -1
- package/bin/update-cmd.js +120 -0
- package/next.config.ts +2 -0
- package/package.json +3 -1
- package/src/app/api/agents/[id]/route.ts +3 -0
- package/src/app/api/agents/[id]/thread/route.ts +2 -1
- package/src/app/api/agents/route.ts +5 -1
- package/src/app/api/auth/route.ts +3 -1
- package/src/app/api/claude-skills/route.ts +3 -1
- package/src/app/api/connectors/[id]/route.ts +4 -0
- package/src/app/api/connectors/route.ts +6 -1
- package/src/app/api/credentials/route.ts +3 -1
- package/src/app/api/daemon/route.ts +6 -1
- package/src/app/api/ip/route.ts +3 -1
- package/src/app/api/mcp-servers/route.ts +3 -1
- package/src/app/api/orchestrator/graph/route.ts +25 -0
- package/src/app/api/plugins/marketplace/route.ts +3 -1
- package/src/app/api/plugins/route.ts +3 -1
- package/src/app/api/providers/[id]/route.ts +3 -0
- package/src/app/api/providers/configs/route.ts +3 -1
- package/src/app/api/providers/route.ts +5 -1
- package/src/app/api/schedules/[id]/route.ts +3 -0
- package/src/app/api/schedules/route.ts +6 -1
- package/src/app/api/secrets/route.ts +3 -1
- package/src/app/api/sessions/[id]/chat/route.ts +5 -2
- package/src/app/api/sessions/route.ts +9 -2
- package/src/app/api/settings/route.ts +3 -1
- package/src/app/api/setup/doctor/route.ts +1 -0
- package/src/app/api/setup/openclaw-device/route.ts +3 -1
- package/src/app/api/skills/route.ts +3 -1
- package/src/app/api/tasks/[id]/approve/route.ts +73 -0
- package/src/app/api/tasks/[id]/route.ts +3 -0
- package/src/app/api/tasks/route.ts +3 -0
- package/src/app/api/usage/route.ts +3 -1
- package/src/app/api/version/route.ts +3 -1
- package/src/app/api/webhooks/[id]/route.ts +2 -1
- package/src/app/api/webhooks/route.ts +3 -1
- package/src/app/icon.svg +58 -0
- package/src/app/page.tsx +8 -2
- package/src/cli/index.js +1 -9
- package/src/cli/index.ts +51 -1
- package/src/cli/spec.js +0 -8
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-sheet.tsx +63 -80
- package/src/components/chat/chat-area.tsx +44 -30
- package/src/components/chat/chat-tool-toggles.tsx +12 -53
- package/src/components/chat/message-bubble.tsx +110 -42
- package/src/components/chat/tool-call-bubble.tsx +41 -3
- package/src/components/chat/tool-request-banner.tsx +1 -9
- package/src/components/connectors/connector-list.tsx +3 -8
- package/src/components/connectors/connector-sheet.tsx +24 -29
- package/src/components/input/chat-input.tsx +72 -56
- package/src/components/knowledge/knowledge-list.tsx +27 -31
- package/src/components/layout/app-layout.tsx +92 -71
- package/src/components/layout/daemon-indicator.tsx +3 -5
- package/src/components/logs/log-list.tsx +5 -9
- package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
- package/src/components/memory/memory-detail.tsx +1 -1
- package/src/components/plugins/plugin-list.tsx +227 -27
- package/src/components/providers/provider-list.tsx +46 -13
- package/src/components/providers/provider-sheet.tsx +0 -45
- package/src/components/runs/run-list.tsx +6 -15
- package/src/components/schedules/schedule-card.tsx +54 -4
- package/src/components/schedules/schedule-list.tsx +6 -3
- package/src/components/schedules/schedule-sheet.tsx +0 -47
- package/src/components/secrets/secrets-list.tsx +20 -2
- package/src/components/sessions/new-session-sheet.tsx +8 -9
- package/src/components/shared/connector-platform-icon.tsx +22 -20
- package/src/components/shared/model-combobox.tsx +148 -0
- package/src/components/shared/settings/section-heartbeat.tsx +7 -39
- package/src/components/shared/settings/section-orchestrator.tsx +8 -9
- package/src/components/skills/skill-list.tsx +260 -34
- package/src/components/skills/skill-sheet.tsx +0 -45
- package/src/components/tasks/task-board.tsx +3 -6
- package/src/components/tasks/task-card.tsx +43 -1
- package/src/components/tasks/task-list.tsx +3 -5
- package/src/components/tasks/task-sheet.tsx +0 -44
- package/src/components/usage/usage-list.tsx +12 -4
- package/src/hooks/use-ws.ts +66 -0
- package/src/instrumentation.ts +2 -0
- package/src/lib/chat.ts +14 -2
- package/src/lib/providers/anthropic.ts +1 -1
- package/src/lib/providers/index.ts +2 -0
- package/src/lib/providers/ollama.ts +1 -1
- package/src/lib/providers/openai.ts +33 -12
- package/src/lib/server/chat-execution.ts +19 -4
- package/src/lib/server/connectors/manager.ts +9 -3
- package/src/lib/server/context-manager.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -0
- package/src/lib/server/data-dir.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +67 -3
- package/src/lib/server/langgraph-checkpoint.ts +274 -0
- package/src/lib/server/main-agent-loop.ts +61 -2
- package/src/lib/server/orchestrator-lg.ts +394 -13
- package/src/lib/server/orchestrator.ts +25 -5
- package/src/lib/server/queue.ts +17 -3
- package/src/lib/server/session-run-manager.ts +6 -1
- package/src/lib/server/session-tools/delegate.ts +2 -2
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/sandbox.ts +164 -0
- package/src/lib/server/storage-mcp.test.ts +25 -2
- package/src/lib/server/storage.ts +24 -7
- package/src/lib/server/stream-agent-chat.ts +77 -22
- package/src/lib/server/task-validation.test.ts +23 -0
- package/src/lib/server/task-validation.ts +5 -3
- package/src/lib/server/ws-hub.ts +85 -0
- package/src/lib/tool-definitions.ts +42 -0
- package/src/lib/upload.ts +7 -1
- package/src/lib/ws-client.ts +124 -0
- package/src/stores/use-chat-store.ts +33 -13
- package/src/types/index.ts +8 -1
- package/src/app/api/agents/generate/route.ts +0 -42
- package/src/app/api/generate/info/route.ts +0 -12
- package/src/app/api/generate/route.ts +0 -106
- package/src/app/favicon.ico +0 -0
- package/src/components/shared/ai-gen-block.tsx +0 -77
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { subscribeWs, unsubscribeWs, isWsConnected } from '@/lib/ws-client'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to a WebSocket topic. Calls `handler` on push events.
|
|
8
|
+
* Falls back to polling at `fallbackMs` when WS is disconnected.
|
|
9
|
+
*/
|
|
10
|
+
export function useWs(topic: string, handler: () => void, fallbackMs?: number) {
|
|
11
|
+
const handlerRef = useRef(handler)
|
|
12
|
+
handlerRef.current = handler
|
|
13
|
+
const fallbackMsRef = useRef(fallbackMs)
|
|
14
|
+
fallbackMsRef.current = fallbackMs
|
|
15
|
+
|
|
16
|
+
// WS subscription — only re-runs when topic changes
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!topic) return
|
|
19
|
+
|
|
20
|
+
const cb = () => handlerRef.current()
|
|
21
|
+
subscribeWs(topic, cb)
|
|
22
|
+
return () => { unsubscribeWs(topic, cb) }
|
|
23
|
+
}, [topic])
|
|
24
|
+
|
|
25
|
+
// Fallback polling — separate effect so it doesn't tear down WS subscription
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!topic) return
|
|
28
|
+
|
|
29
|
+
let fallbackId: ReturnType<typeof setInterval> | null = null
|
|
30
|
+
const cb = () => handlerRef.current()
|
|
31
|
+
|
|
32
|
+
const startFallback = () => {
|
|
33
|
+
const ms = fallbackMsRef.current
|
|
34
|
+
if (fallbackId || !ms || ms <= 0) return
|
|
35
|
+
fallbackId = setInterval(cb, ms)
|
|
36
|
+
}
|
|
37
|
+
const stopFallback = () => {
|
|
38
|
+
if (fallbackId) {
|
|
39
|
+
clearInterval(fallbackId)
|
|
40
|
+
fallbackId = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check WS connection state periodically to toggle fallback
|
|
45
|
+
const checkId = setInterval(() => {
|
|
46
|
+
const ms = fallbackMsRef.current
|
|
47
|
+
if (!ms || ms <= 0) {
|
|
48
|
+
stopFallback()
|
|
49
|
+
} else if (isWsConnected()) {
|
|
50
|
+
stopFallback()
|
|
51
|
+
} else {
|
|
52
|
+
startFallback()
|
|
53
|
+
}
|
|
54
|
+
}, 2000)
|
|
55
|
+
|
|
56
|
+
// Start fallback immediately if not connected and fallback is enabled
|
|
57
|
+
if (!isWsConnected() && fallbackMsRef.current && fallbackMsRef.current > 0) {
|
|
58
|
+
startFallback()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
stopFallback()
|
|
63
|
+
clearInterval(checkId)
|
|
64
|
+
}
|
|
65
|
+
}, [topic])
|
|
66
|
+
}
|
package/src/instrumentation.ts
CHANGED
|
@@ -2,7 +2,9 @@ export async function register() {
|
|
|
2
2
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
3
3
|
const { startScheduler } = await import('./lib/server/scheduler')
|
|
4
4
|
const { resumeQueue } = await import('./lib/server/queue')
|
|
5
|
+
const { initWsServer } = await import('./lib/server/ws-hub')
|
|
5
6
|
startScheduler()
|
|
6
7
|
resumeQueue()
|
|
8
|
+
initWsServer()
|
|
7
9
|
}
|
|
8
10
|
}
|
package/src/lib/chat.ts
CHANGED
|
@@ -12,8 +12,19 @@ export async function streamChat(
|
|
|
12
12
|
imagePath?: string,
|
|
13
13
|
imageUrl?: string,
|
|
14
14
|
onEvent?: (event: SSEEvent) => void,
|
|
15
|
+
optionsOrFiles?: StreamChatOptions | string[],
|
|
15
16
|
options?: StreamChatOptions,
|
|
16
17
|
): Promise<void> {
|
|
18
|
+
// Support both (options) and (attachedFiles, options) as 6th arg
|
|
19
|
+
let attachedFiles: string[] | undefined
|
|
20
|
+
let opts: StreamChatOptions | undefined
|
|
21
|
+
if (Array.isArray(optionsOrFiles)) {
|
|
22
|
+
attachedFiles = optionsOrFiles
|
|
23
|
+
opts = options
|
|
24
|
+
} else {
|
|
25
|
+
opts = optionsOrFiles
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
const key = getStoredAccessKey()
|
|
18
29
|
const res = await fetch(`/api/sessions/${sessionId}/chat`, {
|
|
19
30
|
method: 'POST',
|
|
@@ -25,8 +36,9 @@ export async function streamChat(
|
|
|
25
36
|
message,
|
|
26
37
|
imagePath,
|
|
27
38
|
imageUrl,
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
attachedFiles,
|
|
40
|
+
internal: !!opts?.internal,
|
|
41
|
+
queueMode: opts?.queueMode,
|
|
30
42
|
}),
|
|
31
43
|
})
|
|
32
44
|
|
|
@@ -113,7 +113,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
|
|
|
113
113
|
const msgs: Array<{ role: string; content: any }> = []
|
|
114
114
|
|
|
115
115
|
if (loadHistory) {
|
|
116
|
-
const history = loadHistory(session.id)
|
|
116
|
+
const history = loadHistory(session.id).slice(-40)
|
|
117
117
|
for (const m of history) {
|
|
118
118
|
if (m.role === 'user' && m.imagePath) {
|
|
119
119
|
const blocks = fileToContentBlocks(m.imagePath)
|
|
@@ -246,6 +246,7 @@ export function getProviderList(): ProviderInfo[] {
|
|
|
246
246
|
.map(({ handler, ...info }) => ({
|
|
247
247
|
...info,
|
|
248
248
|
models: overrides[info.id] || info.models,
|
|
249
|
+
defaultModels: info.models,
|
|
249
250
|
}))
|
|
250
251
|
const customs = Object.values(getCustomProviders())
|
|
251
252
|
.filter((c) => c.isEnabled)
|
|
@@ -253,6 +254,7 @@ export function getProviderList(): ProviderInfo[] {
|
|
|
253
254
|
id: c.id as any,
|
|
254
255
|
name: c.name,
|
|
255
256
|
models: c.models,
|
|
257
|
+
defaultModels: c.models,
|
|
256
258
|
requiresApiKey: c.requiresApiKey,
|
|
257
259
|
requiresEndpoint: false,
|
|
258
260
|
defaultEndpoint: c.baseUrl,
|
|
@@ -116,7 +116,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
|
|
|
116
116
|
const msgs: Array<{ role: string; content: string; images?: string[] }> = []
|
|
117
117
|
|
|
118
118
|
if (loadHistory) {
|
|
119
|
-
const history = loadHistory(session.id)
|
|
119
|
+
const history = loadHistory(session.id).slice(-40)
|
|
120
120
|
for (const m of history) {
|
|
121
121
|
if (m.role === 'user' && m.imagePath) {
|
|
122
122
|
msgs.push({ role: 'user', ...fileToOllamaMsg(m.text, m.imagePath) })
|
|
@@ -4,27 +4,48 @@ import type { StreamChatOptions } from './index'
|
|
|
4
4
|
const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
|
|
5
5
|
const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
|
|
6
6
|
|
|
7
|
-
function fileToContentParts(filePath: string): any[] {
|
|
7
|
+
async function fileToContentParts(filePath: string): Promise<any[]> {
|
|
8
8
|
if (!filePath || !fs.existsSync(filePath)) return []
|
|
9
|
+
const name = filePath.split('/').pop() || 'file'
|
|
9
10
|
if (IMAGE_EXTS.test(filePath)) {
|
|
10
|
-
const
|
|
11
|
+
const buf = fs.readFileSync(filePath)
|
|
12
|
+
if (buf.length === 0) return [{ type: 'text', text: `[Attached image: ${name} — file is empty]` }]
|
|
13
|
+
const data = buf.toString('base64')
|
|
11
14
|
const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
|
16
|
+
if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
|
|
17
|
+
else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
|
|
18
|
+
else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
|
|
19
|
+
else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
|
|
20
|
+
return [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }]
|
|
14
21
|
}
|
|
15
|
-
if (
|
|
22
|
+
if (filePath.endsWith('.pdf')) {
|
|
23
|
+
try {
|
|
24
|
+
// @ts-ignore — pdf-parse types
|
|
25
|
+
const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
|
|
26
|
+
const buf = fs.readFileSync(filePath)
|
|
27
|
+
const result = await pdfParse(buf)
|
|
28
|
+
const pdfText = (result.text || '').trim()
|
|
29
|
+
if (!pdfText) return [{ type: 'text', text: `[Attached PDF: ${name} — no extractable text]` }]
|
|
30
|
+
const maxChars = 100_000
|
|
31
|
+
const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
|
|
32
|
+
return [{ type: 'text', text: `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}` }]
|
|
33
|
+
} catch {
|
|
34
|
+
return [{ type: 'text', text: `[Attached PDF: ${name} — could not extract text]` }]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (TEXT_EXTS.test(filePath)) {
|
|
16
38
|
try {
|
|
17
39
|
const text = fs.readFileSync(filePath, 'utf-8')
|
|
18
|
-
const name = filePath.split('/').pop() || 'file'
|
|
19
40
|
return [{ type: 'text', text: `[Attached file: ${name}]\n\n${text}` }]
|
|
20
41
|
} catch { return [] }
|
|
21
42
|
}
|
|
22
|
-
return [{ type: 'text', text: `[Attached file: ${
|
|
43
|
+
return [{ type: 'text', text: `[Attached file: ${name}]` }]
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory }: StreamChatOptions): Promise<string> {
|
|
26
47
|
return new Promise(async (resolve) => {
|
|
27
|
-
const messages = buildMessages(session, message, imagePath, systemPrompt, loadHistory)
|
|
48
|
+
const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory)
|
|
28
49
|
const model = session.model || 'gpt-4o'
|
|
29
50
|
|
|
30
51
|
const payload = JSON.stringify({
|
|
@@ -134,7 +155,7 @@ export function streamOpenAiChat({ session, message, imagePath, apiKey, systemPr
|
|
|
134
155
|
})
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
|
|
158
|
+
async function buildMessages(session: any, message: string, imagePath: string | undefined, systemPrompt: string | undefined, loadHistory: (id: string) => any[]) {
|
|
138
159
|
const msgs: Array<{ role: string; content: any }> = []
|
|
139
160
|
|
|
140
161
|
if (systemPrompt) {
|
|
@@ -142,10 +163,10 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
|
|
|
142
163
|
}
|
|
143
164
|
|
|
144
165
|
if (loadHistory) {
|
|
145
|
-
const history = loadHistory(session.id)
|
|
166
|
+
const history = loadHistory(session.id).slice(-40)
|
|
146
167
|
for (const m of history) {
|
|
147
168
|
if (m.role === 'user' && m.imagePath) {
|
|
148
|
-
const parts = fileToContentParts(m.imagePath)
|
|
169
|
+
const parts = await fileToContentParts(m.imagePath)
|
|
149
170
|
msgs.push({ role: 'user', content: [...parts, { type: 'text', text: m.text }] })
|
|
150
171
|
} else {
|
|
151
172
|
msgs.push({ role: m.role, content: m.text })
|
|
@@ -155,7 +176,7 @@ function buildMessages(session: any, message: string, imagePath: string | undefi
|
|
|
155
176
|
|
|
156
177
|
// Current message with optional attachment
|
|
157
178
|
if (imagePath) {
|
|
158
|
-
const parts = fileToContentParts(imagePath)
|
|
179
|
+
const parts = await fileToContentParts(imagePath)
|
|
159
180
|
msgs.push({ role: 'user', content: [...parts, { type: 'text', text: message }] })
|
|
160
181
|
} else {
|
|
161
182
|
msgs.push({ role: 'user', content: message })
|
|
@@ -20,6 +20,7 @@ import { stripMainLoopMetaForPersistence } from './main-agent-loop'
|
|
|
20
20
|
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
|
|
21
21
|
import { getMemoryDb } from './memory-db'
|
|
22
22
|
import { routeTaskIntent } from './capability-router'
|
|
23
|
+
import { notify } from './ws-hub'
|
|
23
24
|
import { resolveConcreteToolPolicyBlock, resolveSessionToolPolicy } from './tool-capability-policy'
|
|
24
25
|
import type { MessageToolEvent, SSEEvent } from '@/types'
|
|
25
26
|
import { markProviderFailure, markProviderSuccess, rankDelegatesByHealth } from './provider-health'
|
|
@@ -45,6 +46,7 @@ export interface ExecuteChatTurnInput {
|
|
|
45
46
|
message: string
|
|
46
47
|
imagePath?: string
|
|
47
48
|
imageUrl?: string
|
|
49
|
+
attachedFiles?: string[]
|
|
48
50
|
internal?: boolean
|
|
49
51
|
source?: string
|
|
50
52
|
runId?: string
|
|
@@ -423,6 +425,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
423
425
|
message,
|
|
424
426
|
imagePath,
|
|
425
427
|
imageUrl,
|
|
428
|
+
attachedFiles,
|
|
426
429
|
internal = false,
|
|
427
430
|
runId,
|
|
428
431
|
source = 'chat',
|
|
@@ -439,9 +442,12 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
439
442
|
const appSettings = loadSettings()
|
|
440
443
|
const toolPolicy = resolveSessionToolPolicy(session.tools, appSettings)
|
|
441
444
|
const isHeartbeatRun = internal && source === 'heartbeat'
|
|
445
|
+
const isAutoRunNoHistory = isHeartbeatRun || (internal && source === 'main-loop-followup')
|
|
442
446
|
const heartbeatStatus = session.mainLoopState?.status || 'idle'
|
|
443
|
-
const
|
|
444
|
-
&& (
|
|
447
|
+
const mainLoopIdle = session.name === '__main__'
|
|
448
|
+
&& (heartbeatStatus === 'ok' || heartbeatStatus === 'idle')
|
|
449
|
+
&& !(session.mainLoopState?.pendingEvents?.length > 0)
|
|
450
|
+
const heartbeatStatusOnly = isHeartbeatRun && mainLoopIdle
|
|
445
451
|
const toolsForRun = heartbeatStatusOnly ? [] : toolPolicy.enabledTools
|
|
446
452
|
let sessionForRun = toolsForRun === session.tools
|
|
447
453
|
? session
|
|
@@ -520,6 +526,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
520
526
|
time: Date.now(),
|
|
521
527
|
imagePath: imagePath || undefined,
|
|
522
528
|
imageUrl: imageUrl || undefined,
|
|
529
|
+
attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
|
|
523
530
|
})
|
|
524
531
|
session.lastActiveAt = Date.now()
|
|
525
532
|
saveSessions(sessions)
|
|
@@ -567,15 +574,22 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
567
574
|
|
|
568
575
|
try {
|
|
569
576
|
const hasTools = !!sessionForRun.tools?.length && !CLI_PROVIDER_IDS.has(providerType)
|
|
577
|
+
// Heartbeat runs are self-contained — skip conversation history to avoid
|
|
578
|
+
// blowing past the context window on long-lived sessions.
|
|
579
|
+
const heartbeatHistory = isAutoRunNoHistory ? [] : undefined
|
|
580
|
+
|
|
581
|
+
console.log(`[chat-execution] provider=${providerType}, hasTools=${hasTools}, imagePath=${imagePath || 'none'}, attachedFiles=${attachedFiles?.length || 0}, tools=${(sessionForRun.tools || []).length}`)
|
|
582
|
+
|
|
570
583
|
fullResponse = hasTools
|
|
571
584
|
? (await streamAgentChat({
|
|
572
585
|
session: sessionForRun,
|
|
573
586
|
message,
|
|
574
587
|
imagePath,
|
|
588
|
+
attachedFiles,
|
|
575
589
|
apiKey,
|
|
576
590
|
systemPrompt,
|
|
577
591
|
write: (raw) => parseAndEmit(raw),
|
|
578
|
-
history: getSessionMessages(sessionId),
|
|
592
|
+
history: heartbeatHistory ?? getSessionMessages(sessionId),
|
|
579
593
|
signal: abortController.signal,
|
|
580
594
|
})).fullText
|
|
581
595
|
: await provider.handler.streamChat({
|
|
@@ -586,7 +600,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
586
600
|
systemPrompt,
|
|
587
601
|
write: (raw: string) => parseAndEmit(raw),
|
|
588
602
|
active,
|
|
589
|
-
loadHistory: getSessionMessages,
|
|
603
|
+
loadHistory: isAutoRunNoHistory ? () => [] : getSessionMessages,
|
|
590
604
|
})
|
|
591
605
|
} catch (err: any) {
|
|
592
606
|
errorMessage = err?.message || String(err)
|
|
@@ -881,6 +895,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
881
895
|
}
|
|
882
896
|
fresh[sessionId] = current
|
|
883
897
|
saveSessions(fresh)
|
|
898
|
+
notify(`messages:${sessionId}`)
|
|
884
899
|
}
|
|
885
900
|
|
|
886
901
|
return {
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
loadConnectors, saveConnectors, loadSessions, saveSessions,
|
|
4
4
|
loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
|
|
5
5
|
} from '../storage'
|
|
6
|
+
import { WORKSPACE_DIR } from '../data-dir'
|
|
6
7
|
import { streamAgentChat } from '../stream-agent-chat'
|
|
8
|
+
import { notify } from '../ws-hub'
|
|
7
9
|
import { logExecution } from '../execution-log'
|
|
8
10
|
import type { Connector } from '@/types'
|
|
9
11
|
import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
|
|
@@ -124,7 +126,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
124
126
|
session = {
|
|
125
127
|
id,
|
|
126
128
|
name: sessionKey,
|
|
127
|
-
cwd:
|
|
129
|
+
cwd: WORKSPACE_DIR,
|
|
128
130
|
user: 'connector',
|
|
129
131
|
provider: agent.provider === 'claude-cli' ? 'anthropic' : agent.provider,
|
|
130
132
|
model: agent.model,
|
|
@@ -203,7 +205,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
203
205
|
apiKey,
|
|
204
206
|
systemPrompt,
|
|
205
207
|
write: () => {}, // no SSE needed for connectors
|
|
206
|
-
history: session.messages,
|
|
208
|
+
history: session.messages.slice(-20),
|
|
207
209
|
})
|
|
208
210
|
// Use finalResponse for connectors — strips intermediate planning/tool-use text
|
|
209
211
|
fullText = result.finalResponse
|
|
@@ -234,7 +236,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
234
236
|
}
|
|
235
237
|
},
|
|
236
238
|
active: new Map(),
|
|
237
|
-
loadHistory: () => session.messages,
|
|
239
|
+
loadHistory: () => session.messages.slice(-20),
|
|
238
240
|
})
|
|
239
241
|
}
|
|
240
242
|
|
|
@@ -268,6 +270,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
268
270
|
const s2 = loadSessions()
|
|
269
271
|
s2[session.id] = session
|
|
270
272
|
saveSessions(s2)
|
|
273
|
+
notify(`messages:${session.id}`)
|
|
271
274
|
}
|
|
272
275
|
|
|
273
276
|
return fullText || '(no response)'
|
|
@@ -341,6 +344,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
341
344
|
connector.updatedAt = Date.now()
|
|
342
345
|
connectors[connectorId] = connector
|
|
343
346
|
saveConnectors(connectors)
|
|
347
|
+
notify('connectors')
|
|
344
348
|
|
|
345
349
|
console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
|
|
346
350
|
} catch (err: any) {
|
|
@@ -350,6 +354,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
350
354
|
connector.updatedAt = Date.now()
|
|
351
355
|
connectors[connectorId] = connector
|
|
352
356
|
saveConnectors(connectors)
|
|
357
|
+
notify('connectors')
|
|
353
358
|
throw err
|
|
354
359
|
}
|
|
355
360
|
}
|
|
@@ -371,6 +376,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
|
|
|
371
376
|
connector.updatedAt = Date.now()
|
|
372
377
|
connectors[connectorId] = connector
|
|
373
378
|
saveConnectors(connectors)
|
|
379
|
+
notify('connectors')
|
|
374
380
|
}
|
|
375
381
|
|
|
376
382
|
console.log(`[connector] Stopped connector: ${connectorId}`)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors } from './storage'
|
|
2
|
+
import { notify } from './ws-hub'
|
|
2
3
|
import { processNext, cleanupFinishedTaskSessions, validateCompletedTasksQueue, recoverStalledRunningTasks } from './queue'
|
|
3
4
|
import { startScheduler, stopScheduler } from './scheduler'
|
|
4
5
|
import { sweepOrphanedBrowsers, getActiveBrowserCount } from './session-tools'
|
|
@@ -114,6 +115,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
|
|
|
114
115
|
return
|
|
115
116
|
}
|
|
116
117
|
ds.running = true
|
|
118
|
+
notify('daemon')
|
|
117
119
|
console.log(`[daemon] Starting daemon (source=${source}, scheduler + queue processor + heartbeat)`)
|
|
118
120
|
|
|
119
121
|
validateCompletedTasksQueue()
|
|
@@ -135,6 +137,7 @@ export function stopDaemon(options?: { source?: string; manualStop?: boolean })
|
|
|
135
137
|
if (options?.manualStop === true) ds.manualStopRequested = true
|
|
136
138
|
if (!ds.running) return
|
|
137
139
|
ds.running = false
|
|
140
|
+
notify('daemon')
|
|
138
141
|
console.log(`[daemon] Stopping daemon (source=${source})`)
|
|
139
142
|
|
|
140
143
|
stopScheduler()
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
1
3
|
import { loadAgents, loadSessions, loadSettings } from './storage'
|
|
2
4
|
import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
|
|
3
5
|
import { log } from './logger'
|
|
4
6
|
import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
|
|
7
|
+
import { WORKSPACE_DIR } from './data-dir'
|
|
5
8
|
|
|
6
9
|
const HEARTBEAT_TICK_MS = 5_000
|
|
7
10
|
|
|
@@ -118,6 +121,56 @@ export interface HeartbeatConfig {
|
|
|
118
121
|
|
|
119
122
|
const DEFAULT_HEARTBEAT_PROMPT = 'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.'
|
|
120
123
|
|
|
124
|
+
function readHeartbeatFile(session: any): string {
|
|
125
|
+
try {
|
|
126
|
+
const filePath = path.join(session.cwd || WORKSPACE_DIR, 'HEARTBEAT.md')
|
|
127
|
+
if (fs.existsSync(filePath)) {
|
|
128
|
+
return fs.readFileSync(filePath, 'utf-8').trim()
|
|
129
|
+
}
|
|
130
|
+
} catch { /* ignore */ }
|
|
131
|
+
return ''
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
|
|
135
|
+
if (!agent) return fallbackPrompt
|
|
136
|
+
|
|
137
|
+
// Dynamic goal (agent-set) takes priority over static system prompt
|
|
138
|
+
const dynamicGoal = agent.heartbeatGoal || ''
|
|
139
|
+
const dynamicNextAction = agent.heartbeatNextAction || ''
|
|
140
|
+
const description = agent.description || ''
|
|
141
|
+
const systemPrompt = agent.systemPrompt || ''
|
|
142
|
+
const soul = agent.soul || ''
|
|
143
|
+
const goalSummary = systemPrompt.slice(0, 500)
|
|
144
|
+
const recentMessages = (session.messages || []).slice(-5)
|
|
145
|
+
const recentContext = recentMessages
|
|
146
|
+
.map((m: any) => `[${m.role}]: ${(m.text || '').slice(0, 200)}`)
|
|
147
|
+
.join('\n')
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
'AGENT_HEARTBEAT_TICK',
|
|
151
|
+
`Time: ${new Date().toISOString()}`,
|
|
152
|
+
`Agent: ${agent.name}`,
|
|
153
|
+
description ? `Description: ${description}` : '',
|
|
154
|
+
dynamicGoal
|
|
155
|
+
? `Current goal (self-set): ${dynamicGoal}`
|
|
156
|
+
: goalSummary ? `System prompt (initial goal):\n${goalSummary}` : '',
|
|
157
|
+
dynamicNextAction ? `Planned next action: ${dynamicNextAction}` : '',
|
|
158
|
+
soul ? `Persona: ${soul.slice(0, 300)}` : '',
|
|
159
|
+
heartbeatFileContent ? `\nHEARTBEAT.md contents:\n${heartbeatFileContent.slice(0, 2000)}` : '',
|
|
160
|
+
recentContext ? `Recent conversation:\n${recentContext}` : '',
|
|
161
|
+
'',
|
|
162
|
+
'You are running an autonomous heartbeat tick. Review your goal and recent context.',
|
|
163
|
+
'If there is meaningful work to do toward your goal, use your tools and take action.',
|
|
164
|
+
'If nothing needs attention right now, reply exactly HEARTBEAT_OK.',
|
|
165
|
+
'Do not ask clarifying questions. Take the most reasonable next action.',
|
|
166
|
+
'',
|
|
167
|
+
'To update your goal or plan, include this line in your response:',
|
|
168
|
+
'[AGENT_HEARTBEAT_META]{"goal": "your evolved goal", "status": "progress", "next_action": "what you plan to do next"}',
|
|
169
|
+
'You can evolve your goal as you learn more. Set status to "progress" while working, "ok" when done, "idle" when waiting.',
|
|
170
|
+
fallbackPrompt !== DEFAULT_HEARTBEAT_PROMPT ? `\nAdditional instructions: ${fallbackPrompt}` : '',
|
|
171
|
+
].filter(Boolean).join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
121
174
|
function resolveInterval(obj: Record<string, any>, currentSec: number): number {
|
|
122
175
|
// Prefer heartbeatInterval (duration string) over heartbeatIntervalSec (raw number)
|
|
123
176
|
if (obj.heartbeatInterval !== undefined && obj.heartbeatInterval !== null) {
|
|
@@ -288,9 +341,20 @@ async function tickHeartbeats() {
|
|
|
288
341
|
const runState = getSessionRunState(session.id)
|
|
289
342
|
if (runState.runningRunId) continue
|
|
290
343
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
344
|
+
let heartbeatMessage: string
|
|
345
|
+
if (isMainSession(session)) {
|
|
346
|
+
heartbeatMessage = buildMainLoopHeartbeatPrompt(session, cfg.prompt)
|
|
347
|
+
} else {
|
|
348
|
+
const heartbeatFileContent = readHeartbeatFile(session)
|
|
349
|
+
const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
|
|
350
|
+
const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
|
|
351
|
+
// Skip heartbeat only if there's truly nothing to drive it:
|
|
352
|
+
// no agent goal, no HEARTBEAT.md content, AND no custom prompt configured
|
|
353
|
+
if (!hasGoal && !heartbeatFileContent && !hasCustomPrompt) {
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
heartbeatMessage = buildAgentHeartbeatPrompt(session, agent, cfg.prompt, heartbeatFileContent)
|
|
357
|
+
}
|
|
294
358
|
|
|
295
359
|
const enqueue = enqueueSessionRun({
|
|
296
360
|
sessionId: session.id,
|