@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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { genId } from '@/lib/id'
|
|
2
|
+
import { saveNotification, hasUnreadNotificationWithKey } from '@/lib/server/storage'
|
|
3
|
+
import { notify } from '@/lib/server/ws-hub'
|
|
4
|
+
import type { AppNotification } from '@/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create and persist a notification, then push a WS invalidation.
|
|
8
|
+
* If `dedupKey` is provided and an unread notification with the same key
|
|
9
|
+
* already exists, returns `null` (no insert, no WS push).
|
|
10
|
+
*/
|
|
11
|
+
export function createNotification(opts: {
|
|
12
|
+
type: AppNotification['type']
|
|
13
|
+
title: string
|
|
14
|
+
message?: string
|
|
15
|
+
actionLabel?: string
|
|
16
|
+
actionUrl?: string
|
|
17
|
+
entityType?: string
|
|
18
|
+
entityId?: string
|
|
19
|
+
dedupKey?: string
|
|
20
|
+
}): AppNotification | null {
|
|
21
|
+
if (opts.dedupKey && hasUnreadNotificationWithKey(opts.dedupKey)) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const id = genId()
|
|
26
|
+
const notification: AppNotification = {
|
|
27
|
+
id,
|
|
28
|
+
type: opts.type,
|
|
29
|
+
title: opts.title,
|
|
30
|
+
message: opts.message,
|
|
31
|
+
actionLabel: opts.actionLabel,
|
|
32
|
+
actionUrl: opts.actionUrl,
|
|
33
|
+
entityType: opts.entityType,
|
|
34
|
+
entityId: opts.entityId,
|
|
35
|
+
dedupKey: opts.dedupKey,
|
|
36
|
+
read: false,
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
}
|
|
39
|
+
saveNotification(id, notification)
|
|
40
|
+
notify('notifications')
|
|
41
|
+
return notification
|
|
42
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors, saveConnectors, loadWebhookRetryQueue, upsertWebhookRetry, deleteWebhookRetry, loadWebhooks, loadAgents, appendWebhookLog } from './storage'
|
|
1
|
+
import { loadQueue, loadSchedules, loadSessions, saveSessions, loadConnectors, saveConnectors, loadWebhookRetryQueue, upsertWebhookRetry, deleteWebhookRetry, loadWebhooks, loadAgents, appendWebhookLog, loadCredentials, decryptKey } from './storage'
|
|
2
2
|
import { notify } from './ws-hub'
|
|
3
3
|
import { processNext, cleanupFinishedTaskSessions, validateCompletedTasksQueue, recoverStalledRunningTasks } from './queue'
|
|
4
4
|
import { startScheduler, stopScheduler } from './scheduler'
|
|
@@ -17,11 +17,15 @@ import { enqueueSessionRun } from './session-run-manager'
|
|
|
17
17
|
import { WORKSPACE_DIR } from './data-dir'
|
|
18
18
|
import { genId } from '@/lib/id'
|
|
19
19
|
import type { WebhookRetryEntry } from '@/types'
|
|
20
|
+
import { createNotification } from '@/lib/server/create-notification'
|
|
21
|
+
import { pingProvider, OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
|
|
20
22
|
|
|
21
23
|
const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
|
|
22
24
|
const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
|
|
23
25
|
const BROWSER_MAX_AGE = 10 * 60 * 1000 // 10 minutes idle = orphaned
|
|
24
26
|
const HEALTH_CHECK_INTERVAL = 120_000 // 2 minutes
|
|
27
|
+
const MEMORY_CONSOLIDATION_INTERVAL = 6 * 3600_000 // 6 hours
|
|
28
|
+
const MEMORY_CONSOLIDATION_INITIAL_DELAY = 60_000 // 1 minute after daemon start
|
|
25
29
|
const STALE_MULTIPLIER = 4 // session is stale after N × heartbeat interval
|
|
26
30
|
const STALE_MIN_MS = 4 * 60 * 1000 // minimum 4 minutes regardless of interval
|
|
27
31
|
const STALE_AUTO_DISABLE_MULTIPLIER = 16 // auto-disable after much longer sustained staleness
|
|
@@ -73,6 +77,8 @@ const ds: {
|
|
|
73
77
|
queueIntervalId: ReturnType<typeof setInterval> | null
|
|
74
78
|
browserSweepId: ReturnType<typeof setInterval> | null
|
|
75
79
|
healthIntervalId: ReturnType<typeof setInterval> | null
|
|
80
|
+
memoryConsolidationTimeoutId: ReturnType<typeof setTimeout> | null
|
|
81
|
+
memoryConsolidationIntervalId: ReturnType<typeof setInterval> | null
|
|
76
82
|
/** Session IDs we've already alerted as stale (alert-once semantics). */
|
|
77
83
|
staleSessionIds: Set<string>
|
|
78
84
|
connectorRestartState: Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>
|
|
@@ -84,6 +90,8 @@ const ds: {
|
|
|
84
90
|
queueIntervalId: null,
|
|
85
91
|
browserSweepId: null,
|
|
86
92
|
healthIntervalId: null,
|
|
93
|
+
memoryConsolidationTimeoutId: null,
|
|
94
|
+
memoryConsolidationIntervalId: null,
|
|
87
95
|
staleSessionIds: new Set<string>(),
|
|
88
96
|
connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>(),
|
|
89
97
|
manualStopRequested: false,
|
|
@@ -99,6 +107,8 @@ if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { last
|
|
|
99
107
|
if ((ds as any).issueLastAlertAt) delete (ds as any).issueLastAlertAt
|
|
100
108
|
if (ds.healthIntervalId === undefined) ds.healthIntervalId = null
|
|
101
109
|
if (ds.manualStopRequested === undefined) ds.manualStopRequested = false
|
|
110
|
+
if (ds.memoryConsolidationTimeoutId === undefined) ds.memoryConsolidationTimeoutId = null
|
|
111
|
+
if (ds.memoryConsolidationIntervalId === undefined) ds.memoryConsolidationIntervalId = null
|
|
102
112
|
|
|
103
113
|
export function ensureDaemonStarted(source = 'unknown'): boolean {
|
|
104
114
|
if (ds.running) return false
|
|
@@ -120,23 +130,32 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
|
|
|
120
130
|
startBrowserSweep()
|
|
121
131
|
startHealthMonitor()
|
|
122
132
|
startHeartbeatService()
|
|
133
|
+
startMemoryConsolidation()
|
|
123
134
|
return
|
|
124
135
|
}
|
|
125
136
|
ds.running = true
|
|
126
137
|
notify('daemon')
|
|
127
138
|
console.log(`[daemon] Starting daemon (source=${source}, scheduler + queue processor + heartbeat)`)
|
|
128
139
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
try {
|
|
141
|
+
validateCompletedTasksQueue()
|
|
142
|
+
cleanupFinishedTaskSessions()
|
|
143
|
+
startScheduler()
|
|
144
|
+
startQueueProcessor()
|
|
145
|
+
startBrowserSweep()
|
|
146
|
+
startHealthMonitor()
|
|
147
|
+
startHeartbeatService()
|
|
148
|
+
startMemoryConsolidation()
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
ds.running = false
|
|
151
|
+
notify('daemon')
|
|
152
|
+
console.error('[daemon] Failed to start:', err instanceof Error ? err.message : String(err))
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
136
155
|
|
|
137
156
|
// Auto-start enabled connectors
|
|
138
|
-
autoStartConnectors().catch((err) => {
|
|
139
|
-
console.error('[daemon] Error auto-starting connectors:', err.message)
|
|
157
|
+
autoStartConnectors().catch((err: unknown) => {
|
|
158
|
+
console.error('[daemon] Error auto-starting connectors:', err instanceof Error ? err.message : String(err))
|
|
140
159
|
})
|
|
141
160
|
}
|
|
142
161
|
|
|
@@ -153,6 +172,7 @@ export function stopDaemon(options?: { source?: string; manualStop?: boolean })
|
|
|
153
172
|
stopBrowserSweep()
|
|
154
173
|
stopHealthMonitor()
|
|
155
174
|
stopHeartbeatService()
|
|
175
|
+
stopMemoryConsolidation()
|
|
156
176
|
stopAllConnectors().catch(() => {})
|
|
157
177
|
}
|
|
158
178
|
|
|
@@ -254,6 +274,14 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
254
274
|
connectors[connector.id] = connector
|
|
255
275
|
saveConnectors(connectors)
|
|
256
276
|
ds.connectorRestartState.delete(connector.id)
|
|
277
|
+
createNotification({
|
|
278
|
+
type: 'error',
|
|
279
|
+
title: `Connector "${connector.name}" failed`,
|
|
280
|
+
message: `Auto-restart gave up after ${MAX_WAKE_ATTEMPTS} consecutive failures.`,
|
|
281
|
+
dedupKey: `connector-gave-up:${connector.id}`,
|
|
282
|
+
entityType: 'connector',
|
|
283
|
+
entityId: connector.id,
|
|
284
|
+
})
|
|
257
285
|
continue
|
|
258
286
|
}
|
|
259
287
|
|
|
@@ -263,6 +291,18 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
263
291
|
)
|
|
264
292
|
if ((now - current.lastAttemptAt) < backoffMs) continue
|
|
265
293
|
|
|
294
|
+
// Notify on first detection of a down connector
|
|
295
|
+
if (current.wakeAttempts === 0) {
|
|
296
|
+
createNotification({
|
|
297
|
+
type: 'warning',
|
|
298
|
+
title: `Connector "${connector.name}" is down`,
|
|
299
|
+
message: 'Auto-restart in progress.',
|
|
300
|
+
dedupKey: `connector-down:${connector.id}`,
|
|
301
|
+
entityType: 'connector',
|
|
302
|
+
entityId: connector.id,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
266
306
|
current.lastAttemptAt = now
|
|
267
307
|
ds.connectorRestartState.set(connector.id, current)
|
|
268
308
|
try {
|
|
@@ -277,6 +317,11 @@ async function runConnectorHealthChecks(now: number) {
|
|
|
277
317
|
console.warn(`[health] Connector auto-restart failed for ${connector.name} (attempt ${current.wakeAttempts}/${MAX_WAKE_ATTEMPTS}): ${message}`)
|
|
278
318
|
}
|
|
279
319
|
}
|
|
320
|
+
|
|
321
|
+
// Purge restart state for connectors that no longer exist in storage
|
|
322
|
+
for (const id of ds.connectorRestartState.keys()) {
|
|
323
|
+
if (!connectors[id]) ds.connectorRestartState.delete(id)
|
|
324
|
+
}
|
|
280
325
|
}
|
|
281
326
|
|
|
282
327
|
async function processWebhookRetries() {
|
|
@@ -421,6 +466,73 @@ async function processWebhookRetries() {
|
|
|
421
466
|
}
|
|
422
467
|
}
|
|
423
468
|
|
|
469
|
+
async function runProviderHealthChecks() {
|
|
470
|
+
const agents = loadAgents()
|
|
471
|
+
const credentials = loadCredentials()
|
|
472
|
+
|
|
473
|
+
// Build deduplicated set of { provider, credentialId, apiEndpoint } tuples
|
|
474
|
+
const seen = new Set<string>()
|
|
475
|
+
const tuples: { provider: string; credentialId: string; apiEndpoint: string; agentId: string; credentialName: string }[] = []
|
|
476
|
+
|
|
477
|
+
for (const agent of Object.values(agents) as Record<string, unknown>[]) {
|
|
478
|
+
if (!agent?.id || typeof agent.id !== 'string') continue
|
|
479
|
+
const provider = typeof agent.provider === 'string' ? agent.provider : ''
|
|
480
|
+
if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli'].includes(provider)) continue
|
|
481
|
+
|
|
482
|
+
const credentialId = typeof agent.credentialId === 'string' ? agent.credentialId : ''
|
|
483
|
+
const apiEndpoint = typeof agent.apiEndpoint === 'string' ? agent.apiEndpoint : ''
|
|
484
|
+
|
|
485
|
+
// For OpenClaw, scope per agent (each may have a different gateway)
|
|
486
|
+
const key = provider === 'openclaw'
|
|
487
|
+
? `openclaw:${agent.id}`
|
|
488
|
+
: `${provider}:${credentialId || 'no-cred'}:${apiEndpoint}`
|
|
489
|
+
if (seen.has(key)) continue
|
|
490
|
+
seen.add(key)
|
|
491
|
+
|
|
492
|
+
const cred = credentialId ? (credentials[credentialId] as Record<string, unknown> | undefined) : undefined
|
|
493
|
+
const credName = typeof cred?.name === 'string' ? cred.name : provider
|
|
494
|
+
|
|
495
|
+
tuples.push({
|
|
496
|
+
provider,
|
|
497
|
+
credentialId,
|
|
498
|
+
apiEndpoint,
|
|
499
|
+
agentId: agent.id,
|
|
500
|
+
credentialName: credName,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
for (const tuple of tuples) {
|
|
505
|
+
let apiKey: string | undefined
|
|
506
|
+
if (tuple.credentialId) {
|
|
507
|
+
const cred = credentials[tuple.credentialId] as Record<string, unknown> | undefined
|
|
508
|
+
if (cred?.encryptedKey && typeof cred.encryptedKey === 'string') {
|
|
509
|
+
try { apiKey = decryptKey(cred.encryptedKey) } catch { /* skip undecryptable */ continue }
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const endpoint = tuple.apiEndpoint || OPENAI_COMPATIBLE_DEFAULTS[tuple.provider]?.defaultEndpoint || undefined
|
|
514
|
+
const result = await pingProvider(tuple.provider, apiKey, endpoint)
|
|
515
|
+
|
|
516
|
+
if (!result.ok) {
|
|
517
|
+
const dedupKey = tuple.provider === 'openclaw'
|
|
518
|
+
? `openclaw-down:${tuple.agentId}`
|
|
519
|
+
: `provider-down:${tuple.credentialId || tuple.provider}`
|
|
520
|
+
|
|
521
|
+
const entityType = tuple.credentialId ? 'credential' : undefined
|
|
522
|
+
const entityId = tuple.credentialId || undefined
|
|
523
|
+
|
|
524
|
+
createNotification({
|
|
525
|
+
type: 'warning',
|
|
526
|
+
title: `Provider unreachable: ${tuple.credentialName}`,
|
|
527
|
+
message: result.message,
|
|
528
|
+
dedupKey,
|
|
529
|
+
entityType,
|
|
530
|
+
entityId,
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
424
536
|
async function runHealthChecks() {
|
|
425
537
|
// Continuously keep the completed queue honest.
|
|
426
538
|
validateCompletedTasksQueue()
|
|
@@ -482,6 +594,13 @@ async function runHealthChecks() {
|
|
|
482
594
|
|
|
483
595
|
await runConnectorHealthChecks(now)
|
|
484
596
|
|
|
597
|
+
// Provider reachability checks
|
|
598
|
+
try {
|
|
599
|
+
await runProviderHealthChecks()
|
|
600
|
+
} catch (err: unknown) {
|
|
601
|
+
console.error('[daemon] Provider health check failed:', err instanceof Error ? err.message : String(err))
|
|
602
|
+
}
|
|
603
|
+
|
|
485
604
|
// Process webhook retry queue
|
|
486
605
|
try {
|
|
487
606
|
await processWebhookRetries()
|
|
@@ -506,6 +625,42 @@ function stopHealthMonitor() {
|
|
|
506
625
|
}
|
|
507
626
|
}
|
|
508
627
|
|
|
628
|
+
function runConsolidationTick() {
|
|
629
|
+
import('./memory-consolidation').then(({ runDailyConsolidation }) =>
|
|
630
|
+
runDailyConsolidation().then((stats) => {
|
|
631
|
+
if (stats.digests > 0 || stats.pruned > 0 || stats.deduped > 0) {
|
|
632
|
+
console.log(`[daemon] Memory consolidation: ${stats.digests} digest(s), ${stats.pruned} pruned, ${stats.deduped} deduped`)
|
|
633
|
+
}
|
|
634
|
+
if (stats.errors.length > 0) {
|
|
635
|
+
console.warn(`[daemon] Memory consolidation errors: ${stats.errors.join('; ')}`)
|
|
636
|
+
}
|
|
637
|
+
}),
|
|
638
|
+
).catch((err: unknown) => {
|
|
639
|
+
console.error('[daemon] Memory consolidation failed:', err instanceof Error ? err.message : String(err))
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function startMemoryConsolidation() {
|
|
644
|
+
if (ds.memoryConsolidationTimeoutId || ds.memoryConsolidationIntervalId) return
|
|
645
|
+
// Deferred first run, then repeat on interval
|
|
646
|
+
ds.memoryConsolidationTimeoutId = setTimeout(() => {
|
|
647
|
+
ds.memoryConsolidationTimeoutId = null
|
|
648
|
+
runConsolidationTick()
|
|
649
|
+
ds.memoryConsolidationIntervalId = setInterval(runConsolidationTick, MEMORY_CONSOLIDATION_INTERVAL)
|
|
650
|
+
}, MEMORY_CONSOLIDATION_INITIAL_DELAY)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function stopMemoryConsolidation() {
|
|
654
|
+
if (ds.memoryConsolidationTimeoutId) {
|
|
655
|
+
clearTimeout(ds.memoryConsolidationTimeoutId)
|
|
656
|
+
ds.memoryConsolidationTimeoutId = null
|
|
657
|
+
}
|
|
658
|
+
if (ds.memoryConsolidationIntervalId) {
|
|
659
|
+
clearInterval(ds.memoryConsolidationIntervalId)
|
|
660
|
+
ds.memoryConsolidationIntervalId = null
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
509
664
|
export async function runDaemonHealthCheckNow() {
|
|
510
665
|
await runHealthChecks()
|
|
511
666
|
}
|
|
@@ -5,6 +5,7 @@ import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
|
|
|
5
5
|
import { log } from './logger'
|
|
6
6
|
import { buildMainLoopHeartbeatPrompt, getMainLoopStateForSession, isMainSession } from './main-agent-loop'
|
|
7
7
|
import { WORKSPACE_DIR } from './data-dir'
|
|
8
|
+
import { drainSystemEvents } from './system-events'
|
|
8
9
|
|
|
9
10
|
const HEARTBEAT_TICK_MS = 5_000
|
|
10
11
|
|
|
@@ -131,9 +132,28 @@ function readHeartbeatFile(session: any): string {
|
|
|
131
132
|
return ''
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/** Detect HEARTBEAT.md files that contain only skeleton structure (headers, empty list items) but no real content. */
|
|
136
|
+
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
|
137
|
+
if (!content || typeof content !== 'string') return true
|
|
138
|
+
for (const line of content.split('\n')) {
|
|
139
|
+
const trimmed = line.trim()
|
|
140
|
+
if (!trimmed) continue
|
|
141
|
+
if (/^#+(\s|$)/.test(trimmed)) continue // ATX headers
|
|
142
|
+
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue // empty list items / checkboxes
|
|
143
|
+
return false // real content found
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
134
148
|
function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: string, heartbeatFileContent: string): string {
|
|
135
149
|
if (!agent) return fallbackPrompt
|
|
136
150
|
|
|
151
|
+
// Drain system events accumulated since last heartbeat
|
|
152
|
+
const events = drainSystemEvents(session.id)
|
|
153
|
+
const eventBlock = events.length > 0
|
|
154
|
+
? events.map((e) => `- [${new Date(e.timestamp).toISOString()}] ${e.text}`).join('\n')
|
|
155
|
+
: ''
|
|
156
|
+
|
|
137
157
|
// Dynamic goal (agent-set) takes priority over static system prompt
|
|
138
158
|
const dynamicGoal = agent.heartbeatGoal || ''
|
|
139
159
|
const dynamicNextAction = agent.heartbeatNextAction || ''
|
|
@@ -146,17 +166,21 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
|
|
|
146
166
|
.map((m: any) => `[${m.role}]: ${(m.text || '').slice(0, 200)}`)
|
|
147
167
|
.join('\n')
|
|
148
168
|
|
|
169
|
+
// Don't inject effectively-empty HEARTBEAT.md content
|
|
170
|
+
const effectiveFileContent = isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) ? '' : heartbeatFileContent
|
|
171
|
+
|
|
149
172
|
return [
|
|
150
173
|
'AGENT_HEARTBEAT_TICK',
|
|
151
174
|
`Time: ${new Date().toISOString()}`,
|
|
152
175
|
`Agent: ${agent.name}`,
|
|
153
176
|
description ? `Description: ${description}` : '',
|
|
177
|
+
eventBlock ? `Events since last heartbeat:\n${eventBlock}` : '',
|
|
154
178
|
dynamicGoal
|
|
155
179
|
? `Current goal (self-set): ${dynamicGoal}`
|
|
156
180
|
: goalSummary ? `System prompt (initial goal):\n${goalSummary}` : '',
|
|
157
181
|
dynamicNextAction ? `Planned next action: ${dynamicNextAction}` : '',
|
|
158
182
|
soul ? `Persona: ${soul.slice(0, 300)}` : '',
|
|
159
|
-
|
|
183
|
+
effectiveFileContent ? `\nHEARTBEAT.md contents:\n${effectiveFileContent.slice(0, 2000)}` : '',
|
|
160
184
|
recentContext ? `Recent conversation:\n${recentContext}` : '',
|
|
161
185
|
fallbackPrompt !== DEFAULT_HEARTBEAT_PROMPT ? `\nAgent instructions:\n${fallbackPrompt}` : '',
|
|
162
186
|
'',
|
|
@@ -330,9 +354,12 @@ async function tickHeartbeats() {
|
|
|
330
354
|
if (isMainSession(session)) {
|
|
331
355
|
const loopState = getMainLoopStateForSession(session.id)
|
|
332
356
|
if (loopState?.paused) continue
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
357
|
+
// Only suppress idle main sessions when heartbeat is inherited (not explicitly enabled)
|
|
358
|
+
if (!explicitOptIn) {
|
|
359
|
+
const loopStatus = loopState?.status || 'idle'
|
|
360
|
+
const pendingEvents = loopState?.pendingEvents?.length || 0
|
|
361
|
+
if ((loopStatus === 'ok' || loopStatus === 'idle') && pendingEvents === 0) continue
|
|
362
|
+
}
|
|
336
363
|
}
|
|
337
364
|
|
|
338
365
|
const last = state.lastBySession.get(session.id) || 0
|
|
@@ -345,7 +372,8 @@ async function tickHeartbeats() {
|
|
|
345
372
|
if (isMainSession(session)) {
|
|
346
373
|
heartbeatMessage = buildMainLoopHeartbeatPrompt(session, cfg.prompt)
|
|
347
374
|
} else {
|
|
348
|
-
const
|
|
375
|
+
const rawHeartbeatFileContent = readHeartbeatFile(session)
|
|
376
|
+
const heartbeatFileContent = isHeartbeatContentEffectivelyEmpty(rawHeartbeatFileContent) ? '' : rawHeartbeatFileContent
|
|
349
377
|
const hasGoal = !!(agent?.heartbeatGoal || agent?.description || agent?.systemPrompt || agent?.soul)
|
|
350
378
|
const hasCustomPrompt = cfg.prompt !== DEFAULT_HEARTBEAT_PROMPT
|
|
351
379
|
// Skip heartbeat only if there's truly nothing to drive it:
|
|
@@ -422,6 +450,13 @@ export function stopHeartbeatService() {
|
|
|
422
450
|
}
|
|
423
451
|
}
|
|
424
452
|
|
|
453
|
+
/** Clear tracked state and restart the heartbeat timer. Call when heartbeat config changes. */
|
|
454
|
+
export function restartHeartbeatService() {
|
|
455
|
+
stopHeartbeatService()
|
|
456
|
+
state.lastBySession.clear()
|
|
457
|
+
startHeartbeatService()
|
|
458
|
+
}
|
|
459
|
+
|
|
425
460
|
export function getHeartbeatServiceStatus() {
|
|
426
461
|
return {
|
|
427
462
|
running: state.running,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand heartbeat wake — triggers an immediate heartbeat for an agent/session.
|
|
3
|
+
* Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadSessions, loadAgents, loadSettings } from './storage'
|
|
7
|
+
import { enqueueSessionRun } from './session-run-manager'
|
|
8
|
+
import { log } from './logger'
|
|
9
|
+
|
|
10
|
+
interface WakeRequest {
|
|
11
|
+
agentId?: string
|
|
12
|
+
sessionId?: string
|
|
13
|
+
reason?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COALESCE_MS = 250
|
|
17
|
+
|
|
18
|
+
const globalKey = '__swarmclaw_heartbeat_wake__' as const
|
|
19
|
+
const globalScope = globalThis as typeof globalThis & {
|
|
20
|
+
[globalKey]?: { pending: Map<string, WakeRequest>; timer: ReturnType<typeof setTimeout> | null }
|
|
21
|
+
}
|
|
22
|
+
const state = globalScope[globalKey] ?? (globalScope[globalKey] = {
|
|
23
|
+
pending: new Map(),
|
|
24
|
+
timer: null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function flushWakes(): void {
|
|
28
|
+
state.timer = null
|
|
29
|
+
const wakes = new Map(state.pending)
|
|
30
|
+
state.pending.clear()
|
|
31
|
+
|
|
32
|
+
const sessions = loadSessions()
|
|
33
|
+
const agents = loadAgents()
|
|
34
|
+
const settings = loadSettings()
|
|
35
|
+
|
|
36
|
+
for (const [_key, wake] of wakes) {
|
|
37
|
+
try {
|
|
38
|
+
let sessionId = wake.sessionId
|
|
39
|
+
|
|
40
|
+
// If only agentId provided, find the agent's most recently active session
|
|
41
|
+
if (!sessionId && wake.agentId) {
|
|
42
|
+
let bestSession: { id: string; lastActiveAt: number } | null = null
|
|
43
|
+
for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
|
|
44
|
+
if (s.agentId !== wake.agentId) continue
|
|
45
|
+
const lastActive = typeof s.lastActiveAt === 'number' ? s.lastActiveAt : 0
|
|
46
|
+
if (!bestSession || lastActive > bestSession.lastActiveAt) {
|
|
47
|
+
bestSession = { id: s.id as string, lastActiveAt: lastActive }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
sessionId = bestSession?.id
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!sessionId) continue
|
|
54
|
+
|
|
55
|
+
const session = sessions[sessionId] as Record<string, unknown> | undefined
|
|
56
|
+
if (!session) continue
|
|
57
|
+
|
|
58
|
+
const agentId = (session.agentId || wake.agentId) as string | undefined
|
|
59
|
+
const agent = agentId ? agents[agentId] : null
|
|
60
|
+
|
|
61
|
+
// Build a minimal heartbeat prompt for the wake
|
|
62
|
+
const reason = wake.reason || 'on-demand'
|
|
63
|
+
const prompt = [
|
|
64
|
+
'AGENT_HEARTBEAT_WAKE',
|
|
65
|
+
`Time: ${new Date().toISOString()}`,
|
|
66
|
+
agent ? `Agent: ${(agent as Record<string, unknown>).name}` : '',
|
|
67
|
+
`Wake reason: ${reason}`,
|
|
68
|
+
'',
|
|
69
|
+
'An event has occurred that may require your attention.',
|
|
70
|
+
'Review and take appropriate action, or reply HEARTBEAT_OK if nothing is needed.',
|
|
71
|
+
].filter(Boolean).join('\n')
|
|
72
|
+
|
|
73
|
+
// Resolve heartbeat model from agent/settings
|
|
74
|
+
const heartbeatModel =
|
|
75
|
+
(agent as Record<string, unknown> | null)?.heartbeatModel as string | undefined
|
|
76
|
+
|| settings.heartbeatModel as string | undefined
|
|
77
|
+
|| undefined
|
|
78
|
+
|
|
79
|
+
enqueueSessionRun({
|
|
80
|
+
sessionId,
|
|
81
|
+
message: prompt,
|
|
82
|
+
internal: true,
|
|
83
|
+
source: 'heartbeat-wake',
|
|
84
|
+
mode: 'collect',
|
|
85
|
+
dedupeKey: `heartbeat-wake:${sessionId}`,
|
|
86
|
+
modelOverride: heartbeatModel,
|
|
87
|
+
heartbeatConfig: {
|
|
88
|
+
ackMaxChars: 300,
|
|
89
|
+
showOk: false,
|
|
90
|
+
showAlerts: true,
|
|
91
|
+
target: null,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
log.info('heartbeat-wake', `Wake fired for session ${sessionId} (reason: ${reason})`)
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
log.warn('heartbeat-wake', `Wake failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Queue a heartbeat wake. Multiple rapid calls are coalesced into a single flush. */
|
|
103
|
+
export function requestHeartbeatNow(opts: WakeRequest): void {
|
|
104
|
+
const key = opts.agentId || opts.sessionId || 'unknown'
|
|
105
|
+
state.pending.set(key, opts)
|
|
106
|
+
|
|
107
|
+
if (!state.timer) {
|
|
108
|
+
state.timer = setTimeout(flushWakes, COALESCE_MS)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { getMemoryDb } from './memory-db'
|
|
2
|
+
import { HumanMessage } from '@langchain/core/messages'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Produce daily digests per agent and prune stale entries.
|
|
6
|
+
* Only fires when an agent has >5 non-breadcrumb memories in the past 24h
|
|
7
|
+
* and no digest for today already exists.
|
|
8
|
+
*/
|
|
9
|
+
export async function runDailyConsolidation(): Promise<{
|
|
10
|
+
digests: number
|
|
11
|
+
pruned: number
|
|
12
|
+
deduped: number
|
|
13
|
+
errors: string[]
|
|
14
|
+
}> {
|
|
15
|
+
const memDb = getMemoryDb()
|
|
16
|
+
const counts = memDb.countsByAgent()
|
|
17
|
+
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
|
18
|
+
const digestTitle = `Daily digest: ${today}`
|
|
19
|
+
const cutoff24h = Date.now() - 24 * 3600_000
|
|
20
|
+
const errors: string[] = []
|
|
21
|
+
let digestsCreated = 0
|
|
22
|
+
|
|
23
|
+
for (const agentKey of Object.keys(counts)) {
|
|
24
|
+
if (agentKey === '_global') continue
|
|
25
|
+
const agentId = agentKey
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Check if digest already exists for today
|
|
29
|
+
const existing = memDb.search(digestTitle, agentId)
|
|
30
|
+
if (existing.some((m) => m.category === 'daily_digest' && m.title === digestTitle)) continue
|
|
31
|
+
|
|
32
|
+
// Fetch recent memories (exclude breadcrumbs and digests)
|
|
33
|
+
const recent = memDb.getByAgent(agentId, 100)
|
|
34
|
+
const candidates = recent.filter((m) => {
|
|
35
|
+
if (m.category === 'breadcrumb' || m.category === 'daily_digest') return false
|
|
36
|
+
return (m.createdAt || m.updatedAt || 0) >= cutoff24h
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (candidates.length < 5) continue
|
|
40
|
+
|
|
41
|
+
// Build summarization prompt
|
|
42
|
+
const memoryLines = candidates.slice(0, 30).map((m) => {
|
|
43
|
+
const content = (m.content || '').slice(0, 300)
|
|
44
|
+
return `- [${m.category}] ${m.title}: ${content}`
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const prompt = [
|
|
48
|
+
'Summarize the following memory entries from the last 24 hours into a concise daily digest.',
|
|
49
|
+
'Focus on key decisions, discoveries, and outcomes. Skip trivial or redundant entries.',
|
|
50
|
+
'Format as 3-7 bullet points. Be concise.',
|
|
51
|
+
'',
|
|
52
|
+
...memoryLines,
|
|
53
|
+
].join('\n')
|
|
54
|
+
|
|
55
|
+
// Use the configured LangGraph (utility) provider
|
|
56
|
+
const { buildLLM } = await import('./build-llm')
|
|
57
|
+
const { llm } = await buildLLM()
|
|
58
|
+
|
|
59
|
+
const response = await llm.invoke([new HumanMessage(prompt)])
|
|
60
|
+
const digestContent = typeof response.content === 'string'
|
|
61
|
+
? response.content
|
|
62
|
+
: Array.isArray(response.content)
|
|
63
|
+
? response.content.map((b) => ('text' in b && typeof b.text === 'string' ? b.text : '')).join('')
|
|
64
|
+
: ''
|
|
65
|
+
|
|
66
|
+
if (!digestContent.trim()) continue
|
|
67
|
+
|
|
68
|
+
const linkedMemoryIds = candidates.slice(0, 10).map((m) => m.id)
|
|
69
|
+
memDb.add({
|
|
70
|
+
agentId,
|
|
71
|
+
sessionId: null,
|
|
72
|
+
category: 'daily_digest',
|
|
73
|
+
title: digestTitle,
|
|
74
|
+
content: digestContent.trim(),
|
|
75
|
+
linkedMemoryIds,
|
|
76
|
+
})
|
|
77
|
+
digestsCreated++
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
errors.push(`Agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Run maintenance: dedupe + prune stale working entries
|
|
84
|
+
const maintenance = memDb.maintain({ dedupe: true, pruneWorking: true, ttlHours: 24 })
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
digests: digestsCreated,
|
|
88
|
+
pruned: maintenance.pruned,
|
|
89
|
+
deduped: maintenance.deduped,
|
|
90
|
+
errors,
|
|
91
|
+
}
|
|
92
|
+
}
|