@swarmclawai/swarmclaw 0.6.8 → 0.7.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 (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 +179 -349
  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,111 @@ 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 callMcpTool = async (toolName: string, args: Record<string, any>, options?: { saveTo?: string }): Promise<string> => {
286
224
  await ensureMcp()
287
225
  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 isError = result?.isError === true; const content = result?.content; const savedPaths: string[] = []
292
227
  const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
293
228
  const rawSaveTo = options?.saveTo?.trim()
294
229
  if (!rawSaveTo) return
295
230
  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)
231
+ if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
232
+ fs.mkdirSync(path.dirname(resolved), { recursive: true }); fs.writeFileSync(resolved, buffer)
301
233
  savedPaths.push(resolved)
302
234
  }
303
-
304
235
  if (Array.isArray(content)) {
305
236
  let parts: string[] = []
306
237
  const isScreenshotTool = toolName === 'browser_take_screenshot'
307
238
  const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
308
239
  for (const c of content) {
309
240
  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})`)
241
+ const imageBuffer = Buffer.from(c.data, 'base64'); const filename = `screenshot-${Date.now()}.png`
242
+ const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, imageBuffer)
243
+ saveArtifact(imageBuffer, 'png'); parts.push(`![Screenshot](/api/uploads/${filename})`)
316
244
  } else if (c.type === 'resource' && c.resource?.blob) {
317
245
  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})`)
246
+ const resourceBuffer = Buffer.from(c.resource.blob, 'base64'); const filename = `browser-${Date.now()}.${ext}`
247
+ const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, resourceBuffer)
248
+ saveArtifact(resourceBuffer, ext); parts.push(`[Download ${filename}](/api/uploads/${filename})`)
324
249
  } else {
325
250
  let text = c.text || ''
326
- // Detect file paths in output (e.g. PDF save returns a local path)
327
251
  const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
328
252
  if (fileMatch) {
329
- const rawPath = fileMatch[1]
330
- const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
253
+ const rawPath = fileMatch[1]; const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
331
254
  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)
255
+ const ext = path.extname(srcPath).slice(1).toLowerCase(); const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
256
+ if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) parts.push(isError ? text : cleanPlaywrightOutput(text))
257
+ else {
258
+ const filename = `browser-${Date.now()}.${ext}`; const destPath = path.join(UPLOAD_DIR, filename); fs.copyFileSync(srcPath, destPath)
341
259
  if (options?.saveTo?.trim()) {
342
- const raw = options.saveTo.trim()
343
- let targetPath = safePath(cwd, raw)
260
+ let targetPath = safePath(cwd, options.saveTo.trim())
344
261
  if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
345
- fs.mkdirSync(path.dirname(targetPath), { recursive: true })
346
- fs.copyFileSync(srcPath, targetPath)
262
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.copyFileSync(srcPath, targetPath)
347
263
  savedPaths.push(targetPath)
348
264
  }
349
- if (IMAGE_EXTS.includes(ext)) {
350
- parts.push(`![Screenshot](/api/uploads/${filename})`)
351
- } else {
352
- parts.push(`[Download ${filename}](/api/uploads/${filename})`)
353
- }
265
+ parts.push(IMAGE_EXTS.includes(ext) ? `![Screenshot](/api/uploads/${filename})` : `[Download ${filename}](/api/uploads/${filename})`)
354
266
  }
355
- } else {
356
- parts.push(isError ? text : cleanPlaywrightOutput(text))
357
- }
358
- } else {
359
- parts.push(isError ? text : cleanPlaywrightOutput(text))
360
- }
267
+ } else parts.push(isError ? text : cleanPlaywrightOutput(text))
268
+ } else parts.push(isError ? text : cleanPlaywrightOutput(text))
361
269
  }
362
270
  }
363
271
  if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
364
-
365
272
  if (savedPaths.length > 0) {
366
273
  const unique = Array.from(new Set(savedPaths))
367
- const rendered = unique.map((p) => path.relative(cwd, p) || '.').join(', ')
368
- parts.push(`Saved to: ${rendered}`)
274
+ parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
369
275
  }
370
276
  return parts.join('\n')
