@swarmclawai/swarmclaw 0.6.8 → 0.7.1

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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +186 -350
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -10,81 +10,56 @@ import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
10
  import { getSearchProvider } from './search-providers'
11
11
  import { dedupeScreenshotMarkdownLines } from './web-output'
12
12
  import { withRetry } from '../tool-retry'
13
+ import type { Plugin, PluginHooks } from '@/types'
14
+ import { getPluginManager } from '../plugins'
15
+ import { normalizeToolInputArgs } from './normalize-tool-args'
13
16
 
14
- // ---------------------------------------------------------------------------
15
- // Search result compression summarize verbose results before injecting into context
16
- // ---------------------------------------------------------------------------
17
-
18
- async function compressSearchResults(
19
- results: Array<{ title?: string; url?: string; snippet?: string }>,
20
- query: string,
21
- bctx: ToolBuildContext,
22
- ): Promise<string | null> {
17
+ // --- Search result compression logic ---
18
+ async function compressSearchResults(results: any[], query: string, bctx: any): Promise<string | null> {
23
19
  const session = bctx.resolveCurrentSession?.()
24
20
  if (!session?.provider || !session?.model) return null
25
-
26
21
  const { getProvider } = await import('@/lib/providers')
27
22
  const { loadCredentials, decryptKey } = await import('../storage')
28
23
  const providerEntry = getProvider(session.provider)
29
24
  if (!providerEntry?.handler?.streamChat) return null
30
-
31
- // Resolve API key
32
25
  let apiKey: string | undefined
33
26
  if (session.credentialId) {
34
27
  const creds = loadCredentials()
35
28
  const cred = creds[session.credentialId]
36
29
  if (cred) apiKey = decryptKey(cred.encryptedKey)
37
30
  }
38
-
39
31
  const systemPrompt = 'You are a search result summarizer. Condense search results into a concise reference. Keep key facts, URLs, and data points. Remove filler and redundancy. Output plain text, not JSON.'
40
32
  const message = `Query: "${query}"\n\nResults:\n${JSON.stringify(results, null, 1)}\n\nSummarize these results concisely.`
41
-
42
33
  let compressed = ''
43
34
  await providerEntry.handler.streamChat({
44
- session: { ...session, messages: [] },
45
- message,
46
- apiKey,
47
- systemPrompt,
35
+ session: { ...session, messages: [] }, message, apiKey, systemPrompt,
48
36
  write: (raw: string) => {
49
- // Extract text data from SSE lines
50
37
  const lines = raw.split('\n').filter(Boolean)
51
38
  for (const line of lines) {
52
39
  if (!line.startsWith('data: ')) continue
53
40
  try {
54
41
  const ev = JSON.parse(line.slice(6))
55
42
  if (ev.t === 'd' && ev.text) compressed += ev.text
56
- } catch { /* skip */ }
43
+ } catch { /* ignore */ }
57
44
  }
58
45
  },
59
- active: new Map(),
60
- loadHistory: () => [],
46
+ active: new Map(), loadHistory: () => [],
61
47
  })
62
-
63
48
  return compressed.trim() || null
64
49
  }
65
50
 
66
- // ---------------------------------------------------------------------------
67
- // Global registry of active browser instances for cleanup sweeps
68
- // ---------------------------------------------------------------------------
69
-
70
51
  export const activeBrowsers = new Map<string, { client: any; server: any; createdAt: number }>()
71
-
72
- /** Kill all browser instances that have been alive longer than maxAge (default 30 min) */
73
52
  export function sweepOrphanedBrowsers(maxAgeMs = 30 * 60 * 1000): number {
74
- const now = Date.now()
75
- let cleaned = 0
53
+ const now = Date.now(); let cleaned = 0
76
54
  for (const [key, entry] of activeBrowsers) {
77
55
  if (now - entry.createdAt > maxAgeMs) {
78
56
  try { entry.client?.close?.() } catch { /* ignore */ }
79
57
  try { entry.server?.close?.() } catch { /* ignore */ }
80
- activeBrowsers.delete(key)
81
- cleaned++
58
+ activeBrowsers.delete(key); cleaned++
82
59
  }
83
60
  }
84
61
  return cleaned
85
62
  }
