@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.
Files changed (173) hide show
  1. package/README.md +42 -7
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/public/screenshots/agents.png +0 -0
  6. package/public/screenshots/dashboard.png +0 -0
  7. package/public/screenshots/providers.png +0 -0
  8. package/public/screenshots/tasks.png +0 -0
  9. package/scripts/postinstall.mjs +18 -0
  10. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  11. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  12. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  13. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  14. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  15. package/src/app/api/chatrooms/route.ts +50 -0
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/knowledge/[id]/route.ts +13 -2
  18. package/src/app/api/knowledge/route.ts +8 -1
  19. package/src/app/api/memory/route.ts +8 -0
  20. package/src/app/api/notifications/[id]/route.ts +27 -0
  21. package/src/app/api/notifications/route.ts +68 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +155 -0
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/route.ts +3 -3
  29. package/src/app/api/settings/route.ts +9 -0
  30. package/src/app/api/setup/check-provider/route.ts +3 -16
  31. package/src/app/api/skills/[id]/route.ts +6 -0
  32. package/src/app/api/skills/route.ts +6 -0
  33. package/src/app/api/tasks/[id]/route.ts +20 -0
  34. package/src/app/api/tasks/bulk/route.ts +100 -0
  35. package/src/app/api/tasks/route.ts +1 -0
  36. package/src/app/api/usage/route.ts +45 -0
  37. package/src/app/api/webhooks/[id]/route.ts +15 -1
  38. package/src/app/globals.css +58 -15
  39. package/src/app/page.tsx +142 -13
  40. package/src/cli/index.js +42 -0
  41. package/src/cli/index.test.js +30 -0
  42. package/src/cli/spec.js +32 -0
  43. package/src/components/agents/agent-avatar.tsx +57 -10
  44. package/src/components/agents/agent-card.tsx +48 -15
  45. package/src/components/agents/agent-chat-list.tsx +123 -10
  46. package/src/components/agents/agent-list.tsx +50 -19
  47. package/src/components/agents/agent-sheet.tsx +56 -63
  48. package/src/components/auth/access-key-gate.tsx +10 -3
  49. package/src/components/auth/setup-wizard.tsx +2 -2
  50. package/src/components/auth/user-picker.tsx +31 -3
  51. package/src/components/chat/activity-moment.tsx +169 -0
  52. package/src/components/chat/chat-header.tsx +2 -0
  53. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  54. package/src/components/chat/file-path-chip.tsx +125 -0
  55. package/src/components/chat/markdown-utils.ts +9 -0
  56. package/src/components/chat/message-bubble.tsx +46 -295
  57. package/src/components/chat/message-list.tsx +50 -1
  58. package/src/components/chat/streaming-bubble.tsx +36 -46
  59. package/src/components/chat/suggestions-bar.tsx +1 -1
  60. package/src/components/chat/thinking-indicator.tsx +72 -10
  61. package/src/components/chat/tool-call-bubble.tsx +66 -70
  62. package/src/components/chat/tool-request-banner.tsx +31 -7
  63. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  64. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  65. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  66. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  67. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  68. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  69. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  70. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  71. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  72. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  73. package/src/components/connectors/connector-sheet.tsx +34 -47
  74. package/src/components/home/home-view.tsx +501 -0
  75. package/src/components/input/chat-input.tsx +79 -41
  76. package/src/components/knowledge/knowledge-list.tsx +31 -1
  77. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  78. package/src/components/layout/app-layout.tsx +209 -83
  79. package/src/components/layout/mobile-header.tsx +2 -0
  80. package/src/components/layout/update-banner.tsx +2 -2
  81. package/src/components/logs/log-list.tsx +2 -2
  82. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  83. package/src/components/memory/memory-agent-list.tsx +143 -0
  84. package/src/components/memory/memory-browser.tsx +205 -0
  85. package/src/components/memory/memory-card.tsx +34 -7
  86. package/src/components/memory/memory-detail.tsx +359 -120
  87. package/src/components/memory/memory-sheet.tsx +157 -23
  88. package/src/components/plugins/plugin-list.tsx +1 -1
  89. package/src/components/plugins/plugin-sheet.tsx +1 -1
  90. package/src/components/projects/project-detail.tsx +509 -0
  91. package/src/components/projects/project-list.tsx +195 -59
  92. package/src/components/providers/provider-list.tsx +2 -2
  93. package/src/components/providers/provider-sheet.tsx +3 -3
  94. package/src/components/schedules/schedule-card.tsx +3 -2
  95. package/src/components/schedules/schedule-list.tsx +1 -1
  96. package/src/components/schedules/schedule-sheet.tsx +25 -25
  97. package/src/components/secrets/secret-sheet.tsx +47 -24
  98. package/src/components/secrets/secrets-list.tsx +18 -8
  99. package/src/components/sessions/new-session-sheet.tsx +33 -65
  100. package/src/components/sessions/session-card.tsx +45 -14
  101. package/src/components/sessions/session-list.tsx +35 -18
  102. package/src/components/shared/agent-picker-list.tsx +90 -0
  103. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  104. package/src/components/shared/attachment-chip.tsx +165 -0
  105. package/src/components/shared/avatar.tsx +10 -1
  106. package/src/components/shared/check-icon.tsx +12 -0
  107. package/src/components/shared/confirm-dialog.tsx +1 -1
  108. package/src/components/shared/empty-state.tsx +32 -0
  109. package/src/components/shared/file-preview.tsx +34 -0
  110. package/src/components/shared/form-styles.ts +2 -0
  111. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  112. package/src/components/shared/notification-center.tsx +223 -0
  113. package/src/components/shared/profile-sheet.tsx +115 -0
  114. package/src/components/shared/reply-quote.tsx +26 -0
  115. package/src/components/shared/search-dialog.tsx +296 -0
  116. package/src/components/shared/section-label.tsx +12 -0
  117. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  118. package/src/components/shared/settings/section-providers.tsx +1 -1
  119. package/src/components/shared/settings/section-secrets.tsx +1 -1
  120. package/src/components/shared/settings/section-theme.tsx +95 -0
  121. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  122. package/src/components/shared/settings/settings-page.tsx +180 -27
  123. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  124. package/src/components/shared/sheet-footer.tsx +33 -0
  125. package/src/components/skills/skill-list.tsx +61 -30
  126. package/src/components/skills/skill-sheet.tsx +81 -2
  127. package/src/components/tasks/task-board.tsx +448 -26
  128. package/src/components/tasks/task-card.tsx +46 -9
  129. package/src/components/tasks/task-column.tsx +62 -3
  130. package/src/components/tasks/task-list.tsx +12 -4
  131. package/src/components/tasks/task-sheet.tsx +89 -72
  132. package/src/components/ui/hover-card.tsx +52 -0
  133. package/src/components/usage/metrics-dashboard.tsx +78 -0
  134. package/src/components/usage/usage-list.tsx +1 -1
  135. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  136. package/src/hooks/use-view-router.ts +69 -19
  137. package/src/instrumentation.ts +15 -1
  138. package/src/lib/chat.ts +2 -0
  139. package/src/lib/cron-human.ts +114 -0
  140. package/src/lib/memory.ts +3 -0
  141. package/src/lib/server/chat-execution.ts +24 -4
  142. package/src/lib/server/connectors/manager.ts +11 -0
  143. package/src/lib/server/context-manager.ts +225 -13
  144. package/src/lib/server/create-notification.ts +42 -0
  145. package/src/lib/server/daemon-state.ts +165 -10
  146. package/src/lib/server/execution-log.ts +1 -0
  147. package/src/lib/server/heartbeat-service.ts +40 -5
  148. package/src/lib/server/heartbeat-wake.ts +110 -0
  149. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  150. package/src/lib/server/memory-consolidation.ts +92 -0
  151. package/src/lib/server/memory-db.ts +51 -6
  152. package/src/lib/server/openclaw-gateway.ts +9 -1
  153. package/src/lib/server/provider-health.ts +125 -0
  154. package/src/lib/server/queue.ts +5 -4
  155. package/src/lib/server/scheduler.ts +8 -0
  156. package/src/lib/server/session-run-manager.ts +4 -0
  157. package/src/lib/server/session-tools/chatroom.ts +136 -0
  158. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  159. package/src/lib/server/session-tools/index.ts +2 -0
  160. package/src/lib/server/session-tools/memory.ts +6 -1
  161. package/src/lib/server/storage.ts +80 -29
  162. package/src/lib/server/stream-agent-chat.ts +153 -47
  163. package/src/lib/server/system-events.ts +49 -0
  164. package/src/lib/server/ws-hub.ts +11 -0
  165. package/src/lib/soul-suggestions.ts +109 -0
  166. package/src/lib/tasks.ts +4 -1
  167. package/src/lib/view-routes.ts +36 -1
  168. package/src/lib/ws-client.ts +14 -4
  169. package/src/proxy.ts +79 -2
  170. package/src/stores/use-app-store.ts +94 -3
  171. package/src/stores/use-chat-store.ts +48 -3
  172. package/src/stores/use-chatroom-store.ts +276 -0
  173. 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 { VIEW_TO_PATH, PATH_TO_VIEW, DEFAULT_VIEW } from '@/lib/view-routes'
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 view = PATH_TO_VIEW[window.location.pathname]
13
- if (view) {
14
- useAppStore.getState().setActiveView(view)
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, '', VIEW_TO_PATH[DEFAULT_VIEW])
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 prev = useAppStore.getState().activeView
24
- const unsub = useAppStore.subscribe((state) => {
25
- const next = state.activeView
26
- if (next === prev) return
27
- prev = next
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 = VIEW_TO_PATH[next]
33
- if (targetPath && window.location.pathname !== targetPath) {
75
+ const targetPath = buildPath('chatrooms', nextId)
76
+ if (window.location.pathname !== targetPath) {
34
77
  window.history.pushState(null, '', targetPath)
35
78
  }
36
79
  })
37
- return unsub
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 view = PATH_TO_VIEW[window.location.pathname]
44
- if (view) {
90
+ const parsed = parsePath(window.location.pathname)
91
+ if (parsed) {
45
92
  fromPopstate.current = true
46
- useAppStore.getState().setActiveView(view)
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)
@@ -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(/&nbsp;/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 trimmed = text.trim()
346
- if (trimmed === 'HEARTBEAT_OK' || trimmed === 'NO_MESSAGE') return 'suppress'
347
- const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
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 < trimmed.length ? 'strip' : 'keep'
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, ProviderType } from '@/types'
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
- /** Summarize old messages, keep recent ones */
182
- export async function summarizeAndCompact(opts: {
311
+ /** LLM-powered compaction: summarize old messages using an LLM, with progressive fallback */
312
+ export async function llmCompact(opts: {
183
313
  messages: Message[]
184
- keepLastN: number
314
+ provider: string
315
+ model: string
185
316
  agentId: string | null
186
317
  sessionId: string
187
- generateSummary: (text: string) => Promise<string>
318
+ summarize: LLMSummarizer
319
+ keepLastN?: number
188
320
  }): Promise<CompactionResult> {
189
- const { messages, keepLastN, agentId, sessionId, generateSummary } = opts
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 before pruning
330
+ // 1. Consolidate important info to memory (existing regex extraction)
198
331
  const memoriesStored = consolidateToMemory(oldMessages, agentId, sessionId)
199
332
 
200
- // Build text for summarization
201
- const conversationText = oldMessages
202
- .map((m) => `${m.role}: ${m.text}`)
203
- .join('\n\n')
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
- const summary = await generateSummary(conversationText)
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${summary}`,
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[],