371
277
  }
372
278
  return JSON.stringify(result)
373
279
  }
374
280
 
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
281
+ const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
380
282
  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
- `
283
+ const js = `(() => {
284
+ 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'];
285
+ for (const s of sel) { const el = document.querySelector(s); if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; } }
286
+ const btns = [...document.querySelectorAll('button, a[role="button"]')]; const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
287
+ for (const b of btns) { const txt = (b.textContent || '').trim(); if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; } }
288
+ return 'none';
289
+ })()`
415
290
  await mcpCall('browser_evaluate', { expression: js })
416
291
  }
417
292
 
418
- // Action-to-MCP tool mapping
419
293
  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',
294
+ navigate: 'browser_navigate', screenshot: 'browser_take_screenshot', snapshot: 'browser_snapshot', click: 'browser_click',
295
+ type: 'browser_type', press_key: 'browser_press_key', select: 'browser_select_option', evaluate: 'browser_evaluate',
296
+ pdf: 'browser_pdf_save', upload: 'browser_file_upload', wait: 'browser_wait_for',
431
297
  }
432
298
 
433
299
  tools.push(
@@ -435,92 +301,56 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
435
301
  async (params) => {
436
302
  try {
437
303
  const { action, ...rest } = params
438
- // Build MCP args based on action
439
304
  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
305
+ if (!mcpTool) return `Unknown browser action: "${action}"`
442
306
  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
-
307
+ for (const [k, v] of Object.entries(rest)) { if (v !== undefined && v !== null && v !== '') args[k] = v }
308
+ const result = await callMcpTool(mcpTool, args, { saveTo: params.saveTo })
309
+ if (action === 'navigate') { try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ } }
456
310
  return result
457
- } catch (err: unknown) {
458
- return `Error: ${err instanceof Error ? err.message : String(err)}`
459
- }
311
+ } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }
460
312
  },
461
313
  {
462
314
  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(' '),
315
+ description: 'Control the browser. Actions: navigate, screenshot, snapshot, click, type, press_key, select, evaluate, pdf, upload, wait.',
469
316
  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).'),
317
+ action: z.enum(['navigate', 'screenshot', 'snapshot', 'click', 'type', 'press_key', 'select', 'evaluate', 'pdf', 'upload', 'wait']),
318
+ url: z.string().optional(), element: z.string().optional(), ref: z.string().optional(), text: z.string().optional(),
319
+ key: z.string().optional(), option: z.string().optional(), expression: z.string().optional(),
320
+ paths: z.array(z.string()).optional(), timeout: z.number().optional(), saveTo: z.string().optional(),
481
321
  }),
482
322
  },
483
323
  ),
484
324
  )
485
325
  }
486
326
 
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
- }
327
+ // openclaw_browser CLI passthrough
328
+ const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
329
+ if (openclawPath && (bctx.hasTool('browser') || bctx.hasTool('openclaw_browser'))) {
330
+ tools.push(
331
+ tool(
332
+ async (rawArgs) => {
333
+ const normalized = normalizeToolInputArgs((rawArgs ?? {}) as Record<string, unknown>)
334
+ const command = normalized.command as string | undefined
335
+ const cmdArgs = (normalized.args ?? normalized.arguments) as string | undefined
336
+ try {
337
+ if (!command) return 'Error: command is required.'
338
+ const spawnArgs = ['browser', command, '--json']
339
+ if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
340
+ const result = spawnSync(openclawPath, spawnArgs, { encoding: 'utf-8', timeout: 60_000, maxBuffer: MAX_OUTPUT })
341
+ if (result.status !== 0) return `Error (exit ${result.status}): ${result.stderr || result.stdout || 'unknown'}`
342
+ return truncate(result.stdout || '(no output)', MAX_OUTPUT)
343
+ } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }
344
+ },
345
+ {
346
+ name: 'openclaw_browser',
347
+ description: 'Control a browser through the OpenClaw CLI.',
348
+ schema: z.object({
349
+ command: z.string(), args: z.string().optional(),
350
+ }),
351
+ },
352
+ ),
353
+ )
524
354
  }
525
355
 
526
356
  return tools