86
-
87
- /** Kill a specific session's browser instance */
88
63
  export function cleanupSessionBrowser(sessionId: string): void {
89
64
  const entry = activeBrowsers.get(sessionId)
90
65
  if (entry) {
@@ -93,129 +68,115 @@ export function cleanupSessionBrowser(sessionId: string): void {
93
68
  activeBrowsers.delete(sessionId)
94
69
  }
95
70
  }
96
-
97
- /** Get count of active browser instances */
98
- export function getActiveBrowserCount(): number {
99
- return activeBrowsers.size
71
+ export function getActiveBrowserCount(): number { return activeBrowsers.size }
72
+ export function hasActiveBrowser(sessionId: string): boolean { return activeBrowsers.has(sessionId) }
73
+
74
+ /**
75
+ * Unified Web Execution Logic
76
+ */
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ async function executeWebAction(args: Record<string, unknown>, bctx: any) {
79
+ const normalized = normalizeToolInputArgs(args)
80
+ const { action, query, url, maxResults } = normalized as { action: string; query?: string; url?: string; maxResults?: number }
81
+ try {
82
+ if (action === 'search') {
83
+ const searchQuery = query || url
84
+ if (!searchQuery) return 'Error: "query" is required for search action.'
85
+ const limit = Math.min(maxResults || 5, 10)
86
+ const { loadSettings } = await import('../storage')
87
+ const settings = loadSettings()
88
+ const provider = await getSearchProvider(settings)
89
+ const results = await provider.search(searchQuery, limit)
90
+ if (results.length === 0) return 'No results found.'
91
+ const raw = JSON.stringify(results, null, 2)
92
+ if (raw.length > 2000) {
93
+ const compressed = await compressSearchResults(results, searchQuery, bctx)
94
+ if (compressed) return compressed
95
+ }
96
+ return raw
97
+ } else if (action === 'fetch') {
98
+ const fetchUrl = url || query
99
+ if (!fetchUrl) return 'Error: "url" is required for fetch action.'
100
+ const res = await fetch(fetchUrl, {
101
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
102
+ signal: AbortSignal.timeout(15000),
103
+ })
104
+ if (!res.ok) return `HTTP ${res.status}: ${res.statusText}`
105
+ const contentType = res.headers.get('content-type') || ''
106
+ if (contentType.includes('application/pdf')) {
107
+ try {
108
+ const pdfMod = await import(/* webpackIgnore: true */ 'pdf-parse')
109
+ const pdfParse = ((pdfMod as Record<string, unknown>).default ?? pdfMod) as (buf: Buffer) => Promise<{ text: string }>
110
+ const arrayBuffer = await res.arrayBuffer()
111
+ const result = await pdfParse(Buffer.from(arrayBuffer))
112
+ return truncate(result.text, MAX_OUTPUT)
113
+ } catch (err: unknown) {
114
+ return `Error parsing PDF: ${err instanceof Error ? err.message : String(err)}`
115
+ }
116
+ }
117
+ const html = await res.text()
118
+ const $ = cheerio.load(html)
119
+ $('script, style, noscript, nav, footer, header').remove()
120
+ const main = $('article, main, [role="main"]').first()
121
+ const text = (main.length ? main.text() : $('body').text()).replace(/\s+/g, ' ').trim()
122
+ return truncate(text, MAX_OUTPUT)
123
+ }
124
+ return `Error: Unknown action "${action}"`
125
+ } catch (err: unknown) {
126
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
127
+ }
100
128
  }
101
129
 
102
- /** Check if a specific session has an active browser */
103
- export function hasActiveBrowser(sessionId: string): boolean {
104
- return activeBrowsers.has(sessionId)
130
+ /**
131
+ * Register as a Built-in Plugin
132
+ */
133
+ const WebPlugin: Plugin = {
134
+ name: 'Core Web',
135
+ description: 'Search the web and fetch content from URLs.',
136
+ hooks: {} as PluginHooks,
137
+ tools: [
138
+ {
139
+ name: 'web',
140
+ description: 'Unified web access tool. Actions: search, fetch.',
141
+ parameters: {
142
+ type: 'object',
143
+ properties: {
144
+ action: { type: 'string', enum: ['search', 'fetch'] },
145
+ query: { type: 'string' },
146
+ url: { type: 'string' },
147
+ maxResults: { type: 'number' }
148
+ },
149
+ required: ['action']
150
+ },
151
+ execute: async (args, context) => executeWebAction(args, { ...context.session, resolveCurrentSession: () => context.session })
152
+ }
153
+ ]
105
154
  }
106
155
 
107
- // ---------------------------------------------------------------------------
108
- // buildWebTools
109
- // ---------------------------------------------------------------------------
156
+ getPluginManager().registerBuiltin('web', WebPlugin)
110
157
 
158
+ /**
159
+ * Legacy Bridge
160
+ */
111
161
  export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[] {
112
162
  const tools: StructuredToolInterface[] = []
113
163
  const { cwd, ctx, cleanupFns } = bctx
114
164
 
115
- // ---- web_search --------------------------------------------------------
116
-
117
- if (bctx.hasTool('web_search')) {
165
+ if (bctx.hasTool('web')) {
118
166
  tools.push(
119
167
  tool(
120
- ({ query, maxResults }) => withRetry(async (_args: { query: string; maxResults?: number }) => {
121
- try {
122
- const limit = Math.min(_args.maxResults || 5, 10)
123
- const { loadSettings } = await import('../storage')
124
- const settings = loadSettings()
125
- const provider = await getSearchProvider(settings)
126
- const results = await provider.search(_args.query, limit)
127
- if (results.length === 0) return 'No results found.'
128
- const raw = JSON.stringify(results, null, 2)
129
- // Compress search results if they exceed 2000 chars
130
- if (raw.length > 2000) {
131
- try {
132
- const compressed = await compressSearchResults(results, _args.query, bctx)
133
- if (compressed) return compressed
134
- } catch {
135
- // Compression failed — fall through to raw results
136
- }
137
- }
138
- return raw
139
- } catch (err: unknown) {
140
- return `Error searching web: ${err instanceof Error ? err.message : String(err)}`
141
- }
142
- }, { query, maxResults }),
168
+ async (args) => executeWebAction(args, bctx),
143
169
  {
144
- name: 'web_search',
145
- description: 'Search the web for information. Returns results with title, url, and snippet.',
146
- schema: z.object({
147
- query: z.string().describe('Search query'),
148
- maxResults: z.number().optional().describe('Maximum results to return (default 5, max 10)'),
149
- }),
150
- },
151
- ),
152
- )
153
- }
154
-
155
- // ---- web_fetch ---------------------------------------------------------
156
-
157
- if (bctx.hasTool('web_fetch')) {
158
- tools.push(
159
- tool(
160
- ({ url }) => withRetry(async (_args: { url: string }) => {
161
- try {
162
- const res = await fetch(_args.url, {
163
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
164
- signal: AbortSignal.timeout(15000),
165
- })
166
- if (!res.ok) return `HTTP ${res.status}: ${res.statusText}`
167
-
168
- const contentType = res.headers.get('content-type') || ''
169
- if (contentType.includes('application/pdf')) {
170
- try {
171
- // @ts-expect-error pdf-parse has no type declarations
172
- const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
173
- const arrayBuffer = await res.arrayBuffer()
174
- const result = await pdfParse(Buffer.from(arrayBuffer))
175
- return truncate(result.text, MAX_OUTPUT)
176
- } catch (err: unknown) {
177
- return `Error parsing PDF: ${err instanceof Error ? err.message : String(err)}`
178
- }
179
- }
180
-
181
- const html = await res.text()
182
-
183
- // Basic YouTube extraction (title and description)
184
- if (_args.url.includes('youtube.com/watch') || _args.url.includes('youtu.be/')) {
185
- const $ = cheerio.load(html)
186
- const title = $('meta[property="og:title"]').attr('content') || $('title').text()
187
- const description = $('meta[property="og:description"]').attr('content') || ''
188
- return truncate(`YouTube Video: ${title}\n\nDescription:\n${description}\n\n(Transcript extraction requires a specialized tool or API)`, MAX_OUTPUT)
189
- }
190
-
191
- // Use cheerio for robust HTML text extraction
192
- const $ = cheerio.load(html)
193
- $('script, style, noscript, nav, footer, header').remove()
194
- // Prefer article/main content if available
195
- const main = $('article, main, [role="main"]').first()
196
- const text = (main.length ? main.text() : $('body').text())
197
- .replace(/\s+/g, ' ')
198
- .trim()
199
- return truncate(text, MAX_OUTPUT)
200
- } catch (err: unknown) {
201
- return `Error fetching URL: ${err instanceof Error ? err.message : String(err)}`
202
- }
203
- }, { url }),
204
- {
205
- name: 'web_fetch',
206
- description: 'Fetch a URL and read its content. Supports HTML web pages and direct PDF links. Provides basic metadata for YouTube videos.',
207
- schema: z.object({
208
- url: z.string().describe('The URL to fetch'),
209
- }),
210
- },
211
- ),
170
+ name: 'web',
171
+ description: WebPlugin.tools![0].description,
172
+ schema: z.object({}).passthrough()
173
+ }
174
+ )
212
175
  )
213
176
  }
214
177
 
215
- // ---- browser -----------------------------------------------------------
216
-
178
+ // Browser tool (kept as direct injection for now due to complexity)
217
179
  if (bctx.hasTool('browser')) {
218
- // In-process Playwright MCP client via @playwright/mcp programmatic API
219
180
  const sessionKey = ctx?.sessionId || `anon-${Date.now()}`
220
181
  let mcpClient: any = null
221
182
  let mcpServer: any = null
@@ -228,206 +189,117 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
228
189
  const { createConnection } = await import('@playwright/mcp')
229
190
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
230
191
  const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
231
-
232
192
  const server = await createConnection({
233
- browser: {
234
- launchOptions: { headless: true },
235
- isolated: true,
236
- },
237
- imageResponses: 'allow',
238
- capabilities: ['core', 'pdf', 'vision', 'network'],
193
+ browser: { launchOptions: { headless: true }, isolated: true },
194
+ imageResponses: 'allow', capabilities: ['core', 'pdf', 'vision', 'network'],
239
195
  })
240
196
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
241
197
  const client = new Client({ name: 'swarmclaw', version: '1.0' })
242
- await Promise.all([
243
- client.connect(clientTransport),
244
- server.connect(serverTransport),
245
- ])
246
- mcpClient = client
247
- mcpServer = server
248
- // Register in global tracker
198
+ await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
199
+ mcpClient = client; mcpServer = server
249
200
  activeBrowsers.set(sessionKey, { client, server, createdAt: Date.now() })
250
201
  })()
251
202
  return mcpInitializing
252
203
  }
253
204
 
254
- // Register cleanup for this session's browser
255
205
  cleanupFns.push(async () => {
256
206
  try { mcpClient?.close?.() } catch { /* ignore */ }
257
207
  try { mcpServer?.close?.() } catch { /* ignore */ }
258
208
  activeBrowsers.delete(sessionKey)
259
- mcpClient = null
260
- mcpServer = null
209
+ mcpClient = null; mcpServer = null
261
210
  })
262
211
 
263
- /** Strip Playwright debug noise — keep page context for the LLM */
264
212
  const cleanPlaywrightOutput = (text: string): string => {
265
- // Remove "### Ran Playwright code" blocks (internal debug)
266
213
  text = text.replace(/### Ran Playwright code[\s\S]*?(?=###|$)/g, '')
267
- // Truncate snapshot to first 40 lines so LLM has page context without flooding
268
214
  text = text.replace(/### Snapshot\n([\s\S]*?)(?=###|$)/g, (_match, snapshot) => {
269
215
  const lines = (snapshot as string).split('\n')
270
- if (lines.length > 40) {
271
- return 'Page elements:\n' + lines.slice(0, 40).join('\n') + '\n... (truncated)\n'
272
- }
216
+ if (lines.length > 40) return 'Page elements:\n' + lines.slice(0, 40).join('\n') + '\n... (truncated)\n'
273
217
  return 'Page elements:\n' + snapshot
274
218
  })
275
- // Clean headers
276
- text = text.replace(/^### Result\n/gm, '')
277
- text = text.replace(/^### Page\n/gm, '')
219
+ text = text.replace(/^### Result\n/gm, ''); text = text.replace(/^### Page\n/gm, '')
278
220
  return text.replace(/\n{3,}/g, '\n').trim()
279
221
  }
280
222
 
281
- const callMcpTool = async (
282
- toolName: string,
283
- args: Record<string, any>,
284
- options?: { saveTo?: string },
285
- ): Promise<string> => {
223
+ const MCP_CALL_TIMEOUT_MS = 30000 // 30s timeout per browser action
224
+ const callMcpTool = async (toolName: string, args: Record<string, any>, options?: { saveTo?: string }): Promise<string> => {
286
225
  await ensureMcp()
287
- const result = await mcpClient.callTool({ name: toolName, arguments: args })
288
- const isError = result?.isError === true
289
- const content = result?.content
290
- const savedPaths: string[] = []
291
-
226
+ const result = await Promise.race([
227
+ mcpClient.callTool({ name: toolName, arguments: args }),
228
+ new Promise<never>((_resolve, reject) =>
229
+ setTimeout(() => reject(new Error(`Browser action "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS / 1000}s`)), MCP_CALL_TIMEOUT_MS)
230
+ ),
231
+ ])
232
+ const isError = result?.isError === true; const content = result?.content; const savedPaths: string[] = []
292
233
  const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
293
234
  const rawSaveTo = options?.saveTo?.trim()
294
235
  if (!rawSaveTo) return
295
236
  let resolved = safePath(cwd, rawSaveTo)
296
- if (!path.extname(resolved) && suggestedExt) {
297
- resolved = `${resolved}.${suggestedExt}`
298
- }
299
- fs.mkdirSync(path.dirname(resolved), { recursive: true })
300
- fs.writeFileSync(resolved, buffer)
237
+ if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
238
+ fs.mkdirSync(path.dirname(resolved), { recursive: true }); fs.writeFileSync(resolved, buffer)
301
239
  savedPaths.push(resolved)
302
240
  }
303
-
304
241
  if (Array.isArray(content)) {
305
242
  let parts: string[] = []
306
243
  const isScreenshotTool = toolName === 'browser_take_screenshot'
307
244
  const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
308
245
  for (const c of content) {
309
246
  if (c.type === 'image' && c.data) {
310
- const imageBuffer = Buffer.from(c.data, 'base64')
311
- const filename = `screenshot-${Date.now()}.png`
312
- const filepath = path.join(UPLOAD_DIR, filename)
313
- fs.writeFileSync(filepath, imageBuffer)
314
- saveArtifact(imageBuffer, 'png')
315
- parts.push(`![Screenshot](/api/uploads/${filename})`)
247
+ const imageBuffer = Buffer.from(c.data, 'base64'); const filename = `screenshot-${Date.now()}.png`
248
+ const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, imageBuffer)
249
+ saveArtifact(imageBuffer, 'png'); parts.push(`![Screenshot](/api/uploads/${filename})`)
316
250
  } else if (c.type === 'resource' && c.resource?.blob) {
317
251
  const ext = c.resource.mimeType?.includes('pdf') ? 'pdf' : 'bin'
318
- const resourceBuffer = Buffer.from(c.resource.blob, 'base64')
319
- const filename = `browser-${Date.now()}.${ext}`
320
- const filepath = path.join(UPLOAD_DIR, filename)
321
- fs.writeFileSync(filepath, resourceBuffer)
322
- saveArtifact(resourceBuffer, ext)
323
- parts.push(`[Download ${filename}](/api/uploads/${filename})`)
252
+ const resourceBuffer = Buffer.from(c.resource.blob, 'base64'); const filename = `browser-${Date.now()}.${ext}`
253
+ const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, resourceBuffer)
254
+ saveArtifact(resourceBuffer, ext); parts.push(`[Download ${filename}](/api/uploads/${filename})`)
324
255
  } else {
325
256
  let text = c.text || ''
326
- // Detect file paths in output (e.g. PDF save returns a local path)
327
257
  const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
328
258
  if (fileMatch) {
329
- const rawPath = fileMatch[1]
330
- const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
259
+ const rawPath = fileMatch[1]; const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
331
260
  if (fs.existsSync(srcPath)) {
332
- const ext = path.extname(srcPath).slice(1).toLowerCase()
333
- const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
334
- // Skip file-path images whenever MCP already returned image binary payloads.
335
- if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) {
336
- parts.push(isError ? text : cleanPlaywrightOutput(text))
337
- } else {
338
- const filename = `browser-${Date.now()}.${ext}`
339
- const destPath = path.join(UPLOAD_DIR, filename)
340
- fs.copyFileSync(srcPath, destPath)
261
+ const ext = path.extname(srcPath).slice(1).toLowerCase(); const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
262
+ if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) parts.push(isError ? text : cleanPlaywrightOutput(text))
263
+ else {
264
+ const filename = `browser-${Date.now()}.${ext}`; const destPath = path.join(UPLOAD_DIR, filename); fs.copyFileSync(srcPath, destPath)
341
265
  if (options?.saveTo?.trim()) {
342
- const raw = options.saveTo.trim()
343
- let targetPath = safePath(cwd, raw)
266
+ let targetPath = safePath(cwd, options.saveTo.trim())
344
267
  if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
345
- fs.mkdirSync(path.dirname(targetPath), { recursive: true })
346
- fs.copyFileSync(srcPath, targetPath)
268
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.copyFileSync(srcPath, targetPath)
347
269
  savedPaths.push(targetPath)
348
270
  }
349
- if (IMAGE_EXTS.includes(ext)) {
350
- parts.push(`![Screenshot](/api/uploads/${filename})`)
351
- } else {
352
- parts.push(`[Download ${filename}](/api/uploads/${filename})`)
353
- }
271
+ parts.push(IMAGE_EXTS.includes(ext) ? `![Screenshot](/api/uploads/${filename})` : `[Download ${filename}](/api/uploads/${filename})`)
354
272
  }
355
- } else {
356
- parts.push(isError ? text : cleanPlaywrightOutput(text))
357
- }
358
- } else {
359
- parts.push(isError ? text : cleanPlaywrightOutput(text))
360
- }
273
+ } else parts.push(isError ? text : cleanPlaywrightOutput(text))
274
+ } else parts.push(isError ? text : cleanPlaywrightOutput(text))
361
275
  }
362
276
  }
363
277
  if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
364
-
365
278
  if (savedPaths.length > 0) {
366
279
  const unique = Array.from(new Set(savedPaths))
367
- const rendered = unique.map((p) => path.relative(cwd, p) || '.').join(', ')
368
- parts.push(`Saved to: ${rendered}`)
280
+ parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
369
281
  }
370
282
  return parts.join('\n')
371
283
  }
372
284
  return JSON.stringify(result)
373
285
  }
374
286
 
375
- // Best-effort cookie/consent banner dismissal after navigation
376
- const dismissCookieBanners = async (
377
- mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>,
378
- ) => {
379
- // Wait briefly for consent overlays to appear
287
+ const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
380
288
  await new Promise((r) => setTimeout(r, 1500))
381
- const js = `
382
- (() => {
383
- const sel = [
384
- // Common "Reject" / "Reject all" / "Decline" buttons
385
- 'button[id*="reject" i]', 'button[class*="reject" i]',
386
- 'a[id*="reject" i]', 'a[class*="reject" i]',
387
- '[data-testid*="reject" i]', '[data-action="reject"]',
388
- // OneTrust
389
- '#onetrust-reject-all-handler',
390
- // Cookiebot
391
- '#CybotCookiebotDialogBodyButtonDecline',
392
- // Didomi
393
- '#didomi-notice-disagree-button',
394
- // Quantcast / IAB TCF
395
- '.qc-cmp2-summary-buttons button:first-child',
396
- 'button.sp_choice_type_12',
397
- // Generic patterns
398
- 'button[aria-label*="reject" i]', 'button[aria-label*="decline" i]',
399
- 'button[aria-label*="deny" i]', 'button[aria-label*="refuse" i]',
400
- ];
401
- for (const s of sel) {
402
- const el = document.querySelector(s);
403
- if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; }
404
- }
405
- // Fallback: find buttons by visible text
406
- const btns = [...document.querySelectorAll('button, a[role="button"], [class*="cookie"] button, [class*="consent"] button, [id*="cookie"] button')];
407
- const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
408
- for (const b of btns) {
409
- const txt = (b.textContent || '').trim();
410
- if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; }
411
- }
412
- return 'none';
413
- })()
414
- `
289
+ const js = `(() => {
290
+ const sel = ['button[id*="reject" i]', 'button[class*="reject" i]', 'a[id*="reject" i]', 'a[class*="reject" i]', '#onetrust-reject-all-handler', '#CybotCookiebotDialogBodyButtonDecline', '#didomi-notice-disagree-button', '.qc-cmp2-summary-buttons button:first-child', 'button.sp_choice_type_12'];
291
+ for (const s of sel) { const el = document.querySelector(s); if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; } }
292
+ const btns = [...document.querySelectorAll('button, a[role="button"]')]; const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
293
+ for (const b of btns) { const txt = (b.textContent || '').trim(); if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; } }
294
+ return 'none';
295
+ })()`
415
296
  await mcpCall('browser_evaluate', { expression: js })
416
297
  }
