@swarmclawai/swarmclaw 0.5.2 → 0.6.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 +42 -7
- package/bin/swarmclaw.js +76 -16
- package/next.config.ts +11 -1
- package/package.json +4 -2
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/scripts/postinstall.mjs +18 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
- package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
- package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
- package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
- package/src/app/api/chatrooms/[id]/route.ts +84 -0
- package/src/app/api/chatrooms/route.ts +50 -0
- package/src/app/api/credentials/route.ts +2 -3
- package/src/app/api/knowledge/[id]/route.ts +13 -2
- package/src/app/api/knowledge/route.ts +8 -1
- package/src/app/api/memory/route.ts +8 -0
- package/src/app/api/notifications/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +68 -0
- package/src/app/api/orchestrator/run/route.ts +1 -1
- package/src/app/api/plugins/install/route.ts +2 -2
- package/src/app/api/search/route.ts +155 -0
- package/src/app/api/sessions/[id]/chat/route.ts +2 -0
- package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
- package/src/app/api/sessions/[id]/fork/route.ts +1 -1
- package/src/app/api/sessions/route.ts +3 -3
- package/src/app/api/settings/route.ts +9 -0
- package/src/app/api/setup/check-provider/route.ts +3 -16
- package/src/app/api/skills/[id]/route.ts +6 -0
- package/src/app/api/skills/route.ts +6 -0
- package/src/app/api/tasks/[id]/route.ts +20 -0
- package/src/app/api/tasks/bulk/route.ts +100 -0
- package/src/app/api/tasks/route.ts +1 -0
- package/src/app/api/usage/route.ts +45 -0
- package/src/app/api/webhooks/[id]/route.ts +15 -1
- package/src/app/globals.css +58 -15
- package/src/app/page.tsx +142 -13
- package/src/cli/index.js +42 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +32 -0
- package/src/components/agents/agent-avatar.tsx +57 -10
- package/src/components/agents/agent-card.tsx +48 -15
- package/src/components/agents/agent-chat-list.tsx +123 -10
- package/src/components/agents/agent-list.tsx +50 -19
- package/src/components/agents/agent-sheet.tsx +56 -63
- package/src/components/auth/access-key-gate.tsx +10 -3
- package/src/components/auth/setup-wizard.tsx +2 -2
- package/src/components/auth/user-picker.tsx +31 -3
- package/src/components/chat/activity-moment.tsx +169 -0
- package/src/components/chat/chat-header.tsx +2 -0
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/file-path-chip.tsx +125 -0
- package/src/components/chat/markdown-utils.ts +9 -0
- package/src/components/chat/message-bubble.tsx +46 -295
- package/src/components/chat/message-list.tsx +50 -1
- package/src/components/chat/streaming-bubble.tsx +36 -46
- package/src/components/chat/suggestions-bar.tsx +1 -1
- package/src/components/chat/thinking-indicator.tsx +72 -10
- package/src/components/chat/tool-call-bubble.tsx +66 -70
- package/src/components/chat/tool-request-banner.tsx +31 -7
- package/src/components/chat/transfer-agent-picker.tsx +63 -0
- package/src/components/chatrooms/agent-hover-card.tsx +124 -0
- package/src/components/chatrooms/chatroom-input.tsx +320 -0
- package/src/components/chatrooms/chatroom-list.tsx +123 -0
- package/src/components/chatrooms/chatroom-message.tsx +427 -0
- package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
- package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
- package/src/components/chatrooms/chatroom-view.tsx +344 -0
- package/src/components/chatrooms/reaction-picker.tsx +273 -0
- package/src/components/connectors/connector-sheet.tsx +34 -47
- package/src/components/home/home-view.tsx +501 -0
- package/src/components/input/chat-input.tsx +79 -41
- package/src/components/knowledge/knowledge-list.tsx +31 -1
- package/src/components/knowledge/knowledge-sheet.tsx +83 -2
- package/src/components/layout/app-layout.tsx +209 -83
- package/src/components/layout/mobile-header.tsx +2 -0
- package/src/components/layout/update-banner.tsx +2 -2
- package/src/components/logs/log-list.tsx +2 -2
- package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
- package/src/components/memory/memory-agent-list.tsx +143 -0
- package/src/components/memory/memory-browser.tsx +205 -0
- package/src/components/memory/memory-card.tsx +34 -7
- package/src/components/memory/memory-detail.tsx +359 -120
- package/src/components/memory/memory-sheet.tsx +157 -23
- package/src/components/plugins/plugin-list.tsx +1 -1
- package/src/components/plugins/plugin-sheet.tsx +1 -1
- package/src/components/projects/project-detail.tsx +509 -0
- package/src/components/projects/project-list.tsx +195 -59
- package/src/components/providers/provider-list.tsx +2 -2
- package/src/components/providers/provider-sheet.tsx +3 -3
- package/src/components/schedules/schedule-card.tsx +3 -2
- package/src/components/schedules/schedule-list.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +25 -25
- package/src/components/secrets/secret-sheet.tsx +47 -24
- package/src/components/secrets/secrets-list.tsx +18 -8
- package/src/components/sessions/new-session-sheet.tsx +33 -65
- package/src/components/sessions/session-card.tsx +45 -14
- package/src/components/sessions/session-list.tsx +35 -18
- package/src/components/shared/agent-picker-list.tsx +90 -0
- package/src/components/shared/agent-switch-dialog.tsx +156 -0
- package/src/components/shared/attachment-chip.tsx +165 -0
- package/src/components/shared/avatar.tsx +10 -1
- package/src/components/shared/check-icon.tsx +12 -0
- package/src/components/shared/confirm-dialog.tsx +1 -1
- package/src/components/shared/empty-state.tsx +32 -0
- package/src/components/shared/file-preview.tsx +34 -0
- package/src/components/shared/form-styles.ts +2 -0
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
- package/src/components/shared/notification-center.tsx +223 -0
- package/src/components/shared/profile-sheet.tsx +115 -0
- package/src/components/shared/reply-quote.tsx +26 -0
- package/src/components/shared/search-dialog.tsx +296 -0
- package/src/components/shared/section-label.tsx +12 -0
- package/src/components/shared/settings/plugin-manager.tsx +1 -1
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-secrets.tsx +1 -1
- package/src/components/shared/settings/section-theme.tsx +95 -0
- package/src/components/shared/settings/section-user-preferences.tsx +39 -0
- package/src/components/shared/settings/settings-page.tsx +180 -27
- package/src/components/shared/settings/settings-sheet.tsx +9 -73
- package/src/components/shared/sheet-footer.tsx +33 -0
- package/src/components/skills/skill-list.tsx +61 -30
- package/src/components/skills/skill-sheet.tsx +81 -2
- package/src/components/tasks/task-board.tsx +448 -26
- package/src/components/tasks/task-card.tsx +46 -9
- package/src/components/tasks/task-column.tsx +62 -3
- package/src/components/tasks/task-list.tsx +12 -4
- package/src/components/tasks/task-sheet.tsx +89 -72
- package/src/components/ui/hover-card.tsx +52 -0
- package/src/components/usage/metrics-dashboard.tsx +78 -0
- package/src/components/usage/usage-list.tsx +1 -1
- package/src/components/webhooks/webhook-sheet.tsx +1 -1
- package/src/hooks/use-view-router.ts +69 -19
- package/src/instrumentation.ts +15 -1
- package/src/lib/chat.ts +2 -0
- package/src/lib/cron-human.ts +114 -0
- package/src/lib/memory.ts +3 -0
- package/src/lib/server/chat-execution.ts +24 -4
- package/src/lib/server/connectors/manager.ts +11 -0
- package/src/lib/server/context-manager.ts +225 -13
- package/src/lib/server/create-notification.ts +42 -0
- package/src/lib/server/daemon-state.ts +165 -10
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +40 -5
- package/src/lib/server/heartbeat-wake.ts +110 -0
- package/src/lib/server/langgraph-checkpoint.ts +1 -0
- package/src/lib/server/memory-consolidation.ts +92 -0
- package/src/lib/server/memory-db.ts +51 -6
- package/src/lib/server/openclaw-gateway.ts +9 -1
- package/src/lib/server/provider-health.ts +125 -0
- package/src/lib/server/queue.ts +5 -4
- package/src/lib/server/scheduler.ts +8 -0
- package/src/lib/server/session-run-manager.ts +4 -0
- package/src/lib/server/session-tools/chatroom.ts +136 -0
- package/src/lib/server/session-tools/context-mgmt.ts +36 -18
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +6 -1
- package/src/lib/server/storage.ts +80 -29
- package/src/lib/server/stream-agent-chat.ts +153 -47
- package/src/lib/server/system-events.ts +49 -0
- package/src/lib/server/ws-hub.ts +11 -0
- package/src/lib/soul-suggestions.ts +109 -0
- package/src/lib/tasks.ts +4 -1
- package/src/lib/view-routes.ts +36 -1
- package/src/lib/ws-client.ts +14 -4
- package/src/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +94 -3
- package/src/stores/use-chat-store.ts +48 -3
- package/src/stores/use-chatroom-store.ts +276 -0
- package/src/types/index.ts +69 -2
|
@@ -2,48 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
-
import {
|
|
5
|
+
import { useChatroomStore } from '@/stores/use-chatroom-store'
|
|
6
|
+
import { parsePath, buildPath, DEFAULT_VIEW } from '@/lib/view-routes'
|
|
7
|
+
|
|
8
|
+
/** Map a view to the relevant entity ID from stores */
|
|
9
|
+
function getIdForView(view: string): string | null {
|
|
10
|
+
if (view === 'agents') return useAppStore.getState().currentAgentId
|
|
11
|
+
if (view === 'chatrooms') return useChatroomStore.getState().currentChatroomId
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Apply a parsed route to the stores */
|
|
16
|
+
function applyRoute(view: string, id: string | null) {
|
|
17
|
+
if (view === 'agents') useAppStore.getState().setCurrentAgent(id)
|
|
18
|
+
if (view === 'chatrooms') useChatroomStore.getState().setCurrentChatroom(id)
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
export function useViewRouter() {
|
|
8
22
|
const fromPopstate = useRef(false)
|
|
23
|
+
const suppressPush = useRef(false)
|
|
9
24
|
|
|
10
|
-
// Mount: read pathname → set active view
|
|
25
|
+
// Mount: read pathname → set active view + entity ID
|
|
11
26
|
useEffect(() => {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
|
|
27
|
+
const parsed = parsePath(window.location.pathname)
|
|
28
|
+
if (parsed) {
|
|
29
|
+
suppressPush.current = true
|
|
30
|
+
useAppStore.getState().setActiveView(parsed.view)
|
|
31
|
+
applyRoute(parsed.view, parsed.id)
|
|
32
|
+
suppressPush.current = false
|
|
15
33
|
} else {
|
|
16
34
|
useAppStore.getState().setActiveView(DEFAULT_VIEW)
|
|
17
|
-
window.history.replaceState(null, '',
|
|
35
|
+
window.history.replaceState(null, '', buildPath(DEFAULT_VIEW))
|
|
18
36
|
}
|
|
19
37
|
}, [])
|
|
20
38
|
|
|
21
|
-
// State→URL: push new path when activeView changes
|
|
39
|
+
// State→URL: push new path when activeView or entity ID changes
|
|
22
40
|
useEffect(() => {
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
let prevView = useAppStore.getState().activeView
|
|
42
|
+
let prevId = getIdForView(prevView)
|
|
43
|
+
|
|
44
|
+
const unsubApp = useAppStore.subscribe((state) => {
|
|
45
|
+
if (suppressPush.current) return
|
|
46
|
+
const nextView = state.activeView
|
|
47
|
+
const nextId = getIdForView(nextView)
|
|
48
|
+
|
|
49
|
+
if (nextView === prevView && nextId === prevId) return
|
|
50
|
+
prevView = nextView
|
|
51
|
+
prevId = nextId
|
|
52
|
+
|
|
53
|
+
if (fromPopstate.current) {
|
|
54
|
+
fromPopstate.current = false
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const targetPath = buildPath(nextView, nextId)
|
|
58
|
+
if (window.location.pathname !== targetPath) {
|
|
59
|
+
window.history.pushState(null, '', targetPath)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const unsubChatroom = useChatroomStore.subscribe((state) => {
|
|
64
|
+
if (suppressPush.current) return
|
|
65
|
+
const currentView = useAppStore.getState().activeView
|
|
66
|
+
if (currentView !== 'chatrooms') return
|
|
67
|
+
const nextId = state.currentChatroomId
|
|
68
|
+
if (nextId === prevId) return
|
|
69
|
+
prevId = nextId
|
|
70
|
+
|
|
28
71
|
if (fromPopstate.current) {
|
|
29
72
|
fromPopstate.current = false
|
|
30
73
|
return
|
|
31
74
|
}
|
|
32
|
-
const targetPath =
|
|
33
|
-
if (
|
|
75
|
+
const targetPath = buildPath('chatrooms', nextId)
|
|
76
|
+
if (window.location.pathname !== targetPath) {
|
|
34
77
|
window.history.pushState(null, '', targetPath)
|
|
35
78
|
}
|
|
36
79
|
})
|
|
37
|
-
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
unsubApp()
|
|
83
|
+
unsubChatroom()
|
|
84
|
+
}
|
|
38
85
|
}, [])
|
|
39
86
|
|
|
40
|
-
// Popstate: browser back/forward → update view
|
|
87
|
+
// Popstate: browser back/forward → update view + entity ID
|
|
41
88
|
useEffect(() => {
|
|
42
89
|
const onPopstate = () => {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
90
|
+
const parsed = parsePath(window.location.pathname)
|
|
91
|
+
if (parsed) {
|
|
45
92
|
fromPopstate.current = true
|
|
46
|
-
|
|
93
|
+
suppressPush.current = true
|
|
94
|
+
useAppStore.getState().setActiveView(parsed.view)
|
|
95
|
+
applyRoute(parsed.view, parsed.id)
|
|
96
|
+
suppressPush.current = false
|
|
47
97
|
}
|
|
48
98
|
}
|
|
49
99
|
window.addEventListener('popstate', onPopstate)
|
package/src/instrumentation.ts
CHANGED
|
@@ -2,9 +2,23 @@ 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
|
+
const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
|
|
6
|
+
const { stopDaemon } = await import('./lib/server/daemon-state')
|
|
6
7
|
startScheduler()
|
|
7
8
|
resumeQueue()
|
|
8
9
|
initWsServer()
|
|
10
|
+
|
|
11
|
+
// Graceful shutdown: stop background services and close WS connections
|
|
12
|
+
let shuttingDown = false
|
|
13
|
+
const shutdown = async (signal: string) => {
|
|
14
|
+
if (shuttingDown) return
|
|
15
|
+
shuttingDown = true
|
|
16
|
+
console.log(`[server] ${signal} received, shutting down gracefully...`)
|
|
17
|
+
stopDaemon({ source: signal })
|
|
18
|
+
await closeWsServer()
|
|
19
|
+
process.exit(0)
|
|
20
|
+
}
|
|
21
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
22
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
9
23
|
}
|
|
10
24
|
}
|
package/src/lib/chat.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getStoredAccessKey } from './api-client'
|
|
|
4
4
|
interface StreamChatOptions {
|
|
5
5
|
internal?: boolean
|
|
6
6
|
queueMode?: 'followup' | 'steer' | 'collect'
|
|
7
|
+
replyToId?: string
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export async function streamChat(
|
|
@@ -39,6 +40,7 @@ export async function streamChat(
|
|
|
39
40
|
attachedFiles,
|
|
40
41
|
internal: !!opts?.internal,
|
|
41
42
|
queueMode: opts?.queueMode,
|
|
43
|
+
...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
|
|
42
44
|
}),
|
|
43
45
|
})
|
|
44
46
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
|
|
2
|
+
const MONTH_NAMES = [
|
|
3
|
+
'', 'January', 'February', 'March', 'April', 'May', 'June',
|
|
4
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
5
|
+
] as const
|
|
6
|
+
|
|
7
|
+
function formatTime(hour: number, minute: number): string {
|
|
8
|
+
const period = hour >= 12 ? 'PM' : 'AM'
|
|
9
|
+
const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
|
10
|
+
const m = minute.toString().padStart(2, '0')
|
|
11
|
+
return `${h}:${m} ${period}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ordinal(n: number): string {
|
|
15
|
+
const s = ['th', 'st', 'nd', 'rd']
|
|
16
|
+
const v = n % 100
|
|
17
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseDowRange(field: string): string | null {
|
|
21
|
+
// Normalize 7 → 0 (both mean Sunday)
|
|
22
|
+
const normalized = field.replace(/7/g, '0')
|
|
23
|
+
if (normalized === '1-5') return 'Weekdays'
|
|
24
|
+
if (normalized === '0,6' || normalized === '6,0') return 'Weekends'
|
|
25
|
+
// Single day
|
|
26
|
+
const single = parseInt(normalized, 10)
|
|
27
|
+
if (!isNaN(single) && single >= 0 && single <= 6) return `Every ${DAY_NAMES[single]}`
|
|
28
|
+
// Comma-separated days
|
|
29
|
+
if (/^[0-6](,[0-6])+$/.test(normalized)) {
|
|
30
|
+
const days = normalized.split(',').map((d) => DAY_NAMES[parseInt(d, 10)])
|
|
31
|
+
return days.join(', ')
|
|
32
|
+
}
|
|
33
|
+
// Range like 1-3
|
|
34
|
+
const rangeMatch = normalized.match(/^([0-6])-([0-6])$/)
|
|
35
|
+
if (rangeMatch) {
|
|
36
|
+
const start = parseInt(rangeMatch[1], 10)
|
|
37
|
+
const end = parseInt(rangeMatch[2], 10)
|
|
38
|
+
return `${DAY_NAMES[start]} through ${DAY_NAMES[end]}`
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert a 5-field cron expression to a human-readable string.
|
|
45
|
+
* Falls back to the raw expression for patterns too complex to describe simply.
|
|
46
|
+
*/
|
|
47
|
+
export function cronToHuman(expression: string): string {
|
|
48
|
+
const raw = expression.trim()
|
|
49
|
+
const parts = raw.split(/\s+/)
|
|
50
|
+
if (parts.length !== 5) return raw
|
|
51
|
+
|
|
52
|
+
const [minute, hour, dom, month, dow] = parts
|
|
53
|
+
|
|
54
|
+
// Every minute
|
|
55
|
+
if (raw === '* * * * *') return 'Every minute'
|
|
56
|
+
|
|
57
|
+
// Step minutes: */N * * * *
|
|
58
|
+
if (/^\*\/\d+$/.test(minute) && hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
|
59
|
+
const n = parseInt(minute.slice(2), 10)
|
|
60
|
+
return n === 1 ? 'Every minute' : `Every ${n} minutes`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Step hours: 0 */N * * *
|
|
64
|
+
if (minute === '0' && /^\*\/\d+$/.test(hour) && dom === '*' && month === '*' && dow === '*') {
|
|
65
|
+
const n = parseInt(hour.slice(2), 10)
|
|
66
|
+
return n === 1 ? 'Every hour' : `Every ${n} hours`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fixed minute, every hour: M * * * *
|
|
70
|
+
if (/^\d+$/.test(minute) && hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
|
71
|
+
const m = parseInt(minute, 10)
|
|
72
|
+
return m === 0 ? 'Every hour' : `Every hour at minute ${m}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// From here, we need a fixed minute and hour
|
|
76
|
+
const fixedMinute = /^\d+$/.test(minute) ? parseInt(minute, 10) : null
|
|
77
|
+
const fixedHour = /^\d+$/.test(hour) ? parseInt(hour, 10) : null
|
|
78
|
+
|
|
79
|
+
if (fixedMinute === null || fixedHour === null) return raw
|
|
80
|
+
|
|
81
|
+
const time = formatTime(fixedHour, fixedMinute)
|
|
82
|
+
const atTime = fixedHour === 0 && fixedMinute === 0 ? 'at midnight' : `at ${time}`
|
|
83
|
+
|
|
84
|
+
// Specific day-of-week
|
|
85
|
+
if (dom === '*' && month === '*' && dow !== '*') {
|
|
86
|
+
const dowDesc = parseDowRange(dow)
|
|
87
|
+
if (!dowDesc) return raw
|
|
88
|
+
if (dowDesc === 'Weekdays' || dowDesc === 'Weekends') return `${dowDesc} ${atTime}`
|
|
89
|
+
return `${dowDesc} ${atTime}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Specific day-of-month (any month)
|
|
93
|
+
if (/^\d+$/.test(dom) && month === '*' && dow === '*') {
|
|
94
|
+
const d = parseInt(dom, 10)
|
|
95
|
+
return `${ordinal(d)} of every month ${atTime}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Specific month and day-of-month
|
|
99
|
+
if (/^\d+$/.test(dom) && /^\d+$/.test(month) && dow === '*') {
|
|
100
|
+
const d = parseInt(dom, 10)
|
|
101
|
+
const mo = parseInt(month, 10)
|
|
102
|
+
if (mo >= 1 && mo <= 12) {
|
|
103
|
+
return `${MONTH_NAMES[mo]} ${ordinal(d)} ${atTime}`
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Every day at specific time
|
|
108
|
+
if (dom === '*' && month === '*' && dow === '*') {
|
|
109
|
+
return `Every day ${atTime}`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback
|
|
113
|
+
return raw
|
|
114
|
+
}
|
package/src/lib/memory.ts
CHANGED
|
@@ -40,3 +40,6 @@ export const updateMemory = (id: string, data: Partial<MemoryEntry>) =>
|
|
|
40
40
|
|
|
41
41
|
export const deleteMemory = (id: string) =>
|
|
42
42
|
api<string>('DELETE', `/memory/${id}`)
|
|
43
|
+
|
|
44
|
+
export const getMemoryCounts = () =>
|
|
45
|
+
api<Record<string, number>>('GET', '/memory?counts=true')
|
|
@@ -55,6 +55,7 @@ export interface ExecuteChatTurnInput {
|
|
|
55
55
|
onEvent?: (event: SSEEvent) => void
|
|
56
56
|
modelOverride?: string
|
|
57
57
|
heartbeatConfig?: { ackMaxChars: number; showOk: boolean; showAlerts: boolean; target: string | null }
|
|
58
|
+
replyToId?: string
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
export interface ExecuteChatTurnResult {
|
|
@@ -341,13 +342,26 @@ function resolveApiKeyForSession(session: SessionWithCredentials, provider: Prov
|
|
|
341
342
|
return null
|
|
342
343
|
}
|
|
343
344
|
|
|
345
|
+
function stripMarkupForHeartbeat(text: string): string {
|
|
346
|
+
return text
|
|
347
|
+
.replace(/<[^>]*>/g, ' ') // strip HTML tags
|
|
348
|
+
.replace(/ /gi, ' ') // decode nbsp
|
|
349
|
+
.replace(/^[*`~_]+/, '') // strip leading markdown
|
|
350
|
+
.replace(/[*`~_]+$/, '') // strip trailing markdown
|
|
351
|
+
.trim()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const HEARTBEAT_OK_RE = /HEARTBEAT_OK[^\w]{0,4}$/
|
|
355
|
+
const NO_MESSAGE_RE = /NO_MESSAGE[^\w]{0,4}$/
|
|
356
|
+
|
|
344
357
|
function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
347
|
-
|
|
358
|
+
const cleaned = stripMarkupForHeartbeat(text)
|
|
359
|
+
if (cleaned === 'HEARTBEAT_OK' || cleaned === 'NO_MESSAGE') return 'suppress'
|
|
360
|
+
if (HEARTBEAT_OK_RE.test(cleaned) || NO_MESSAGE_RE.test(cleaned)) return 'suppress'
|
|
361
|
+
const stripped = cleaned.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
|
|
348
362
|
if (!stripped) return 'suppress'
|
|
349
363
|
if (stripped.length <= ackMaxChars) return 'suppress'
|
|
350
|
-
return stripped.length <
|
|
364
|
+
return stripped.length < cleaned.length ? 'strip' : 'keep'
|
|
351
365
|
}
|
|
352
366
|
|
|
353
367
|
function estimateConversationTone(text: string): string {
|
|
@@ -542,6 +556,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
542
556
|
imagePath: imagePath || undefined,
|
|
543
557
|
imageUrl: imageUrl || undefined,
|
|
544
558
|
attachedFiles: attachedFiles?.length ? attachedFiles : undefined,
|
|
559
|
+
replyToId: input.replyToId || undefined,
|
|
545
560
|
})
|
|
546
561
|
session.lastActiveAt = Date.now()
|
|
547
562
|
saveSessions(sessions)
|
|
@@ -857,6 +872,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
857
872
|
heartbeatClassification = classifyHeartbeatResponse(textForPersistence, heartbeatConfig?.ackMaxChars ?? 300)
|
|
858
873
|
}
|
|
859
874
|
|
|
875
|
+
// Emit WS notification for every heartbeat completion so UI can show pulse
|
|
876
|
+
if (isHeartbeatRun && session.agentId) {
|
|
877
|
+
notify(`heartbeat:agent:${session.agentId}`)
|
|
878
|
+
}
|
|
879
|
+
|
|
860
880
|
const shouldPersistAssistant = textForPersistence.length > 0
|
|
861
881
|
&& heartbeatClassification !== 'suppress'
|
|
862
882
|
|
|
@@ -7,6 +7,8 @@ import { WORKSPACE_DIR } from '../data-dir'
|
|
|
7
7
|
import { streamAgentChat } from '../stream-agent-chat'
|
|
8
8
|
import { notify } from '../ws-hub'
|
|
9
9
|
import { logExecution } from '../execution-log'
|
|
10
|
+
import { enqueueSystemEvent } from '../system-events'
|
|
11
|
+
import { requestHeartbeatNow } from '../heartbeat-wake'
|
|
10
12
|
import type { Connector } from '@/types'
|
|
11
13
|
import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
|
|
12
14
|
import {
|
|
@@ -431,6 +433,15 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
431
433
|
const agent = agents[effectiveAgentId]
|
|
432
434
|
if (!agent) return '[Error] Connector agent not found.'
|
|
433
435
|
|
|
436
|
+
// Enqueue system event + heartbeat wake for the agent
|
|
437
|
+
const preview = (msg.text || '').slice(0, 80)
|
|
438
|
+
enqueueSystemEvent(
|
|
439
|
+
`connector:${connector.id}:${msg.channelId}`,
|
|
440
|
+
`Inbound message from ${msg.platform}: ${preview}`,
|
|
441
|
+
'connector-message',
|
|
442
|
+
)
|
|
443
|
+
requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
|
|
444
|
+
|
|
434
445
|
// Log connector trigger
|
|
435
446
|
const triggerSessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
436
447
|
const allSessions = loadSessions()
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import type { Message
|
|
1
|
+
import type { Message } from '@/types'
|
|
2
2
|
import { getMemoryDb } from './memory-db'
|
|
3
3
|
|
|
4
|
+
// --- LLM compaction constants ---
|
|
5
|
+
|
|
6
|
+
const COMPACTION_CHUNK_BUDGET_RATIO = 0.4 // 40% of context per summarization chunk
|
|
7
|
+
const COMPACTION_SAFETY_MARGIN = 1.2 // 20% buffer for token underestimation
|
|
8
|
+
const COMPACTION_OVERHEAD_TOKENS = 4096 // reserved for summarization prompt + response
|
|
9
|
+
const MAX_TOOL_FAILURES = 8
|
|
10
|
+
const MAX_FAILURE_CHARS = 240
|
|
11
|
+
|
|
12
|
+
/** Callback that sends a prompt to an LLM and returns response text */
|
|
13
|
+
export type LLMSummarizer = (prompt: string) => Promise<string>
|
|
14
|
+
|
|
4
15
|
// --- Context window sizes (tokens) per provider/model ---
|
|
5
16
|
|
|
6
17
|
const PROVIDER_CONTEXT_WINDOWS: Record<string, number> = {
|
|
@@ -160,6 +171,125 @@ export function consolidateToMemory(
|
|
|
160
171
|
return stored
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// --- LLM compaction helpers ---
|
|
175
|
+
|
|
176
|
+
/** Extract recent tool failures from messages for metadata appendix */
|
|
177
|
+
export function extractToolFailures(messages: Message[]): string[] {
|
|
178
|
+
const failures: string[] = []
|
|
179
|
+
for (const m of messages) {
|
|
180
|
+
if (!m.toolEvents) continue
|
|
181
|
+
for (const te of m.toolEvents) {
|
|
182
|
+
if (!te.error) continue
|
|
183
|
+
const snippet = (te.output || '').slice(0, MAX_FAILURE_CHARS)
|
|
184
|
+
failures.push(`[${te.name}] error: ${snippet}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return failures.slice(-MAX_TOOL_FAILURES)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Extract file paths read and modified from tool events */
|
|
191
|
+
export function extractFileOperations(messages: Message[]): { read: string[]; modified: string[] } {
|
|
192
|
+
const readSet = new Set<string>()
|
|
193
|
+
const modifiedSet = new Set<string>()
|
|
194
|
+
|
|
195
|
+
const READ_TOOLS = new Set(['read_file', 'list_files'])
|
|
196
|
+
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'copy_file', 'move_file', 'delete_file'])
|
|
197
|
+
|
|
198
|
+
for (const m of messages) {
|
|
199
|
+
if (!m.toolEvents) continue
|
|
200
|
+
for (const te of m.toolEvents) {
|
|
201
|
+
let parsed: Record<string, unknown> | null = null
|
|
202
|
+
try { parsed = JSON.parse(te.input) } catch { /* not JSON */ }
|
|
203
|
+
if (!parsed) continue
|
|
204
|
+
|
|
205
|
+
const paths: string[] = []
|
|
206
|
+
for (const key of ['filePath', 'sourcePath', 'destinationPath']) {
|
|
207
|
+
const v = parsed[key]
|
|
208
|
+
if (typeof v === 'string' && v) paths.push(v)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const isRead = READ_TOOLS.has(te.name)
|
|
212
|
+
const isWrite = WRITE_TOOLS.has(te.name)
|
|
213
|
+
for (const p of paths) {
|
|
214
|
+
if (isWrite) modifiedSet.add(p)
|
|
215
|
+
else if (isRead) readSet.add(p)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { read: [...readSet], modified: [...modifiedSet] }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Split messages into chunks that fit within a token budget each */
|
|
223
|
+
export function splitMessagesByTokenBudget(messages: Message[], budgetPerChunk: number): Message[][] {
|
|
224
|
+
if (messages.length === 0) return []
|
|
225
|
+
const chunks: Message[][] = []
|
|
226
|
+
let current: Message[] = []
|
|
227
|
+
let currentTokens = 0
|
|
228
|
+
|
|
229
|
+
for (const m of messages) {
|
|
230
|
+
const msgTokens = estimateMessagesTokens([m])
|
|
231
|
+
if (current.length > 0 && currentTokens + msgTokens > budgetPerChunk) {
|
|
232
|
+
chunks.push(current)
|
|
233
|
+
current = []
|
|
234
|
+
currentTokens = 0
|
|
235
|
+
}
|
|
236
|
+
current.push(m)
|
|
237
|
+
currentTokens += msgTokens
|
|
238
|
+
}
|
|
239
|
+
if (current.length > 0) chunks.push(current)
|
|
240
|
+
return chunks
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Build an OpenClaw-aligned summarization prompt for a batch of messages */
|
|
244
|
+
function buildSummarizationPrompt(messages: Message[]): string {
|
|
245
|
+
const transcript = messages.map((m) => {
|
|
246
|
+
let line = `[${m.role}]: ${m.text}`
|
|
247
|
+
if (m.toolEvents?.length) {
|
|
248
|
+
for (const te of m.toolEvents) {
|
|
249
|
+
const inp = (te.input || '').slice(0, 500)
|
|
250
|
+
const out = (te.output || '').slice(0, 500)
|
|
251
|
+
line += `\n tool:${te.name}(${inp})${te.error ? ' [ERROR]' : ''} → ${out}`
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return line
|
|
255
|
+
}).join('\n\n')
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
'Summarize the following conversation transcript into structured notes.',
|
|
259
|
+
'',
|
|
260
|
+
'Rules:',
|
|
261
|
+
'- Preserve all decisions, TODOs, open questions, and constraints',
|
|
262
|
+
'- Preserve all opaque identifiers exactly as they appear (UUIDs, hashes, IDs, URLs, file paths, API keys, variable names)',
|
|
263
|
+
'- Note errors encountered and their resolutions',
|
|
264
|
+
'- Keep technical details needed to continue work (versions, configs, commands)',
|
|
265
|
+
'- Aim for 20-40% of original length',
|
|
266
|
+
'- Use structured notes with bullet points, not narrative prose',
|
|
267
|
+
'- Group by topic/theme when possible',
|
|
268
|
+
'',
|
|
269
|
+
'---TRANSCRIPT---',
|
|
270
|
+
transcript,
|
|
271
|
+
'---END TRANSCRIPT---',
|
|
272
|
+
].join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Build a merge prompt for combining multiple partial summaries */
|
|
276
|
+
function buildMergePrompt(partialSummaries: string[]): string {
|
|
277
|
+
const numbered = partialSummaries.map((s, i) => `--- Part ${i + 1} ---\n${s}`).join('\n\n')
|
|
278
|
+
|
|
279
|
+
return [
|
|
280
|
+
'Merge the following partial conversation summaries into a single cohesive summary.',
|
|
281
|
+
'',
|
|
282
|
+
'Rules:',
|
|
283
|
+
'- Remove redundancy across parts while preserving all important details',
|
|
284
|
+
'- Preserve all opaque identifiers exactly (UUIDs, hashes, IDs, URLs, file paths)',
|
|
285
|
+
'- Keep decisions, TODOs, open questions, constraints, and error resolutions',
|
|
286
|
+
'- Use structured notes with bullet points',
|
|
287
|
+
'- The result should be shorter than the combined input',
|
|
288
|
+
'',
|
|
289
|
+
numbered,
|
|
290
|
+
].join('\n')
|
|
291
|
+
}
|
|
292
|
+
|
|
163
293
|
// --- Compaction strategies ---
|
|
164
294
|
|
|
165
295
|
export interface CompactionResult {
|
|
@@ -178,15 +308,18 @@ export function slidingWindowCompact(
|
|
|
178
308
|
return messages.slice(-keepLastN)
|
|
179
309
|
}
|
|
180
310
|
|
|
181
|
-
/**
|
|
182
|
-
export async function
|
|
311
|
+
/** LLM-powered compaction: summarize old messages using an LLM, with progressive fallback */
|
|
312
|
+
export async function llmCompact(opts: {
|
|
183
313
|
messages: Message[]
|
|
184
|
-
|
|
314
|
+
provider: string
|
|
315
|
+
model: string
|
|
185
316
|
agentId: string | null
|
|
186
317
|
sessionId: string
|
|
187
|
-
|
|
318
|
+
summarize: LLMSummarizer
|
|
319
|
+
keepLastN?: number
|
|
188
320
|
}): Promise<CompactionResult> {
|
|
189
|
-
const { messages,
|
|
321
|
+
const { messages, provider, model, agentId, sessionId, summarize, keepLastN = 10 } = opts
|
|
322
|
+
|
|
190
323
|
if (messages.length <= keepLastN) {
|
|
191
324
|
return { messages, prunedCount: 0, memoriesStored: 0, summaryAdded: false }
|
|
192
325
|
}
|
|
@@ -194,19 +327,75 @@ export async function summarizeAndCompact(opts: {
|
|
|
194
327
|
const oldMessages = messages.slice(0, -keepLastN)
|
|
195
328
|
const recentMessages = messages.slice(-keepLastN)
|
|
196
329
|
|
|
197
|
-
// Consolidate important info to memory
|
|
330
|
+
// 1. Consolidate important info to memory (existing regex extraction)
|
|
198
331
|
const memoriesStored = consolidateToMemory(oldMessages, agentId, sessionId)
|
|
199
332
|
|
|
200
|
-
//
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
333
|
+
// 2. Extract metadata from old messages
|
|
334
|
+
const toolFailures = extractToolFailures(oldMessages)
|
|
335
|
+
const fileOps = extractFileOperations(oldMessages)
|
|
336
|
+
|
|
337
|
+
// 3. Compute chunk budget
|
|
338
|
+
const contextWindow = getContextWindowSize(provider, model)
|
|
339
|
+
const chunkBudget = Math.floor((contextWindow / COMPACTION_SAFETY_MARGIN) * COMPACTION_CHUNK_BUDGET_RATIO) - COMPACTION_OVERHEAD_TOKENS
|
|
340
|
+
|
|
341
|
+
// 4. Split old messages into chunks
|
|
342
|
+
const chunks = splitMessagesByTokenBudget(oldMessages, Math.max(chunkBudget, 2000))
|
|
343
|
+
|
|
344
|
+
// 5. Summarize chunks (progressive fallback on failure)
|
|
345
|
+
let finalSummary: string | null = null
|
|
346
|
+
try {
|
|
347
|
+
if (chunks.length === 1) {
|
|
348
|
+
finalSummary = await summarize(buildSummarizationPrompt(chunks[0]))
|
|
349
|
+
} else {
|
|
350
|
+
// Multi-chunk: summarize each, then merge
|
|
351
|
+
const partialSummaries: string[] = []
|
|
352
|
+
for (const chunk of chunks) {
|
|
353
|
+
try {
|
|
354
|
+
const partial = await summarize(buildSummarizationPrompt(chunk))
|
|
355
|
+
if (partial?.trim()) partialSummaries.push(partial.trim())
|
|
356
|
+
} catch {
|
|
357
|
+
// Skip failed chunks — progressive fallback
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (partialSummaries.length === 0) {
|
|
361
|
+
finalSummary = null // all chunks failed
|
|
362
|
+
} else if (partialSummaries.length === 1) {
|
|
363
|
+
finalSummary = partialSummaries[0]
|
|
364
|
+
} else {
|
|
365
|
+
finalSummary = await summarize(buildMergePrompt(partialSummaries))
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
finalSummary = null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 6. Fall back to sliding window if LLM summarization failed entirely
|
|
373
|
+
if (!finalSummary?.trim()) {
|
|
374
|
+
return {
|
|
375
|
+
messages: slidingWindowCompact(messages, keepLastN),
|
|
376
|
+
prunedCount: oldMessages.length,
|
|
377
|
+
memoriesStored,
|
|
378
|
+
summaryAdded: false,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 7. Append metadata sections
|
|
383
|
+
const metaSections: string[] = [finalSummary.trim()]
|
|
204
384
|
|
|
205
|
-
|
|
385
|
+
if (toolFailures.length > 0) {
|
|
386
|
+
metaSections.push('\n## Tool Failures\n' + toolFailures.join('\n'))
|
|
387
|
+
}
|
|
388
|
+
if (fileOps.read.length > 0 || fileOps.modified.length > 0) {
|
|
389
|
+
const parts: string[] = []
|
|
390
|
+
if (fileOps.read.length) parts.push('Read: ' + fileOps.read.join(', '))
|
|
391
|
+
if (fileOps.modified.length) parts.push('Modified: ' + fileOps.modified.join(', '))
|
|
392
|
+
metaSections.push('\n## File Operations\n' + parts.join('\n'))
|
|
393
|
+
}
|
|
206
394
|
|
|
395
|
+
// 8. Build context summary message
|
|
207
396
|
const summaryMessage: Message = {
|
|
208
397
|
role: 'assistant',
|
|
209
|
-
text: `[Context Summary]\n${
|
|
398
|
+
text: `[Context Summary]\n${metaSections.join('\n')}`,
|
|
210
399
|
time: Date.now(),
|
|
211
400
|
kind: 'system',
|
|
212
401
|
}
|
|
@@ -219,6 +408,29 @@ export async function summarizeAndCompact(opts: {
|
|
|
219
408
|
}
|
|
220
409
|
}
|
|
221
410
|
|
|
411
|
+
/** Summarize old messages, keep recent ones. Delegates to llmCompact for LLM-powered summarization. */
|
|
412
|
+
export async function summarizeAndCompact(opts: {
|
|
413
|
+
messages: Message[]
|
|
414
|
+
keepLastN: number
|
|
415
|
+
agentId: string | null
|
|
416
|
+
sessionId: string
|
|
417
|
+
provider: string
|
|
418
|
+
model: string
|
|
419
|
+
generateSummary: LLMSummarizer
|
|
420
|
+
}): Promise<CompactionResult> {
|
|
421
|
+
const { messages, keepLastN, agentId, sessionId, provider, model, generateSummary } = opts
|
|
422
|
+
|
|
423
|
+
return llmCompact({
|
|
424
|
+
messages,
|
|
425
|
+
provider,
|
|
426
|
+
model,
|
|
427
|
+
agentId,
|
|
428
|
+
sessionId,
|
|
429
|
+
summarize: generateSummary,
|
|
430
|
+
keepLastN,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
222
434
|
/** Auto-compact: triggers when estimated tokens exceed threshold */
|
|
223
435
|
export function shouldAutoCompact(
|
|
224
436
|
messages: Message[],
|