@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
@@ -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
- validateCompletedTasksQueue()
130
- cleanupFinishedTaskSessions()
131
- startScheduler()
132
- startQueueProcessor()
133
- startBrowserSweep()
134
- startHealthMonitor()
135
- startHeartbeatService()
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
  }
@@ -55,6 +55,7 @@ function getDb(): Database.Database {
55
55
  if (_db) return _db
56
56
  _db = new Database(DB_PATH)
57
57
  _db.pragma('journal_mode = WAL')
58
+ _db.pragma('busy_timeout = 5000')
58
59
  _db.exec(`
59
60
  CREATE TABLE IF NOT EXISTS execution_logs (
60
61
  id TEXT PRIMARY KEY,
@@ -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
- heartbeatFileContent ? `\nHEARTBEAT.md contents:\n${heartbeatFileContent.slice(0, 2000)}` : '',
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
- const loopStatus = loopState?.status || 'idle'
334
- const pendingEvents = loopState?.pendingEvents?.length || 0
335
- if ((loopStatus === 'ok' || loopStatus === 'idle') && pendingEvents === 0) continue
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 heartbeatFileContent = readHeartbeatFile(session)
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
+ }
@@ -19,6 +19,7 @@ const DB_PATH = path.join(DATA_DIR, 'swarmclaw.db')
19
19
  function getDb(): Database.Database {
20
20
  const db = new Database(DB_PATH)
21
21
  db.pragma('journal_mode = WAL')
22
+ db.pragma('busy_timeout = 5000')
22
23
  return db
23
24
  }
24
25
 
@@ -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
+ }