417
298
 
418
- // Action-to-MCP tool mapping
419
299
  const MCP_TOOL_MAP: Record<string, string> = {
420
- navigate: 'browser_navigate',
421
- screenshot: 'browser_take_screenshot',
422
- snapshot: 'browser_snapshot',
423
- click: 'browser_click',
424
- type: 'browser_type',
425
- press_key: 'browser_press_key',
426
- select: 'browser_select_option',
427
- evaluate: 'browser_evaluate',
428
- pdf: 'browser_pdf_save',
429
- upload: 'browser_file_upload',
430
- wait: 'browser_wait_for',
300
+ navigate: 'browser_navigate', screenshot: 'browser_take_screenshot', snapshot: 'browser_snapshot', click: 'browser_click',
301
+ type: 'browser_type', press_key: 'browser_press_key', select: 'browser_select_option', evaluate: 'browser_evaluate',
302
+ pdf: 'browser_pdf_save', upload: 'browser_file_upload', wait: 'browser_wait_for',
431
303
  }
432
304
 
433
305
  tools.push(
@@ -435,92 +307,56 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
435
307
  async (params) => {
436
308
  try {
437
309
  const { action, ...rest } = params
438
- // Build MCP args based on action
439
310
  const mcpTool = MCP_TOOL_MAP[action]
440
- if (!mcpTool) return `Unknown browser action: "${action}". Valid: ${Object.keys(MCP_TOOL_MAP).join(', ')}`
441
- // Pass only defined (non-undefined) params to MCP
311
+ if (!mcpTool) return `Unknown browser action: "${action}"`
442
312
  const args: Record<string, any> = {}
443
- for (const [k, v] of Object.entries(rest)) {
444
- if (v !== undefined && v !== null && v !== '') args[k] = v
445
- }
446
- const saveTo = typeof params.saveTo === 'string' && params.saveTo.trim()
447
- ? params.saveTo.trim()
448
- : undefined
449
- const result = await callMcpTool(mcpTool, args, { saveTo })
450
-
451
- // After navigation, attempt to dismiss cookie consent banners
452
- if (action === 'navigate') {
453
- try { await dismissCookieBanners(callMcpTool) } catch { /* best-effort */ }
454
- }
455
-
313
+ for (const [k, v] of Object.entries(rest)) { if (v !== undefined && v !== null && v !== '') args[k] = v }
314
+ const result = await callMcpTool(mcpTool, args, { saveTo: params.saveTo })
315
+ if (action === 'navigate') { try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ } }
456
316
  return result
457
- } catch (err: unknown) {
458
- return `Error: ${err instanceof Error ? err.message : String(err)}`
459
- }
317
+ } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }
460
318
  },
