@swarmclawai/swarmclaw 0.7.8 → 0.8.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 (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -2,12 +2,13 @@ import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import fs from 'fs'
4
4
  import path from 'path'
5
+ import { fileURLToPath, pathToFileURL } from 'url'
5
6
  import * as cheerio from 'cheerio'
6
7
  import { UPLOAD_DIR } from '../storage'
7
8
  import type { ToolBuildContext } from './context'
8
9
  import { spawnSync } from 'child_process'
9
10
  import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
- import { getSearchProvider } from './search-providers'
11
+ import { getSearchProvider, type SearchResult } from './search-providers'
11
12
  import { dedupeScreenshotMarkdownLines } from './web-output'
12
13
  import { withRetry } from '../tool-retry'
13
14
  import type { Plugin, PluginHooks } from '@/types'
@@ -16,44 +17,55 @@ import { normalizeToolInputArgs } from './normalize-tool-args'
16
17
  import {
17
18
  ensureSessionBrowserProfileId,
18
19
  getBrowserProfileDir,
20
+ loadBrowserSessionRecord,
19
21
  markBrowserSessionClosed,
20
22
  recordBrowserObservation,
21
23
  removeBrowserSessionRecord,
22
24
  upsertBrowserSessionRecord,
23
25
  } from '../browser-state'
24
26
 
25
- // --- Search result compression logic ---
26
- async function compressSearchResults(results: any[], query: string, bctx: any): Promise<string | null> {
27
- const session = bctx.resolveCurrentSession?.()
28
- if (!session?.provider || !session?.model) return null
29
- const { getProvider } = await import('@/lib/providers')
30
- const { loadCredentials, decryptKey } = await import('../storage')
31
- const providerEntry = getProvider(session.provider)
32
- if (!providerEntry?.handler?.streamChat) return null
33
- let apiKey: string | undefined
34
- if (session.credentialId) {
35
- const creds = loadCredentials()
36
- const cred = creds[session.credentialId]
37
- if (cred) apiKey = decryptKey(cred.encryptedKey)
38
- }
39
- 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
- const message = `Query: "${query}"\n\nResults:\n${JSON.stringify(results, null, 1)}\n\nSummarize these results concisely.`
41
- let compressed = ''
42
- await providerEntry.handler.streamChat({
43
- session: { ...session, messages: [] }, message, apiKey, systemPrompt,
44
- write: (raw: string) => {
45
- const lines = raw.split('\n').filter(Boolean)
46
- for (const line of lines) {
47
- if (!line.startsWith('data: ')) continue
48
- try {
49
- const ev = JSON.parse(line.slice(6))
50
- if (ev.t === 'd' && ev.text) compressed += ev.text
51
- } catch { /* ignore */ }
27
+ function cleanSearchField(value: string | undefined): string {
28
+ return (value || '').replace(/\s+/g, ' ').trim()
29
+ }
30
+
31
+ export function formatWebSearchResults(query: string, results: SearchResult[], maxChars = MAX_OUTPUT): string {
32
+ const cleanedQuery = cleanSearchField(query)
33
+ const header = cleanedQuery ? `Search results for: ${cleanedQuery}` : 'Search results'
34
+ const sections: string[] = [header]
35
+ const joinSections = (items: string[]) => items.filter(Boolean).join('\n\n')
36
+
37
+ for (let index = 0; index < results.length; index++) {
38
+ const result = results[index]
39
+ const title = cleanSearchField(result?.title) || cleanSearchField(result?.url) || `Result ${index + 1}`
40
+ const url = cleanSearchField(result?.url)
41
+ const snippet = cleanSearchField(result?.snippet)
42
+ const lines = [`${index + 1}. ${title}`]
43
+ if (url) lines.push(`URL: ${url}`)
44
+ if (snippet) lines.push(`Snippet: ${snippet}`)
45
+ const candidate = joinSections([...sections, lines.join('\n')])
46
+ if (candidate.length <= maxChars) {
47
+ sections.push(lines.join('\n'))
48
+ continue
49
+ }
50
+
51
+ if (url) {
52
+ const minimalLines = [`${index + 1}. ${title}`, `URL: ${url}`]
53
+ const minimalCandidate = joinSections([...sections, minimalLines.join('\n')])
54
+ if (minimalCandidate.length <= maxChars) {
55
+ sections.push(minimalLines.join('\n'))
52
56
  }
53
- },
54
- active: new Map(), loadHistory: () => [],
55
- })
56
- return compressed.trim() || null
57
+ }
58
+
59
+ const omitted = results.length - index
60
+ if (omitted > 0) {
61
+ const remainingNotice = `(${omitted} additional result${omitted === 1 ? '' : 's'} omitted for brevity)`
62
+ const withNotice = joinSections([...sections, remainingNotice])
63
+ if (withNotice.length <= maxChars) sections.push(remainingNotice)
64
+ }
65
+ return truncate(joinSections(sections), maxChars)
66
+ }
67
+
68
+ return truncate(joinSections(sections), maxChars)
57
69
  }
58
70
 
59
71
  type BrowserRuntimeEntry = {
@@ -171,11 +183,202 @@ export function inferWebActionFromArgs(params: {
171
183
  return undefined
172
184
  }
173
185
 
186
+ function parseStructuredJsonValue(value: unknown): unknown {
187
+ if (typeof value !== 'string') return value
188
+ const trimmed = value.trim()
189
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value
190
+ try {
191
+ return JSON.parse(trimmed)
192
+ } catch {
193
+ return value
194
+ }
195
+ }
196
+
197
+ function parseJsonObjectValue(value: unknown): Record<string, unknown> | null {
198
+ const parsed = parseStructuredJsonValue(value)
199
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
200
+ ? parsed as Record<string, unknown>
201
+ : null
202
+ }
203
+
204
+ function parseJsonArrayValue(value: unknown): unknown[] | null {
205
+ const parsed = parseStructuredJsonValue(value)
206
+ return Array.isArray(parsed) ? parsed : null
207
+ }
208
+
209
+ function pickNonEmptyBrowserString(...values: unknown[]): string | undefined {
210
+ for (const value of values) {
211
+ if (typeof value !== 'string') continue
212
+ const trimmed = value.trim()
213
+ if (trimmed) return trimmed
214
+ }
215
+ return undefined
216
+ }
217
+
218
+ function wrapBrowserEvaluateFunction(code: string): string {
219
+ const trimmed = code.trim()
220
+ if (!trimmed) return trimmed
221
+ if (/^(?:async\s+)?function\b/.test(trimmed)) return trimmed
222
+ if (/^(?:async\s*)?\([^)]*\)\s*=>/.test(trimmed)) return trimmed
223
+ return /[;{}]/.test(trimmed)
224
+ ? `() => { ${trimmed} }`
225
+ : `() => (${trimmed})`
226
+ }
227
+
228
+ function wrapBrowserRunCodeFunction(code: string): string {
229
+ const trimmed = code.trim()
230
+ if (!trimmed) return trimmed
231
+ if (/^(?:async\s+)?function\b/.test(trimmed)) return trimmed
232
+ if (/^(?:async\s*)?\([^)]*\)\s*=>/.test(trimmed)) return trimmed
233
+ return /[;{}]/.test(trimmed)
234
+ ? `async (page) => { ${trimmed} }`
235
+ : `async (page) => (${trimmed})`
236
+ }
237
+
238
+ export function normalizeBrowserActionParams(rawParams: Record<string, unknown>): Record<string, unknown> {
239
+ const normalized = normalizeToolInputArgs(rawParams)
240
+ const action = String(normalized.action || '').trim().toLowerCase()
241
+ const params: Record<string, unknown> = { ...normalized }
242
+
243
+ const parsedFields = parseJsonArrayValue(params.fields)
244
+ if (parsedFields) params.fields = parsedFields
245
+
246
+ const parsedForm = parseJsonObjectValue(params.form)
247
+ if (parsedForm) params.form = parsedForm
248
+
249
+ if (typeof params.selector === 'string' && !pickNonEmptyBrowserString(params.element)) {
250
+ params.element = params.selector
251
+ }
252
+
253
+ if (action === 'submit_form' && typeof params.selector === 'string' && !pickNonEmptyBrowserString(params.submitElement)) {
254
+ params.submitElement = params.selector
255
+ }
256
+
257
+ if (action === 'select') {
258
+ const parsedValues = parseJsonArrayValue(params.values ?? params.option ?? params.value)
259
+ if (parsedValues) params.values = parsedValues
260
+ else if (params.values === undefined) {
261
+ const scalar = pickNonEmptyBrowserString(params.option, params.value, params.text)
262
+ if (scalar) params.values = [scalar]
263
+ }
264
+ }
265
+
266
+ if (action === 'evaluate' && !pickNonEmptyBrowserString(params.function)) {
267
+ const code = pickNonEmptyBrowserString(params.code, params.script, params.javascript, params.js)
268
+ if (code) params.function = wrapBrowserEvaluateFunction(code)
269
+ }
270
+
271
+ if (action === 'run_code') {
272
+ const code = pickNonEmptyBrowserString(params.code, params.function, params.script, params.javascript, params.js)
273
+ if (code) params.code = wrapBrowserRunCodeFunction(code)
274
+ }
275
+
276
+ return params
277
+ }
278
+
279
+ function pickBrowserTargetFromParams(params: Record<string, unknown>): string | null {
280
+ for (const value of [
281
+ params.url,
282
+ params.filePath,
283
+ params.path,
284
+ params.href,
285
+ params.link,
286
+ params.target,
287
+ params.page,
288
+ ]) {
289
+ if (typeof value !== 'string') continue
290
+ const trimmed = value.trim()
291
+ if (trimmed) return trimmed
292
+ }
293
+ return null
294
+ }
295
+
296
+ function resolveUploadFilePath(target: string): string | null {
297
+ const normalized = target.replace(/^sandbox:/, '')
298
+ const match = normalized.match(/^\/api\/uploads\/([^?#]+)/)
299
+ if (!match) return null
300
+ let decoded = match[1]
301
+ try {
302
+ decoded = decodeURIComponent(decoded)
303
+ } catch {
304
+ // keep raw segment
305
+ }
306
+ const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
307
+ const resolved = path.join(UPLOAD_DIR, safeName)
308
+ return fs.existsSync(resolved) ? resolved : null
309
+ }
310
+
311
+ function resolveBrowserFileUrlPath(target: string): string | null {
312
+ if (!/^file:/i.test(target)) return null
313
+ try {
314
+ const resolved = fileURLToPath(target)
315
+ return fs.existsSync(resolved) ? resolved : null
316
+ } catch {
317
+ return null
318
+ }
319
+ }
320
+
321
+ function tryResolveBrowserLocalPath(cwd: string, target: string): string | null {
322
+ const uploadPath = resolveUploadFilePath(target)
323
+ if (uploadPath) return uploadPath
324
+
325
+ const fileUrlPath = resolveBrowserFileUrlPath(target)
326
+ if (fileUrlPath) return fileUrlPath
327
+
328
+ if (/^(?:https?:|about:|data:)/i.test(target)) return null
329
+
330
+ const normalized = target.replace(/^sandbox:/, '')
331
+ const looksLikePath = normalized.startsWith('/')
332
+ || normalized.startsWith('./')
333
+ || normalized.startsWith('../')
334
+ || normalized.includes('/')
335
+ || /\.(?:html?|xhtml|txt|md|json|ya?ml|csv|ts|tsx|js|jsx|mjs|cjs|css|png|jpe?g|gif|webp|svg|pdf)$/i.test(normalized)
336
+ if (!looksLikePath) return null
337
+
338
+ const candidates = new Set<string>()
339
+ if (path.isAbsolute(normalized)) candidates.add(normalized)
340
+ try { candidates.add(safePath(cwd, normalized)) } catch { /* ignore */ }
341
+ try { candidates.add(path.resolve(cwd, normalized)) } catch { /* ignore */ }
342
+
343
+ for (const candidate of candidates) {
344
+ if (!candidate || !fs.existsSync(candidate)) continue
345
+ const stat = fs.statSync(candidate)
346
+ if (stat.isDirectory()) {
347
+ const indexPath = path.join(candidate, 'index.html')
348
+ if (fs.existsSync(indexPath)) return indexPath
349
+ return null
350
+ }
351
+ return candidate
352
+ }
353
+ return null
354
+ }
355
+
356
+ function localHtmlFileToDataUrl(filePath: string): string | null {
357
+ const ext = path.extname(filePath).toLowerCase()
358
+ if (ext !== '.html' && ext !== '.htm') return null
359
+ try {
360
+ const html = fs.readFileSync(filePath, 'utf8')
361
+ const hasRelativeAssetReferences = /<(?:script|img|source|video|audio)\b[^>]+\b(?:src|poster)\s*=\s*["'](?![a-z]+:|\/\/|#|\/)([^"']+)["']|<link\b[^>]+\bhref\s*=\s*["'](?![a-z]+:|\/\/|#|\/)([^"']+)["']/i.test(html)
362
+ if (hasRelativeAssetReferences) return null
363
+ return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`
364
+ } catch {
365
+ return null
366
+ }
367
+ }
368
+
369
+ export function resolveBrowserNavigationTarget(cwd: string, target: string): string {
370
+ const trimmed = target.trim()
371
+ if (!trimmed) return trimmed
372
+ const localPath = tryResolveBrowserLocalPath(cwd, trimmed)
373
+ if (localPath) return localHtmlFileToDataUrl(localPath) || pathToFileURL(localPath).toString()
374
+ return trimmed.replace(/^sandbox:/, '')
375
+ }
376
+
174
377
  /**
175
378
  * Unified Web Execution Logic
176
379
  */
177
380
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- async function executeWebAction(args: Record<string, unknown>, bctx: any) {
381
+ async function executeWebAction(args: Record<string, unknown>) {
179
382
  const normalized = normalizeToolInputArgs(args)
180
383
  const { query, url, maxResults } = normalized as { query?: string; url?: string; maxResults?: number }
181
384
  const action = inferWebActionFromArgs({
@@ -193,12 +396,7 @@ async function executeWebAction(args: Record<string, unknown>, bctx: any) {
193
396
  const provider = await getSearchProvider(settings)
194
397
  const results = await provider.search(searchQuery, limit)
195
398
  if (results.length === 0) return 'No results found.'
196
- const raw = JSON.stringify(results, null, 2)
197
- if (raw.length > 2000) {
198
- const compressed = await compressSearchResults(results, searchQuery, bctx)
199
- if (compressed) return compressed
200
- }
201
- return raw
399
+ return formatWebSearchResults(searchQuery, results)
202
400
  } else if (action === 'fetch') {
203
401
  const fetchUrl = url || query
204
402
  if (!fetchUrl) return 'Error: "url" is required for fetch action.'
@@ -255,7 +453,7 @@ const WebPlugin: Plugin = {
255
453
  },
256
454
  required: ['action']
257
455
  },
258
- execute: async (args, context) => executeWebAction(args, { ...context.session, resolveCurrentSession: () => context.session })
456
+ execute: async (args) => executeWebAction(args)
259
457
  }
260
458
  ]
261
459
  }
@@ -272,7 +470,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
272
470
  if (bctx.hasPlugin('web')) {
273
471
  tools.push(
274
472
  tool(
275
- async (args) => executeWebAction(args, bctx),
473
+ async (args) => executeWebAction(args),
276
474
  {
277
475
  name: 'web',
278
476
  description: WebPlugin.tools![0].description,
@@ -346,13 +544,30 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
346
544
  pendingBrowserInitializations.set(sessionKey, connectPromise)
347
545
  const entry = await connectPromise
348
546
  acquireExistingEntry(entry)
547
+ const lastState = loadBrowserSessionRecord(sessionKey)
548
+ const restoreUrl = typeof lastState?.currentUrl === 'string' ? lastState.currentUrl.trim() : ''
549
+ if (restoreUrl && restoreUrl !== 'about:blank') {
550
+ try {
551
+ await entry.client.callTool({ name: 'browser_navigate', arguments: { url: restoreUrl } })
552
+ } catch (err: unknown) {
553
+ upsertBrowserSessionRecord({
554
+ sessionId: sessionKey,
555
+ profileId: profileInfo.profileId,
556
+ profileDir,
557
+ inheritedFromSessionId: profileInfo.inheritedFromSessionId,
558
+ status: 'error',
559
+ lastAction: 'browser_restore',
560
+ lastError: err instanceof Error ? err.message : String(err),
561
+ })
562
+ }
563
+ }
349
564
  upsertBrowserSessionRecord({
350
565
  sessionId: sessionKey,
351
566
  profileId: profileInfo.profileId,
352
567
  profileDir,
353
568
  inheritedFromSessionId: profileInfo.inheritedFromSessionId,
354
569
  status: 'active',
355
- lastAction: 'browser_open',
570
+ lastAction: restoreUrl && restoreUrl !== 'about:blank' ? 'browser_restore' : 'browser_open',
356
571
  })
357
572
  } finally {
358
573
  if (pendingBrowserInitializations.get(sessionKey)) {
@@ -421,6 +636,105 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
421
636
  function: fn,
422
637
  })
423
638
 
639
+ const performSelectorDomAction = async (
640
+ action: 'click' | 'type' | 'select' | 'hover',
641
+ params: Record<string, unknown>,
642
+ ): Promise<{ ok: true; output: string } | { ok: false; error: string } | null> => {
643
+ const selector = pickNonEmptyBrowserString(params.element, params.selector)
644
+ if (!selector) return null
645
+ if (typeof params.ref === 'string' && params.ref.trim()) return null
646
+
647
+ const payload =
648
+ action === 'click'
649
+ ? `() => {
650
+ const selector = ${JSON.stringify(selector)};
651
+ try {
652
+ const element = document.querySelector(selector);
653
+ if (!element) return { ok: false, error: 'No element matched selector.' };
654
+ element.click?.();
655
+ return { ok: true, action: 'click', selector };
656
+ } catch (error) {
657
+ return { ok: false, error: String(error) };
658
+ }
659
+ }`
660
+ : action === 'hover'
661
+ ? `() => {
662
+ const selector = ${JSON.stringify(selector)};
663
+ try {
664
+ const element = document.querySelector(selector);
665
+ if (!element) return { ok: false, error: 'No element matched selector.' };
666
+ element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
667
+ element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
668
+ return { ok: true, action: 'hover', selector };
669
+ } catch (error) {
670
+ return { ok: false, error: String(error) };
671
+ }
672
+ }`
673
+ : action === 'type'
674
+ ? `() => {
675
+ const selector = ${JSON.stringify(selector)};
676
+ const value = ${JSON.stringify(String(params.text ?? params.value ?? ''))};
677
+ const submit = ${params.submit === true ? 'true' : 'false'};
678
+ try {
679
+ const element = document.querySelector(selector);
680
+ if (!element) return { ok: false, error: 'No element matched selector.' };
681
+ element.focus?.();
682
+ if ('value' in element) element.value = value;
683
+ else if (element.isContentEditable) element.textContent = value;
684
+ else return { ok: false, error: 'Matched element is not editable.' };
685
+ element.dispatchEvent(new Event('input', { bubbles: true }));
686
+ element.dispatchEvent(new Event('change', { bubbles: true }));
687
+ if (submit) {
688
+ if (element.form?.requestSubmit) element.form.requestSubmit();
689
+ else {
690
+ element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
691
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
692
+ }
693
+ }
694
+ return { ok: true, action: 'type', selector, valueLength: value.length };
695
+ } catch (error) {
696
+ return { ok: false, error: String(error) };
697
+ }
698
+ }`
699
+ : `() => {
700
+ const selector = ${JSON.stringify(selector)};
701
+ const desired = ${JSON.stringify(
702
+ Array.isArray(params.values)
703
+ ? params.values.map((value) => String(value ?? ''))
704
+ : [String(params.value ?? params.option ?? '')],
705
+ )};
706
+ try {
707
+ const element = document.querySelector(selector);
708
+ if (!element) return { ok: false, error: 'No element matched selector.' };
709
+ if (!(element instanceof HTMLSelectElement)) return { ok: false, error: 'Matched element is not a <select>.' };
710
+ const selected = [];
711
+ for (const option of Array.from(element.options)) {
712
+ const match = desired.includes(option.value) || desired.includes(option.text);
713
+ option.selected = match;
714
+ if (match) selected.push(option.value || option.text || '');
715
+ }
716
+ element.dispatchEvent(new Event('input', { bubbles: true }));
717
+ element.dispatchEvent(new Event('change', { bubbles: true }));
718
+ return { ok: true, action: 'select', selector, selected };
719
+ } catch (error) {
720
+ return { ok: false, error: String(error) };
721
+ }
722
+ }`
723
+
724
+ const raw = await callBrowserEvaluate(payload)
725
+ const parsed = extractJsonPayload(raw)
726
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
727
+ return { ok: false, error: cleanPlaywrightOutput(raw) || `DOM ${action} fallback failed.` }
728
+ }
729
+ if ((parsed as Record<string, unknown>).ok !== true) {
730
+ return {
731
+ ok: false,
732
+ error: String((parsed as Record<string, unknown>).error || `DOM ${action} fallback failed.`),
733
+ }
734
+ }
735
+ return { ok: true, output: stringifyStructured(parsed) }
736
+ }
737
+
424
738
  const captureStructuredObservation = async () => {
425
739
  const expression = `() => {
426
740
  const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
@@ -811,9 +1125,9 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
811
1125
  const fields = Array.isArray(params.fields)
812
1126
  ? params.fields
813
1127
  : (() => {
814
- const form = params.form
815
- if (!form || typeof form !== 'object' || Array.isArray(form)) return []
816
- return Object.entries(form as Record<string, unknown>).map(([key, value]) => {
1128
+ const form = params.form
1129
+ if (!form || typeof form !== 'object' || Array.isArray(form)) return []
1130
+ return Object.entries(form as Record<string, unknown>).map(([key, value]) => {
817
1131
  const escapedId = String(key).replace(/[^a-zA-Z0-9_-]/g, '')
818
1132
  const escapedAttr = String(key).replace(/["\\]/g, '\\$&')
819
1133
  const inferredType = typeof value === 'boolean'
@@ -838,11 +1152,43 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
838
1152
  const ref = typeof entry.ref === 'string' ? entry.ref : undefined
839
1153
  const element = typeof entry.element === 'string' ? entry.element : undefined
840
1154
  const fieldType = String(entry.type || 'text').toLowerCase()
841
- const value = entry.value
1155
+ const value = entry.value ?? entry.text
842
1156
  if (!ref && !element) continue
1157
+ const selectValues = Array.isArray(entry.values)
1158
+ ? entry.values.map((item) => String(item ?? ''))
1159
+ : Array.isArray(parseJsonArrayValue(entry.values ?? entry.option ?? value))
1160
+ ? (parseJsonArrayValue(entry.values ?? entry.option ?? value) as unknown[]).map((item) => String(item ?? ''))
1161
+ : [String(entry.option ?? value ?? '')]
1162
+ if (!ref && element) {
1163
+ if (fieldType === 'select') {
1164
+ const result = await performSelectorDomAction('select', { element, values: selectValues })
1165
+ if (!result) return { ok: false, error: 'Selector fallback failed for select field.' }
1166
+ if (!result.ok) return result
1167
+ } else if (fieldType === 'checkbox' || fieldType === 'radio') {
1168
+ if (value === true || value === 'true' || value === 'on' || value === 'checked') {
1169
+ const result = await performSelectorDomAction('click', { element })
1170
+ if (!result) return { ok: false, error: 'Selector fallback failed for checkbox field.' }
1171
+ if (!result.ok) return result
1172
+ }
1173
+ } else {
1174
+ const result = await performSelectorDomAction('type', {
1175
+ element,
1176
+ text: String(value ?? ''),
1177
+ submit: params.submit === true,
1178
+ })
1179
+ if (!result) return { ok: false, error: 'Selector fallback failed for input field.' }
1180
+ if (!result.ok) return result
1181
+ }
1182
+ filled.push({
1183
+ ref: null,
1184
+ element,
1185
+ type: fieldType,
1186
+ value: value ?? null,
1187
+ })
1188
+ continue
1189
+ }
843
1190
  if (fieldType === 'select') {
844
- const values = Array.isArray(value) ? value.map(String) : [String(value ?? '')]
845
- await callMcpTool('browser_select_option', { ref, element, values })
1191
+ await callMcpTool('browser_select_option', { ref, element, values: selectValues })
846
1192
  } else if (fieldType === 'checkbox' || fieldType === 'radio') {
847
1193
  if (value === true || value === 'true' || value === 'on' || value === 'checked') {
848
1194
  await callMcpTool('browser_click', { ref, element })
@@ -866,11 +1212,19 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
866
1212
  }
867
1213
 
868
1214
  const submitForm = async (params: Record<string, unknown>) => {
869
- if (typeof params.submitRef === 'string' || typeof params.submitElement === 'string') {
870
- await callMcpTool('browser_click', {
871
- ref: typeof params.submitRef === 'string' ? params.submitRef : undefined,
872
- element: typeof params.submitElement === 'string' ? params.submitElement : undefined,
873
- })
1215
+ const submitRef = pickNonEmptyBrowserString(params.submitRef)
1216
+ const submitElement = pickNonEmptyBrowserString(params.submitElement, params.selector, params.element)
1217
+ if (submitRef || submitElement) {
1218
+ if (submitRef) {
1219
+ await callMcpTool('browser_click', {
1220
+ ref: submitRef,
1221
+ element: submitElement,
1222
+ })
1223
+ } else if (submitElement) {
1224
+ const result = await performSelectorDomAction('click', { element: submitElement })
1225
+ if (!result) return { ok: false, error: 'submitElement is required for selector-based submit.' }
1226
+ if (!result.ok) return result
1227
+ }
874
1228
  } else {
875
1229
  await callBrowserEvaluate(`() => {
876
1230
  const form = document.forms[0];
@@ -1057,8 +1411,9 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1057
1411
  const completeWebTask = async (params: Record<string, unknown>) => {
1058
1412
  const steps: string[] = []
1059
1413
  if (typeof params.url === 'string' && params.url.trim()) {
1060
- await callMcpTool('browser_navigate', { url: params.url.trim() })
1061
- steps.push(`navigate:${params.url.trim()}`)
1414
+ const navigationTarget = resolveBrowserNavigationTarget(cwd, params.url.trim())
1415
+ await callMcpTool('browser_navigate', { url: navigationTarget })
1416
+ steps.push(`navigate:${navigationTarget}`)
1062
1417
  try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1063
1418
  }
1064
1419
 
@@ -1073,7 +1428,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1073
1428
  if (scroll.ok) initialPage = scroll.page
1074
1429
  }
1075
1430
 
1076
- if (Array.isArray(params.fields) && params.fields.length > 0) {
1431
+ if ((Array.isArray(params.fields) && params.fields.length > 0) || (params.form && typeof params.form === 'object' && !Array.isArray(params.form))) {
1077
1432
  const filled = await performFillForm(params)
1078
1433
  if (!filled.ok) return filled
1079
1434
  steps.push('fill_form')
@@ -1137,7 +1492,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1137
1492
  tools.push(
1138
1493
  tool(
1139
1494
  async (rawParams) => {
1140
- const params = normalizeToolInputArgs((rawParams ?? {}) as Record<string, unknown>)
1495
+ const params = normalizeBrowserActionParams((rawParams ?? {}) as Record<string, unknown>)
1141
1496
  try {
1142
1497
  const action = String(params.action || '').trim()
1143
1498
 
@@ -1175,9 +1530,9 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1175
1530
  }
1176
1531
 
1177
1532
  if (action === 'read_page') {
1178
- const url = typeof params.url === 'string' ? params.url : ''
1179
- if (url) {
1180
- await callMcpTool('browser_navigate', { url })
1533
+ const target = pickBrowserTargetFromParams(params)
1534
+ if (target) {
1535
+ await callMcpTool('browser_navigate', { url: resolveBrowserNavigationTarget(cwd, target) })
1181
1536
  try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1182
1537
  }
1183
1538
  return stringifyStructured(await captureStructuredObservation())
@@ -1252,6 +1607,18 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1252
1607
  if (v !== undefined && v !== null && v !== '') args[k] = v
1253
1608
  }
1254
1609
 
1610
+ if (action === 'navigate') {
1611
+ const target = pickBrowserTargetFromParams(params)
1612
+ if (!target) return 'Error: url or filePath is required for navigate.'
1613
+ args.url = resolveBrowserNavigationTarget(cwd, target)
1614
+ delete args.filePath
1615
+ delete args.path
1616
+ delete args.href
1617
+ delete args.link
1618
+ delete args.target
1619
+ delete args.page
1620
+ }
1621
+
1255
1622
  if (action === 'tabs') {
1256
1623
  args.action = typeof params.tabAction === 'string' ? params.tabAction : 'list'
1257
1624
  delete args.tabAction
@@ -1264,9 +1631,13 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1264
1631
  args.values = Array.isArray(args.option) ? args.option : [String(args.option)]
1265
1632
  delete args.option
1266
1633
  }
1634
+ if (action === 'select' && args.values === undefined && args.value !== undefined) {
1635
+ args.values = Array.isArray(args.value) ? args.value : [String(args.value)]
1636
+ delete args.value
1637
+ }
1267
1638
 
1268
1639
  if ((action === 'screenshot' || action === 'snapshot') && args.url) {
1269
- const navUrl = args.url
1640
+ const navUrl = resolveBrowserNavigationTarget(cwd, String(args.url))
1270
1641
  delete args.url
1271
1642
  await callMcpTool('browser_navigate', { url: navUrl })
1272
1643
  try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
@@ -1288,6 +1659,14 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1288
1659
  try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1289
1660
  }
1290
1661
 
1662
+ if (['click', 'hover', 'type', 'select'].includes(action) && !args.ref && typeof args.element === 'string') {
1663
+ const selectorResult = await performSelectorDomAction(action as 'click' | 'hover' | 'type' | 'select', args)
1664
+ if (!selectorResult) return `Error: ${action} requires a target element.`
1665
+ if (!selectorResult.ok) return `Error: ${selectorResult.error}`
1666
+ try { await captureStructuredObservation() } catch { /* ignore */ }
1667
+ return selectorResult.output
1668
+ }
1669
+
1291
1670
  let result = await callMcpTool(mcpTool, args, { saveTo: typeof params.saveTo === 'string' ? params.saveTo : undefined })
1292
1671
  if (action === 'navigate' && result.includes('ERR_ABORTED')) {
1293
1672
  await new Promise((r) => setTimeout(r, 1000))
@@ -1366,9 +1745,34 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
1366
1745
  const cmdArgs = (normalized.args ?? normalized.arguments) as string | undefined
1367
1746
  try {
1368
1747
  if (!command) return 'Error: command is required.'
1369
- const spawnArgs = ['browser', command, '--json']
1370
- if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
1371
- const result = spawnSync(openclawPath, spawnArgs, { encoding: 'utf-8', timeout: 60_000, maxBuffer: MAX_OUTPUT })
1748
+ const parsedArgs = cmdArgs ? cmdArgs.split(/\s+/).filter(Boolean) : []
1749
+ const runBrowserCommand = (browserCommand: string, browserArgs: string[]) => {
1750
+ const spawnArgs = ['browser', '--json', browserCommand, ...browserArgs]
1751
+ return spawnSync(openclawPath, spawnArgs, {
1752
+ encoding: 'utf-8',
1753
+ timeout: 60_000,
1754
+ maxBuffer: MAX_OUTPUT,
1755
+ })
1756
+ }
1757
+
1758
+ if (command === 'capture') {
1759
+ const outputs: string[] = []
1760
+ if (parsedArgs.length > 0) {
1761
+ const openResult = runBrowserCommand('open', parsedArgs)
1762
+ if (openResult.status !== 0) {
1763
+ return `Error (exit ${openResult.status}): ${openResult.stderr || openResult.stdout || 'unknown'}`
1764
+ }
1765
+ if (openResult.stdout?.trim()) outputs.push(openResult.stdout.trim())
1766
+ }
1767
+ const screenshotResult = runBrowserCommand('screenshot', [])
1768
+ if (screenshotResult.status !== 0) {
1769
+ return `Error (exit ${screenshotResult.status}): ${screenshotResult.stderr || screenshotResult.stdout || 'unknown'}`
1770
+ }
1771
+ if (screenshotResult.stdout?.trim()) outputs.push(screenshotResult.stdout.trim())
1772
+ return truncate(outputs.join('\n').trim() || '(no output)', MAX_OUTPUT)
1773
+ }
1774
+
1775
+ const result = runBrowserCommand(command, parsedArgs)
1372
1776
  if (result.status !== 0) return `Error (exit ${result.status}): ${result.stderr || result.stdout || 'unknown'}`
1373
1777
  return truncate(result.stdout || '(no output)', MAX_OUTPUT)
1374
1778
  } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }