@swarmclawai/swarmclaw 0.6.4 → 0.6.7

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 (143) hide show
  1. package/README.md +62 -30
  2. package/package.json +10 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +58 -3
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +34 -2
  8. package/src/app/api/chatrooms/route.ts +26 -3
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/schedules/[id]/run/route.ts +3 -0
  14. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  15. package/src/app/api/sessions/route.ts +11 -2
  16. package/src/app/api/tasks/[id]/route.ts +18 -13
  17. package/src/app/api/tasks/route.ts +44 -1
  18. package/src/app/api/usage/route.ts +16 -7
  19. package/src/app/api/wallets/[id]/approve/route.ts +62 -0
  20. package/src/app/api/wallets/[id]/balance-history/route.ts +18 -0
  21. package/src/app/api/wallets/[id]/route.ts +118 -0
  22. package/src/app/api/wallets/[id]/send/route.ts +118 -0
  23. package/src/app/api/wallets/[id]/transactions/route.ts +18 -0
  24. package/src/app/api/wallets/route.ts +74 -0
  25. package/src/app/globals.css +8 -0
  26. package/src/cli/index.js +20 -0
  27. package/src/cli/index.ts +223 -39
  28. package/src/cli/spec.js +14 -0
  29. package/src/components/agents/agent-avatar.tsx +15 -1
  30. package/src/components/agents/agent-card.tsx +38 -6
  31. package/src/components/agents/agent-chat-list.tsx +79 -3
  32. package/src/components/agents/agent-sheet.tsx +191 -26
  33. package/src/components/auth/setup-wizard.tsx +268 -353
  34. package/src/components/chat/chat-area.tsx +24 -9
  35. package/src/components/chat/chat-header.tsx +48 -19
  36. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  37. package/src/components/chat/delegation-banner.test.ts +27 -0
  38. package/src/components/chat/delegation-banner.tsx +109 -23
  39. package/src/components/chat/message-bubble.tsx +17 -16
  40. package/src/components/chat/message-list.tsx +6 -5
  41. package/src/components/chat/streaming-bubble.tsx +3 -2
  42. package/src/components/chat/thinking-indicator.tsx +3 -2
  43. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  44. package/src/components/chatrooms/agent-hover-card.tsx +1 -1
  45. package/src/components/chatrooms/chatroom-input.tsx +1 -1
  46. package/src/components/chatrooms/chatroom-message.tsx +165 -23
  47. package/src/components/chatrooms/chatroom-sheet.tsx +289 -4
  48. package/src/components/chatrooms/chatroom-typing-bar.tsx +1 -1
  49. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  50. package/src/components/connectors/connector-health.tsx +120 -0
  51. package/src/components/connectors/connector-list.tsx +1 -1
  52. package/src/components/connectors/connector-sheet.tsx +9 -0
  53. package/src/components/home/home-view.tsx +25 -3
  54. package/src/components/input/chat-input.tsx +8 -1
  55. package/src/components/knowledge/knowledge-list.tsx +1 -1
  56. package/src/components/knowledge/knowledge-sheet.tsx +1 -1
  57. package/src/components/layout/app-layout.tsx +35 -4
  58. package/src/components/memory/memory-agent-list.tsx +1 -1
  59. package/src/components/memory/memory-browser.tsx +1 -0
  60. package/src/components/memory/memory-card.tsx +3 -2
  61. package/src/components/memory/memory-detail.tsx +3 -3
  62. package/src/components/memory/memory-sheet.tsx +2 -2
  63. package/src/components/projects/project-detail.tsx +4 -4
  64. package/src/components/schedules/schedule-list.tsx +55 -9
  65. package/src/components/schedules/schedule-sheet.tsx +134 -23
  66. package/src/components/secrets/secret-sheet.tsx +1 -1
  67. package/src/components/secrets/secrets-list.tsx +1 -1
  68. package/src/components/sessions/session-card.tsx +1 -1
  69. package/src/components/shared/agent-picker-list.tsx +1 -1
  70. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  71. package/src/components/shared/command-palette.tsx +237 -0
  72. package/src/components/shared/connector-platform-icon.tsx +1 -0
  73. package/src/components/shared/settings/section-user-preferences.tsx +4 -4
  74. package/src/components/skills/skill-list.tsx +1 -1
  75. package/src/components/skills/skill-sheet.tsx +1 -1
  76. package/src/components/tasks/task-board.tsx +3 -3
  77. package/src/components/tasks/task-card.tsx +22 -2
  78. package/src/components/tasks/task-sheet.tsx +112 -17
  79. package/src/components/usage/metrics-dashboard.tsx +13 -25
  80. package/src/components/wallets/wallet-approval-dialog.tsx +99 -0
  81. package/src/components/wallets/wallet-panel.tsx +616 -0
  82. package/src/components/wallets/wallet-section.tsx +100 -0
  83. package/src/hooks/use-swipe.ts +49 -0
  84. package/src/lib/providers/anthropic.ts +16 -2
  85. package/src/lib/providers/claude-cli.ts +7 -1
  86. package/src/lib/providers/index.ts +7 -0
  87. package/src/lib/providers/ollama.ts +16 -2
  88. package/src/lib/providers/openai.ts +7 -2
  89. package/src/lib/providers/openclaw.ts +6 -1
  90. package/src/lib/providers/provider-defaults.ts +7 -0
  91. package/src/lib/schedule-templates.ts +115 -0
  92. package/src/lib/server/agent-registry.ts +2 -2
  93. package/src/lib/server/alert-dispatch.ts +64 -0
  94. package/src/lib/server/chat-execution.ts +76 -4
  95. package/src/lib/server/chatroom-health.ts +60 -0
  96. package/src/lib/server/chatroom-helpers.test.ts +94 -0
  97. package/src/lib/server/chatroom-helpers.ts +86 -12
  98. package/src/lib/server/chatroom-routing.ts +65 -0
  99. package/src/lib/server/connectors/discord.ts +3 -0
  100. package/src/lib/server/connectors/email.ts +267 -0
  101. package/src/lib/server/connectors/inbound-audio-transcription.test.ts +191 -0
  102. package/src/lib/server/connectors/inbound-audio-transcription.ts +261 -0
  103. package/src/lib/server/connectors/manager.ts +239 -5
  104. package/src/lib/server/connectors/openclaw.ts +3 -0
  105. package/src/lib/server/connectors/slack.ts +6 -0
  106. package/src/lib/server/connectors/telegram.ts +18 -0
  107. package/src/lib/server/connectors/types.ts +2 -0
  108. package/src/lib/server/connectors/whatsapp-text.test.ts +29 -0
  109. package/src/lib/server/connectors/whatsapp-text.ts +26 -0
  110. package/src/lib/server/connectors/whatsapp.ts +17 -5
  111. package/src/lib/server/cost.ts +70 -0
  112. package/src/lib/server/create-notification.ts +2 -0
  113. package/src/lib/server/daemon-state.ts +124 -0
  114. package/src/lib/server/dag-validation.ts +115 -0
  115. package/src/lib/server/memory-db.ts +12 -7
  116. package/src/lib/server/openclaw-doctor.ts +48 -0
  117. package/src/lib/server/orchestrator-lg.ts +12 -2
  118. package/src/lib/server/orchestrator.ts +6 -1
  119. package/src/lib/server/queue-followups.test.ts +224 -0
  120. package/src/lib/server/queue.ts +238 -24
  121. package/src/lib/server/scheduler.ts +3 -0
  122. package/src/lib/server/session-run-manager.ts +22 -1
  123. package/src/lib/server/session-tools/chatroom.ts +11 -2
  124. package/src/lib/server/session-tools/context-mgmt.ts +2 -2
  125. package/src/lib/server/session-tools/index.ts +8 -2
  126. package/src/lib/server/session-tools/memory.ts +23 -4
  127. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  128. package/src/lib/server/session-tools/shell.ts +1 -1
  129. package/src/lib/server/session-tools/wallet.ts +124 -0
  130. package/src/lib/server/session-tools/web.ts +2 -2
  131. package/src/lib/server/solana.ts +122 -0
  132. package/src/lib/server/storage.ts +158 -6
  133. package/src/lib/server/stream-agent-chat.ts +126 -63
  134. package/src/lib/server/task-mention.test.ts +41 -0
  135. package/src/lib/server/task-mention.ts +3 -2
  136. package/src/lib/setup-defaults.ts +277 -0
  137. package/src/lib/tool-definitions.ts +1 -0
  138. package/src/lib/validation/schemas.ts +69 -0
  139. package/src/lib/view-routes.ts +1 -0
  140. package/src/stores/use-app-store.ts +15 -3
  141. package/src/stores/use-chatroom-store.ts +52 -2
  142. package/src/types/index.ts +98 -2
  143. package/tsconfig.json +2 -1
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import { api } from '@/lib/api-client'
5
+ import type { AgentWallet, WalletChain } from '@/types'
6
+
7
+ interface WalletSectionProps {
8
+ agentId: string
9
+ wallet: (Omit<AgentWallet, 'encryptedPrivateKey'> & { balanceLamports?: number; balanceSol?: number }) | null
10
+ onWalletCreated: () => void
11
+ }
12
+
13
+ export function WalletSection({ agentId, wallet, onWalletCreated }: WalletSectionProps) {
14
+ const [creating, setCreating] = useState(false)
15
+ const [error, setError] = useState<string | null>(null)
16
+ const [copied, setCopied] = useState(false)
17
+
18
+ const createWallet = useCallback(async () => {
19
+ setCreating(true)
20
+ setError(null)
21
+ try {
22
+ await api('POST', '/wallets', { agentId, chain: 'solana' as WalletChain })
23
+ onWalletCreated()
24
+ } catch (err: unknown) {
25
+ setError(err instanceof Error ? err.message : String(err))
26
+ } finally {
27
+ setCreating(false)
28
+ }
29
+ }, [agentId, onWalletCreated])
30
+
31
+ const copyAddress = useCallback(() => {
32
+ if (!wallet) return
33
+ navigator.clipboard.writeText(wallet.publicKey)
34
+ setCopied(true)
35
+ setTimeout(() => setCopied(false), 2000)
36
+ }, [wallet])
37
+
38
+ return (
39
+ <div className="mb-8">
40
+ <div className="flex items-center gap-2 mb-3">
41
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">
42
+ Wallet
43
+ </label>
44
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-400 text-[9px] font-600 uppercase tracking-wide">
45
+ Experimental
46
+ </span>
47
+ </div>
48
+
49
+ {!wallet ? (
50
+ <div className="p-4 rounded-[12px] border border-white/[0.06] bg-surface-2/50">
51
+ <p className="text-[12px] text-text-3/70 mb-3">
52
+ Create a Solana wallet for this agent to hold funds, pay for services, and trade autonomously.
53
+ </p>
54
+ <button
55
+ type="button"
56
+ onClick={createWallet}
57
+ disabled={creating}
58
+ className="px-3 py-1.5 rounded-[8px] bg-accent-soft text-accent-bright text-[11px] font-600 hover:bg-accent-bright/15 transition-all cursor-pointer disabled:opacity-50 border border-accent-bright/20"
59
+ style={{ fontFamily: 'inherit' }}
60
+ >
61
+ {creating ? 'Creating...' : 'Create Wallet'}
62
+ </button>
63
+ {error && <p className="text-[11px] text-red-400 mt-2">{error}</p>}
64
+ </div>
65
+ ) : (
66
+ <div className="p-4 rounded-[12px] border border-white/[0.06] bg-surface-2/50 space-y-3">
67
+ <div className="flex items-center gap-2">
68
+ <span className="text-[10px] text-text-3/60 uppercase tracking-wide font-600">
69
+ {wallet.chain}
70
+ </span>
71
+ <span className="flex-1" />
72
+ {typeof wallet.balanceSol === 'number' && (
73
+ <span className="text-[13px] font-600 text-text-1">
74
+ {wallet.balanceSol.toFixed(4)} SOL
75
+ </span>
76
+ )}
77
+ </div>
78
+ <div className="flex items-center gap-2">
79
+ <code className="text-[11px] text-text-3 bg-black/20 px-2 py-1 rounded-[6px] font-mono truncate flex-1">
80
+ {wallet.publicKey}
81
+ </code>
82
+ <button
83
+ type="button"
84
+ onClick={copyAddress}
85
+ className="shrink-0 px-2 py-1 rounded-[6px] text-[10px] text-text-3 hover:text-text-2 border border-white/[0.08] bg-surface transition-colors cursor-pointer"
86
+ style={{ fontFamily: 'inherit' }}
87
+ >
88
+ {copied ? 'Copied!' : 'Copy'}
89
+ </button>
90
+ </div>
91
+ <div className="flex items-center gap-3 text-[10px] text-text-3/60">
92
+ <span>Limit: {((wallet.spendingLimitLamports ?? 100_000_000) / 1e9).toFixed(2)} SOL/tx</span>
93
+ <span>Daily: {((wallet.dailyLimitLamports ?? 1_000_000_000) / 1e9).toFixed(1)} SOL</span>
94
+ <span>{wallet.requireApproval ? 'Approval required' : 'Auto-send'}</span>
95
+ </div>
96
+ </div>
97
+ )}
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,49 @@
1
+ import { useRef, useCallback } from 'react'
2
+
3
+ interface UseSwipeOptions {
4
+ onSwipe: (direction: 'left' | 'right') => void
5
+ /** Only trigger right-swipe from this many pixels from the left edge */
6
+ edgeWidth?: number
7
+ /** Minimum horizontal distance to count as a swipe */
8
+ threshold?: number
9
+ /** Whether left-swipe is currently allowed (e.g. sidebar is open) */
10
+ leftSwipeEnabled?: boolean
11
+ }
12
+
13
+ export function useSwipe({
14
+ onSwipe,
15
+ edgeWidth = 40,
16
+ threshold = 50,
17
+ leftSwipeEnabled = false,
18
+ }: UseSwipeOptions) {
19
+ const startX = useRef(0)
20
+ const startY = useRef(0)
21
+ const isEdge = useRef(false)
22
+
23
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
24
+ const touch = e.touches[0]
25
+ startX.current = touch.clientX
26
+ startY.current = touch.clientY
27
+ isEdge.current = touch.clientX <= edgeWidth
28
+ }, [edgeWidth])
29
+
30
+ // No-op — we only evaluate on touchend, but callers may wire this for consistency
31
+ const onTouchMove = useCallback(() => {}, [])
32
+
33
+ const onTouchEnd = useCallback((e: React.TouchEvent) => {
34
+ const touch = e.changedTouches[0]
35
+ const dx = touch.clientX - startX.current
36
+ const dy = touch.clientY - startY.current
37
+ // Ignore if vertical movement dominates
38
+ if (Math.abs(dy) > Math.abs(dx)) return
39
+ if (Math.abs(dx) < threshold) return
40
+
41
+ if (dx > 0 && isEdge.current) {
42
+ onSwipe('right')
43
+ } else if (dx < 0 && leftSwipeEnabled) {
44
+ onSwipe('left')
45
+ }
46
+ }, [threshold, leftSwipeEnabled, onSwipe])
47
+
48
+ return { onTouchStart, onTouchMove, onTouchEnd }
49
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import https from 'https'
3
3
  import type { StreamChatOptions } from './index'
4
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
4
5
 
5
6
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
6
7
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
@@ -23,7 +24,7 @@ function fileToContentBlocks(filePath: string): any[] {
23
24
  return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
24
25
  }
25
26
 
26
- export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
+ export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
27
28
  return new Promise((resolve) => {
28
29
  const messages = buildMessages(session, message, imagePath, loadHistory, imageUrl)
29
30
  const model = session.model || 'claude-sonnet-4-6'
@@ -43,9 +44,21 @@ export function streamAnthropicChat({ session, message, imagePath, imageUrl, api
43
44
  const payload = JSON.stringify(body)
44
45
  const abortController = { aborted: false }
45
46
  let fullResponse = ''
47
+ let apiReqRef: ReturnType<typeof https.request> | null = null
48
+
49
+ if (signal) {
50
+ if (signal.aborted) {
51
+ abortController.aborted = true
52
+ } else {
53
+ signal.addEventListener('abort', () => {
54
+ abortController.aborted = true
55
+ apiReqRef?.destroy()
56
+ }, { once: true })
57
+ }
58
+ }
46
59
 
47
60
  const apiReq = https.request({
48
- hostname: 'api.anthropic.com',
61
+ hostname: PROVIDER_DEFAULTS.anthropic,
49
62
  path: '/v1/messages',
50
63
  method: 'POST',
51
64
  headers: {
@@ -109,6 +122,7 @@ export function streamAnthropicChat({ session, message, imagePath, imageUrl, api
109
122
  })
110
123
  })
111
124
 
125
+ apiReqRef = apiReq
112
126
  active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
113
127
 
114
128
  apiReq.on('error', (e) => {
@@ -24,7 +24,7 @@ function findClaude(): string {
24
24
 
25
25
  const CLAUDE = findClaude()
26
26
 
27
- export function streamClaudeCliChat({ session, message, imagePath, systemPrompt, write, active }: StreamChatOptions): Promise<string> {
27
+ export function streamClaudeCliChat({ session, message, imagePath, systemPrompt, write, active, signal }: StreamChatOptions): Promise<string> {
28
28
  const processTimeoutMs = loadRuntimeSettings().cliProcessTimeoutMs
29
29
  let prompt = message
30
30
  if (imagePath) {
@@ -108,6 +108,12 @@ export function streamClaudeCliChat({ session, message, imagePath, systemPrompt,
108
108
  proc.stdin!.end()
109
109
 
110
110
  active.set(session.id, proc)
111
+
112
+ if (signal) {
113
+ if (signal.aborted) { proc.kill(); }
114
+ else signal.addEventListener('abort', () => { proc.kill() }, { once: true })
115
+ }
116
+
111
117
  let fullResponse = ''
112
118
  let buf = ''
113
119
  let eventCount = 0
@@ -29,6 +29,8 @@ export interface StreamChatOptions {
29
29
  active: Map<string, any>
30
30
  loadHistory: (sessionId: string) => any[]
31
31
  onUsage?: (usage: StreamChatUsage) => void
32
+ /** Abort signal from the caller — providers should use this to cancel in-flight requests. */
33
+ signal?: AbortSignal
32
34
  }
33
35
 
34
36
  interface BuiltinProviderConfig extends ProviderInfo {
@@ -351,6 +353,11 @@ export async function streamChatWithFailover(
351
353
  t: 'md',
352
354
  text: JSON.stringify({ failover: { from: credId, reason: err.message?.slice(0, 100) } }),
353
355
  })}\n\n`)
356
+ // Exponential backoff for rate-limit / server errors (skip for auth rotation)
357
+ if (statusCode !== 401) {
358
+ const delay = Math.min(500 * Math.pow(2, i), 8000)
359
+ await new Promise((r) => setTimeout(r, delay))
360
+ }
354
361
  continue
355
362
  }
356
363
  throw err
@@ -2,16 +2,17 @@ import fs from 'fs'
2
2
  import http from 'http'
3
3
  import https from 'https'
4
4
  import type { StreamChatOptions } from './index'
5
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
5
6
 
6
7
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
7
8
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
8
9
 
9
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
10
+ export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
10
11
  return new Promise((resolve) => {
11
12
  const messages = buildMessages(session, message, imagePath, loadHistory)
12
13
  const model = session.model || 'llama3'
13
14
  // Cloud: no endpoint but API key present → use Ollama cloud
14
- const endpoint = session.apiEndpoint || (apiKey ? 'https://ollama.com' : 'http://localhost:11434')
15
+ const endpoint = session.apiEndpoint || (apiKey ? PROVIDER_DEFAULTS.ollamaCloud : PROVIDER_DEFAULTS.ollama)
15
16
 
16
17
  const parsed = new URL(endpoint)
17
18
  const isHttps = parsed.protocol === 'https:'
@@ -26,6 +27,18 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
26
27
 
27
28
  const abortController = { aborted: false }
28
29
  let fullResponse = ''
30
+ let apiReqRef: ReturnType<typeof http.request> | null = null
31
+
32
+ if (signal) {
33
+ if (signal.aborted) {
34
+ abortController.aborted = true
35
+ } else {
36
+ signal.addEventListener('abort', () => {
37
+ abortController.aborted = true
38
+ apiReqRef?.destroy()
39
+ }, { once: true })
40
+ }
41
+ }
29
42
 
30
43
  const headers: Record<string, string> = {
31
44
  'Content-Type': 'application/json',
@@ -87,6 +100,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
87
100
  })
88
101
  })
89
102
 
103
+ apiReqRef = apiReq
90
104
  active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
91
105
 
92
106
  apiReq.on('error', (e: NodeJS.ErrnoException) => {
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import type { StreamChatOptions } from './index'
3
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
3
4
 
4
5
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
5
6
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
@@ -43,7 +44,7 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
43
44
  return [{ type: 'text', text: `[Attached file: ${name}]` }]
44
45
  }
45
46
 
46
- export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
47
+ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
47
48
  return new Promise(async (resolve) => {
48
49
  const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory, imageUrl)
49
50
  const model = session.model || 'gpt-4o'
@@ -58,7 +59,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
58
59
  let fullResponse = ''
59
60
 
60
61
  // Support custom base URLs for custom providers
61
- const baseUrl = session.apiEndpoint || 'https://api.openai.com/v1'
62
+ const baseUrl = session.apiEndpoint || PROVIDER_DEFAULTS.openai
62
63
  const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`
63
64
 
64
65
  // OpenClaw endpoints behind Hostinger's proxy use express.json() middleware
@@ -67,6 +68,10 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
67
68
  const contentType = session.contentType || 'application/json'
68
69
 
69
70
  const abortController = new AbortController()
71
+ if (signal) {
72
+ if (signal.aborted) abortController.abort()
73
+ else signal.addEventListener('abort', () => abortController.abort(), { once: true })
74
+ }
70
75
  active.set(session.id, { kill: () => abortController.abort() })
71
76
 
72
77
  try {
@@ -279,7 +279,7 @@ async function connectToGateway(
279
279
 
280
280
  // --- Provider ---
281
281
 
282
- export function streamOpenClawChat({ session, message, imagePath, apiKey, write, active }: StreamChatOptions): Promise<string> {
282
+ export function streamOpenClawChat({ session, message, imagePath, apiKey, write, active, signal }: StreamChatOptions): Promise<string> {
283
283
  let prompt = message
284
284
  if (imagePath) {
285
285
  prompt = `[The user has shared an image at: ${imagePath}]\n\n${message}`
@@ -316,6 +316,11 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
316
316
 
317
317
  active.set(session.id, { kill: () => { ws.close(); clearTimeout(timeout); finish('Aborted.') } })
318
318
 
319
+ if (signal) {
320
+ if (signal.aborted) { ws.close(); clearTimeout(timeout); finish('Aborted.'); return }
321
+ signal.addEventListener('abort', () => { ws.close(); clearTimeout(timeout); finish('Aborted.') }, { once: true })
322
+ }
323
+
319
324
  const agentReqId = randomUUID()
320
325
  ws.send(JSON.stringify({
321
326
  type: 'req',
@@ -0,0 +1,7 @@
1
+ /** Default base URLs for built-in LLM providers */
2
+ export const PROVIDER_DEFAULTS = {
3
+ openai: 'https://api.openai.com/v1',
4
+ anthropic: 'api.anthropic.com',
5
+ ollama: 'http://localhost:11434',
6
+ ollamaCloud: 'https://ollama.com',
7
+ } as const
@@ -0,0 +1,115 @@
1
+ export interface ScheduleTemplate {
2
+ id: string
3
+ name: string
4
+ description: string
5
+ icon: string
6
+ category: 'monitoring' | 'reporting' | 'maintenance' | 'content'
7
+ defaults: {
8
+ taskPrompt: string
9
+ scheduleType: 'cron' | 'interval'
10
+ cron?: string
11
+ intervalMs?: number
12
+ }
13
+ }
14
+
15
+ export const SCHEDULE_TEMPLATES: ScheduleTemplate[] = [
16
+ {
17
+ id: 'daily-digest',
18
+ name: 'Daily Digest',
19
+ description: 'Summarize activity from the past 24 hours each morning',
20
+ icon: 'Newspaper',
21
+ category: 'reporting',
22
+ defaults: {
23
+ taskPrompt: 'Summarize all notable activity, events, and updates from the past 24 hours. Highlight key metrics, completed tasks, and anything that needs attention.',
24
+ scheduleType: 'cron',
25
+ cron: '0 9 * * *',
26
+ },
27
+ },
28
+ {
29
+ id: 'weekly-report',
30
+ name: 'Weekly Report',
31
+ description: 'Generate a weekly metrics and progress report every Monday',
32
+ icon: 'BarChart3',
33
+ category: 'reporting',
34
+ defaults: {
35
+ taskPrompt: 'Generate a comprehensive weekly report covering key metrics, completed tasks, ongoing work, blockers, and recommendations for the coming week.',
36
+ scheduleType: 'cron',
37
+ cron: '0 10 * * 1',
38
+ },
39
+ },
40
+ {
41
+ id: 'health-monitor',
42
+ name: 'Health Monitor',
43
+ description: 'Check system health and service status every 5 minutes',
44
+ icon: 'HeartPulse',
45
+ category: 'monitoring',
46
+ defaults: {
47
+ taskPrompt: 'Perform a system health check. Verify all services are running, check resource usage (CPU, memory, disk), and report any anomalies or degraded performance.',
48
+ scheduleType: 'interval',
49
+ intervalMs: 300000,
50
+ },
51
+ },
52
+ {
53
+ id: 'content-generation',
54
+ name: 'Content Generation',
55
+ description: 'Generate daily content such as posts, summaries, or briefs',
56
+ icon: 'PenLine',
57
+ category: 'content',
58
+ defaults: {
59
+ taskPrompt: 'Generate fresh content based on current trends and recent activity. Create a well-structured draft ready for review and publishing.',
60
+ scheduleType: 'cron',
61
+ cron: '0 8 * * *',
62
+ },
63
+ },
64
+ {
65
+ id: 'data-cleanup',
66
+ name: 'Data Cleanup',
67
+ description: 'Run weekly cleanup of stale data and temporary files',
68
+ icon: 'Trash2',
69
+ category: 'maintenance',
70
+ defaults: {
71
+ taskPrompt: 'Identify and clean up stale data, expired records, orphaned files, and temporary resources. Log what was removed and any issues encountered.',
72
+ scheduleType: 'cron',
73
+ cron: '0 2 * * 0',
74
+ },
75
+ },
76
+ {
77
+ id: 'metric-snapshot',
78
+ name: 'Metric Snapshot',
79
+ description: 'Capture an hourly snapshot of key metrics and KPIs',
80
+ icon: 'Activity',
81
+ category: 'monitoring',
82
+ defaults: {
83
+ taskPrompt: 'Capture a snapshot of all key metrics and KPIs. Record current values, compare against previous snapshots, and flag any significant changes or threshold breaches.',
84
+ scheduleType: 'interval',
85
+ intervalMs: 3600000,
86
+ },
87
+ },
88
+ {
89
+ id: 'security-audit',
90
+ name: 'Security Audit',
91
+ description: 'Run a daily security scan and vulnerability check',
92
+ icon: 'ShieldCheck',
93
+ category: 'monitoring',
94
+ defaults: {
95
+ taskPrompt: 'Perform a security audit. Check for unusual access patterns, review authentication logs, scan for known vulnerabilities, and report any security concerns.',
96
+ scheduleType: 'cron',
97
+ cron: '0 0 * * *',
98
+ },
99
+ },
100
+ {
101
+ id: 'backup-check',
102
+ name: 'Backup Check',
103
+ description: 'Verify backup integrity and completeness daily',
104
+ icon: 'DatabaseBackup',
105
+ category: 'maintenance',
106
+ defaults: {
107
+ taskPrompt: 'Verify that all scheduled backups completed successfully. Check backup integrity, storage usage, and retention policy compliance. Alert on any failures.',
108
+ scheduleType: 'cron',
109
+ cron: '0 3 * * *',
110
+ },
111
+ },
112
+ ]
113
+
114
+ /** Subset of templates to feature in the empty state */
115
+ export const FEATURED_TEMPLATE_IDS = ['daily-digest', 'health-monitor', 'content-generation'] as const
@@ -63,8 +63,8 @@ export function buildAgentAwarenessBlock(excludeId: string): string {
63
63
  })
64
64
 
65
65
  return [
66
- '## Available Agents',
66
+ '## My Colleagues',
67
+ 'These are the other agents I work alongside. I can hand off tasks to any of them if their skills are a better fit:',
67
68
  ...lines,
68
- 'You can delegate tasks to any agent using the delegate_to_agent tool.',
69
69
  ].join('\n')
70
70
  }
@@ -0,0 +1,64 @@
1
+ import { loadSettings } from './storage'
2
+ import type { AppNotification } from '@/types'
3
+
4
+ /** In-memory rate limiter: dedupKey → last dispatch timestamp */
5
+ const recentDispatches = new Map<string, number>()
6
+ const DEDUP_WINDOW_MS = 60_000
7
+
8
+ export async function dispatchAlert(notification: AppNotification): Promise<void> {
9
+ const settings = loadSettings()
10
+ const url = typeof settings.alertWebhookUrl === 'string' ? settings.alertWebhookUrl.trim() : ''
11
+ if (!url) return
12
+
13
+ const allowedEvents: string[] = Array.isArray(settings.alertWebhookEvents)
14
+ ? settings.alertWebhookEvents
15
+ : ['error']
16
+ if (!allowedEvents.includes(notification.type)) return
17
+
18
+ // Rate limit by dedupKey (or notification id as fallback)
19
+ const dedupKey = notification.dedupKey || notification.id
20
+ const now = Date.now()
21
+ const lastSent = recentDispatches.get(dedupKey)
22
+ if (lastSent && now - lastSent < DEDUP_WINDOW_MS) return
23
+ recentDispatches.set(dedupKey, now)
24
+
25
+ // Prune stale entries periodically
26
+ if (recentDispatches.size > 200) {
27
+ for (const [key, ts] of recentDispatches) {
28
+ if (now - ts > DEDUP_WINDOW_MS) recentDispatches.delete(key)
29
+ }
30
+ }
31
+
32
+ const webhookType = settings.alertWebhookType || 'custom'
33
+ let body: string
34
+
35
+ if (webhookType === 'discord') {
36
+ body = JSON.stringify({
37
+ content: `⚠️ **${notification.title}**${notification.message ? `\n${notification.message}` : ''}`,
38
+ })
39
+ } else if (webhookType === 'slack') {
40
+ body = JSON.stringify({
41
+ text: `⚠️ *${notification.title}*${notification.message ? `\n${notification.message}` : ''}`,
42
+ })
43
+ } else {
44
+ body = JSON.stringify({
45
+ type: notification.type,
46
+ title: notification.title,
47
+ message: notification.message || null,
48
+ entityType: notification.entityType || null,
49
+ entityId: notification.entityId || null,
50
+ timestamp: notification.createdAt,
51
+ })
52
+ }
53
+
54
+ try {
55
+ await fetch(url, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body,
59
+ signal: AbortSignal.timeout(5000),
60
+ })
61
+ } catch (err: unknown) {
62
+ console.warn('[alert-dispatch] Webhook delivery failed:', err instanceof Error ? err.message : String(err))
63
+ }
64
+ }
@@ -13,7 +13,7 @@ import {
13
13
  active,
14
14
  } from './storage'
15
15
  import { getProvider } from '@/lib/providers'
16
- import { estimateCost } from './cost'
16
+ import { estimateCost, checkBudget } from './cost'
17
17
  import { log } from './logger'
18
18
  import { logExecution } from './execution-log'
19
19
  import { streamAgentChat } from './stream-agent-chat'
@@ -109,6 +109,21 @@ function collectToolEvent(ev: SSEEvent, bag: MessageToolEvent[]) {
109
109
  }
110
110
  }
111
111
 
112
+ function shouldReplaceRecentAssistantMessage(params: {
113
+ previous: Message | null | undefined
114
+ nextToolEvents: MessageToolEvent[]
115
+ nextKind: Message['kind']
116
+ now: number
117
+ }): boolean {
118
+ const { previous, nextToolEvents, nextKind, now } = params
119
+ if (!previous || previous.role !== 'assistant') return false
120
+ if (nextToolEvents.length === 0) return false
121
+ if (previous.kind && nextKind && previous.kind !== nextKind) return false
122
+ if (typeof previous.time === 'number' && now - previous.time > 45_000) return false
123
+ const prevTools = Array.isArray(previous.toolEvents) ? previous.toolEvents.length : 0
124
+ return prevTools === 0
125
+ }
126
+
112
127
  function requestedToolNamesFromMessage(message: string): string[] {
113
128
  const lower = message.toLowerCase()
114
129
  const candidates = [
@@ -377,6 +392,11 @@ function buildAgentSystemPrompt(session: any): string | undefined {
377
392
 
378
393
  const settings = loadSettings()
379
394
  const parts: string[] = []
395
+ // Identity block — agent needs to know who it is
396
+ const identityLines = [`## My Identity`, `My name is ${agent.name}.`]
397
+ if (agent.description) identityLines.push(agent.description)
398
+ identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
399
+ parts.push(identityLines.join(' '))
380
400
  if (settings.userPrompt) parts.push(settings.userPrompt)
381
401
  parts.push(buildCurrentDateTimePromptContext())
382
402
  if (agent.soul) parts.push(agent.soul)
@@ -562,6 +582,45 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
562
582
  onEvent?.({ t: 'err', text: `Capability policy blocked tools for this run: ${blockedSummary}` })
563
583
  }
564
584
 
585
+ // --- Agent monthly budget enforcement ---
586
+ if (session.agentId) {
587
+ const agentsMap = loadAgents()
588
+ const agent = agentsMap[session.agentId]
589
+ if (agent?.monthlyBudget && agent.monthlyBudget > 0) {
590
+ const budgetResult = checkBudget(agent)
591
+ if (!budgetResult.ok) {
592
+ const action = agent.budgetAction || 'warn'
593
+ if (action === 'block') {
594
+ const budgetError = budgetResult.message || `Agent budget exceeded: $${budgetResult.spend.toFixed(4)} / $${budgetResult.budget.toFixed(2)}`
595
+ onEvent?.({ t: 'err', text: budgetError })
596
+
597
+ let persisted = false
598
+ if (!internal) {
599
+ session.messages.push({
600
+ role: 'assistant',
601
+ text: budgetError,
602
+ time: Date.now(),
603
+ })
604
+ session.lastActiveAt = Date.now()
605
+ saveSessions(sessions)
606
+ persisted = true
607
+ }
608
+
609
+ return {
610
+ runId,
611
+ sessionId,
612
+ text: budgetError,
613
+ persisted,
614
+ toolEvents: [],
615
+ error: budgetError,
616
+ }
617
+ }
618
+ // budgetAction === 'warn': emit a warning but continue
619
+ onEvent?.({ t: 'status', text: JSON.stringify({ budgetWarning: budgetResult.message }) })
620
+ }
621
+ }
622
+ }
623
+
565
624
  const dailySpendLimitUsd = parseUsdLimit(appSettings.safetyMaxDailySpendUsd)
566
625
  if (dailySpendLimitUsd !== null) {
567
626
  const todaySpendUsd = getTodaySpendUsd()
@@ -711,6 +770,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
711
770
  active,
712
771
  loadHistory: isAutoRunNoHistory ? () => getSessionMessages(sessionId).slice(-6) : (sid: string) => applyContextClearBoundary(getSessionMessages(sid)),
713
772
  onUsage: (u) => { directUsage.inputTokens = u.inputTokens; directUsage.outputTokens = u.outputTokens; directUsage.received = true },
773
+ signal: abortController.signal,
714
774
  })
715
775
  } catch (err: any) {
716
776
  errorMessage = err?.message || String(err)
@@ -1014,14 +1074,26 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1014
1074
  const persistedText = heartbeatClassification === 'strip'
1015
1075
  ? textForPersistence.replace(/HEARTBEAT_OK/gi, '').trim()
1016
1076
  : textForPersistence
1017
- current.messages.push({
1077
+ const nowTs = Date.now()
1078
+ const nextAssistantMessage: Message = {
1018
1079
  role: 'assistant',
1019
1080
  text: persistedText,
1020
- time: Date.now(),
1081
+ time: nowTs,
1021
1082
  thinking: thinkingText || undefined,
1022
1083
  toolEvents: toolEvents.length ? toolEvents : undefined,
1023
1084
  kind: persistedKind,
1024
- })
1085
+ }
1086
+ const previous = current.messages.at(-1)
1087
+ if (shouldReplaceRecentAssistantMessage({
1088
+ previous,
1089
+ nextToolEvents: toolEvents,
1090
+ nextKind: persistedKind,
1091
+ now: nowTs,
1092
+ })) {
1093
+ current.messages[current.messages.length - 1] = nextAssistantMessage
1094
+ } else {
1095
+ current.messages.push(nextAssistantMessage)
1096
+ }
1025
1097
  changed = true
1026
1098
 
1027
1099
  // Conversation tone detection