461
319
  {
462
320
  name: 'browser',
463
- description: [
464
- 'Control the browser. Use action to specify what to do.',
465
- 'Actions: navigate (url), screenshot, snapshot (get page elements), click (element/ref), type (element/ref, text), press_key (key), select (element/ref, option), evaluate (expression), pdf, upload (paths, ref), wait (text/timeout).',
466
- 'Workflow: use snapshot to see the page and get element refs, then use click/type/select with those refs.',
467
- 'Screenshots are returned as images visible to the user. Use saveTo to persist screenshot/PDF artifacts to disk.',
468
- ].join(' '),
321
+ description: 'Control the browser. Actions: navigate, screenshot, snapshot, click, type, press_key, select, evaluate, pdf, upload, wait.',
469
322
  schema: z.object({
470
- action: z.enum(['navigate', 'screenshot', 'snapshot', 'click', 'type', 'press_key', 'select', 'evaluate', 'pdf', 'upload', 'wait']).describe('The browser action to perform'),
471
- url: z.string().optional().describe('URL to navigate to (for navigate action)'),
472
- element: z.string().optional().describe('CSS selector or description of an element (for click/type/select)'),
473
- ref: z.string().optional().describe('Element reference from a previous snapshot (for click/type/select/upload)'),
474
- text: z.string().optional().describe('Text to type (for type action) or text to wait for (for wait action)'),
475
- key: z.string().optional().describe('Key to press, e.g. Enter, Tab, Escape (for press_key action)'),
476
- option: z.string().optional().describe('Option value or label to select (for select action)'),
477
- expression: z.string().optional().describe('JavaScript expression to evaluate (for evaluate action)'),
478
- paths: z.array(z.string()).optional().describe('File paths to upload (for upload action)'),
479
- timeout: z.number().optional().describe('Timeout in milliseconds (for wait action, default 30000)'),
480
- saveTo: z.string().optional().describe('Optional output path for screenshot/pdf artifacts (relative to working directory).'),
323
+ action: z.enum(['navigate', 'screenshot', 'snapshot', 'click', 'type', 'press_key', 'select', 'evaluate', 'pdf', 'upload', 'wait']),
324
+ url: z.string().optional(), element: z.string().optional(), ref: z.string().optional(), text: z.string().optional(),
325
+ key: z.string().optional(), option: z.string().optional(), expression: z.string().optional(),
326
+ paths: z.array(z.string()).optional(), timeout: z.number().optional(), saveTo: z.string().optional(),
481
327
  }),
482
328
  },
483
329
  ),
484
330
  )
485
331
  }
486
332
 
487
- // ---- openclaw_browser (CLI passthrough) -----------------------------------
488
-
489
- if (bctx.hasTool('browser') || bctx.hasTool('openclaw_browser')) {
490
- const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
491
- if (openclawPath) {
492
- tools.push(
493
- tool(
494
- async ({ command, args: cmdArgs }) => {
495
- try {
496
- const spawnArgs = ['browser', command, '--json']
497
- if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
498
- const result = spawnSync(openclawPath, spawnArgs, {
499
- encoding: 'utf-8',
500
- timeout: 60_000,
501
- maxBuffer: MAX_OUTPUT,
502
- })
503
- const stdout = (result.stdout || '').trim()
504
- const stderr = (result.stderr || '').trim()
505
- if (result.status !== 0) {
506
- return `Error (exit ${result.status}): ${stderr || stdout || 'unknown error'}`
507
- }
508
- return truncate(stdout || '(no output)', MAX_OUTPUT)
509
- } catch (err: unknown) {
510
- return `Error: ${err instanceof Error ? err.message : String(err)}`
511
- }
512
- },
513
- {
514
- name: 'openclaw_browser',
515
- description: 'Control a browser through the OpenClaw CLI. Requires openclaw/clawdbot CLI on PATH. Passes through to `openclaw browser <command> --json`.',
516
- schema: z.object({
517
- command: z.string().describe('Browser command (navigate, screenshot, click, type, evaluate, etc.)'),
518
- args: z.string().optional().describe('Additional arguments as a space-separated string'),
519
- }),
520
- },
521
- ),
522
- )
523
- }
333
+ // openclaw_browser CLI passthrough
334
+ const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
335
+ if (openclawPath && (bctx.hasTool('browser') || bctx.hasTool('openclaw_browser'))) {
336
+ tools.push(
337
+ tool(
338
+ async (rawArgs) => {
339
+ const normalized = normalizeToolInputArgs((rawArgs ?? {}) as Record<string, unknown>)
340
+ const command = normalized.command as string | undefined
341
+ const cmdArgs = (normalized.args ?? normalized.arguments) as string | undefined
342
+ try {
343
+ if (!command) return 'Error: command is required.'
344
+ const spawnArgs = ['browser', command, '--json']
345
+ if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
346
+ const result = spawnSync(openclawPath, spawnArgs, { encoding: 'utf-8', timeout: 60_000, maxBuffer: MAX_OUTPUT })
347
+ if (result.status !== 0) return `Error (exit ${result.status}): ${result.stderr || result.stdout || 'unknown'}`
348
+ return truncate(result.stdout || '(no output)', MAX_OUTPUT)
349
+ } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }
350
+ },
351
+ {
352
+ name: 'openclaw_browser',
353
+ description: 'Control a browser through the OpenClaw CLI.',
354
+ schema: z.object({
355
+ command: z.string(), args: z.string().optional(),
356
+ }),
357
+ },
358
+ ),
359
+ )
524
360
  }
525
361
 
526
362
  return tools