dsh-builtin-browser 0.2.0 → 0.3.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 (34) hide show
  1. package/README.en.md +46 -1
  2. package/README.md +58 -7
  3. package/client.js +44 -8
  4. package/desktop-bridge/install.mjs +98 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  6. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  7. package/lib/browser-electron/bridge-connection.js +133 -0
  8. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  9. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  10. package/lib/browser-electron/entry.js +82 -13
  11. package/lib/browser-electron/history-store.d.ts +5 -1
  12. package/lib/browser-electron/history-store.js +12 -1
  13. package/lib/browser-electron/provider.d.ts +42 -1
  14. package/lib/browser-electron/provider.js +96 -24
  15. package/lib/browser-electron/remote-host.d.ts +9 -1
  16. package/lib/browser-electron/remote-host.js +17 -3
  17. package/lib/browser-electron/settings-store.d.ts +5 -0
  18. package/lib/browser-electron/settings-store.js +24 -1
  19. package/lib/browser-electron/system-browser.d.ts +100 -0
  20. package/lib/browser-electron/system-browser.js +322 -0
  21. package/lib/browser-electron/virtual-cursor.d.ts +27 -2
  22. package/lib/browser-electron/virtual-cursor.js +77 -7
  23. package/lib/tool-browser/index.js +51 -15
  24. package/package.json +2 -1
  25. package/src/browser-electron/bridge-connection.ts +145 -0
  26. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  27. package/src/browser-electron/entry.ts +84 -13
  28. package/src/browser-electron/history-store.ts +11 -2
  29. package/src/browser-electron/provider.ts +124 -25
  30. package/src/browser-electron/remote-host.ts +17 -2
  31. package/src/browser-electron/settings-store.ts +30 -1
  32. package/src/browser-electron/system-browser.ts +318 -0
  33. package/src/browser-electron/virtual-cursor.ts +76 -6
  34. package/src/tool-browser/index.ts +51 -15
@@ -22,15 +22,46 @@ export type CursorAction = 'move' | 'click'
22
22
  /** Element id of the overlay root, stable so repeat calls update one node. */
23
23
  const CURSOR_ID = '__dsh_agent_cursor__'
24
24
 
25
+ /**
26
+ * Where the overlay was last placed, so an unchanged position is not repainted.
27
+ *
28
+ * A single tool call issues several CDP commands (locate, click, cursor), and the
29
+ * cursor is repainted for each one that moves it. Repainting to the same pixel is a
30
+ * wasted round-trip *and* a visible flicker: the transition restarts from the same
31
+ * place, which reads as a stutter rather than as movement.
32
+ */
33
+ const lastPlaced = new WeakMap<object, { x: number; y: number; action: CursorAction }>()
34
+
35
+ /**
36
+ * Whether a paint at this position would change anything on screen.
37
+ * @param handle - the view the overlay belongs to.
38
+ * @param x - viewport x in CSS pixels.
39
+ * @param y - viewport y in CSS pixels.
40
+ * @param action - the action being reported.
41
+ * @param force - paint even when unchanged (a click ripple must always fire).
42
+ * @returns true when the overlay should be painted.
43
+ */
44
+ export function cursorNeedsPaint<TView>(handle: TView, x: number, y: number, action: CursorAction, force = false): boolean {
45
+ if (force) return true
46
+ const key = handle as unknown as object
47
+ if (key === null || typeof key !== 'object') return true
48
+ const previous = lastPlaced.get(key)
49
+ return previous === undefined || previous.x !== x || previous.y !== y || previous.action !== action
50
+ }
51
+
25
52
  /**
26
53
  * The in-page expression that materializes or moves the overlay. Idempotent: the
27
54
  * first call creates the nodes, later calls only move them (and pulse).
28
55
  * @param x - viewport x in CSS pixels.
29
56
  * @param y - viewport y in CSS pixels.
30
57
  * @param action - whether to pulse a click ripple at the point.
58
+ * @param label - optional short description of the operation, shown beside the
59
+ * pointer. The overlay is the only "the agent has taken over this tab" signal, so
60
+ * saying *what* it is doing is what turns it from a decoration into an account of
61
+ * the agent's actions that a human can follow without reading the tool log.
31
62
  * @returns a self-contained expression that evaluates to `true`.
32
63
  */
33
- export function cursorScript(x: number, y: number, action: CursorAction): string {
64
+ export function cursorScript(x: number, y: number, action: CursorAction, label?: string): string {
34
65
  return `(() => {
35
66
  const id = ${JSON.stringify(CURSOR_ID)}
36
67
  let root = document.getElementById(id)
@@ -38,25 +69,56 @@ export function cursorScript(x: number, y: number, action: CursorAction): string
38
69
  root = document.createElement('div')
39
70
  root.id = id
40
71
  root.setAttribute('aria-hidden', 'true')
41
- root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
72
+ root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 190ms cubic-bezier(.22,.85,.24,1);will-change:transform'
42
73
  const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
43
74
  + '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
44
- const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
45
- root.innerHTML = svg + ripple
75
+ const ripple = '<span data-dsh-role="ripple" style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
76
+ // The bubble sits below-right of the pointer tip and is capped in width so a long
77
+ // label cannot cover the page it is describing.
78
+ const bubble = '<span data-dsh-role="bubble" style="position:absolute;left:18px;top:16px;max-width:280px;padding:3px 8px;border-radius:5px;background:rgba(17,17,17,.92);color:#fff;font:12px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;box-shadow:0 1px 3px rgba(0,0,0,.4)"></span>'
79
+ root.innerHTML = svg + ripple + bubble
46
80
  ;(document.body || document.documentElement).appendChild(root)
47
81
  }
48
82
  root.style.transform = 'translate(${x}px, ${y}px)'
49
- ${action === 'click' ? `const ripple = root.lastElementChild
83
+ ${action === 'click' ? `const ripple = root.querySelector('[data-dsh-role="ripple"]')
50
84
  if (ripple && typeof ripple.animate === 'function') {
51
85
  ripple.animate(
52
86
  [{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
53
87
  { duration: 550, easing: 'ease-out' },
54
88
  )
55
89
  }` : ''}
90
+ ${label !== undefined && label !== '' ? `const bubble = root.querySelector('[data-dsh-role="bubble"]')
91
+ if (bubble) {
92
+ bubble.textContent = ${JSON.stringify(label)}
93
+ if (typeof bubble.animate === 'function') {
94
+ // Fade in, hold, fade out: the label is a narration of the action, not a
95
+ // permanent caption, so a stale one never lingers over later work.
96
+ bubble.animate(
97
+ [{ opacity: 0, transform: 'translateY(-3px)' }, { opacity: 1, transform: 'translateY(0)' }, { opacity: 1, offset: 0.6 }, { opacity: 0 }],
98
+ { duration: 2200, easing: 'ease-out', fill: 'forwards' },
99
+ )
100
+ } else {
101
+ bubble.style.opacity = '1'
102
+ }
103
+ }` : ''}
56
104
  return true
57
105
  })()`
58
106
  }
59
107
 
108
+ /**
109
+ * Forget the remembered position for a view.
110
+ *
111
+ * Called when the document is replaced: the overlay lived in the old document and
112
+ * went with it, so the cache would otherwise claim the pointer is already where it
113
+ * needs to be and skip every later paint — the pointer would simply never return
114
+ * after a navigation.
115
+ * @param handle - the view whose cache should be dropped.
116
+ */
117
+ export function forgetCursor<TView>(handle: TView): void {
118
+ const key = handle as unknown as object
119
+ if (key !== null && typeof key === 'object') lastPlaced.delete(key)
120
+ }
121
+
60
122
  /**
61
123
  * Draw (or move) the pointer overlay on one view's page.
62
124
  *
@@ -69,6 +131,7 @@ export function cursorScript(x: number, y: number, action: CursorAction): string
69
131
  * @param y - viewport y in CSS pixels.
70
132
  * @param action - whether to pulse a click ripple.
71
133
  * @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
134
+ * @param label - optional short description of the operation, shown beside the pointer.
72
135
  */
73
136
  export function paintCursor<TView>(
74
137
  handle: TView,
@@ -76,7 +139,14 @@ export function paintCursor<TView>(
76
139
  y: number,
77
140
  action: CursorAction,
78
141
  evaluate: (handle: TView, expression: string) => Promise<unknown>,
142
+ label?: string,
143
+ force = false,
79
144
  ): void {
80
145
  if (!Number.isFinite(x) || !Number.isFinite(y)) return
81
- void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => {})
146
+ const roundedX = Math.round(x)
147
+ const roundedY = Math.round(y)
148
+ if (!cursorNeedsPaint(handle, roundedX, roundedY, action, force)) return
149
+ const key = handle as unknown as object
150
+ if (key !== null && typeof key === 'object') lastPlaced.set(key, { x: roundedX, y: roundedY, action })
151
+ void evaluate(handle, cursorScript(roundedX, roundedY, action, label)).catch(() => {})
82
152
  }
@@ -161,15 +161,26 @@ function parseFillValue(v: string | undefined): string | number | boolean {
161
161
  return v ?? ''
162
162
  }
163
163
 
164
- /** Format a snapshot element list for the model. */
164
+ /**
165
+ * Format a snapshot element list for the model.
166
+ *
167
+ * Coordinates are opt-in. They are only meaningful to a caller that intends to
168
+ * click a pixel position, which means a vision pass; for the far more common case
169
+ * of locating an element by role/label and passing a semantic target, they are the
170
+ * bulkiest and least useful part of every line. Omitting them keeps the listing
171
+ * readable — which is exactly what a model without image input has to work from.
172
+ * @param snapshot - the snapshot payload.
173
+ * @param options - `coords: true` to include each element's viewport position.
174
+ */
165
175
  function formatSnapshot(snapshot: {
166
176
  url: string
167
177
  title?: string
168
178
  elements: readonly { ref: number; kind: string; label: string; x: number; y: number; frame?: boolean }[]
169
179
  truncated?: boolean
170
180
  challenge?: { blocked: boolean; kind?: string; reason?: string }
171
- }): string {
172
- const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''} (${el.x},${el.y})`)
181
+ }, options: { coords?: boolean } = {}): string {
182
+ const showCoords = options.coords === true
183
+ const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`)
173
184
  const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`
174
185
  const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)'
175
186
  const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : ''
@@ -241,7 +252,10 @@ export function apply(ctx: Context, config: Config = {}): void {
241
252
  },
242
253
  },
243
254
  },
244
- render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
255
+ // browser_open is an overview of what just loaded, not a locator: it has no
256
+ // `coords` parameter, so coordinates stay off here (browser_snapshot owns
257
+ // that switch for callers that actually target pixels).
258
+ render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: (args as { coords?: boolean }).coords === true }) }],
245
259
  },
246
260
  timeoutMs,
247
261
  isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
@@ -267,8 +281,10 @@ export function apply(ctx: Context, config: Config = {}): void {
267
281
 
268
282
  ctx.tools.register(defineTool({
269
283
  name: 'browser_snapshot',
270
- description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. Use this to understand an interactive page before driving it.',
271
- parameters: {},
284
+ description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. This is the primary way to understand a page, and it needs no image input — every listed element can afterwards be addressed by a semantic target (browser_click, browser_type, browser_check, …) rather than by coordinates.',
285
+ parameters: {
286
+ coords: { type: 'boolean', description: 'Also print each element\'s viewport coordinates. Off by default; only useful when a vision pass is going to click a pixel position.' },
287
+ },
272
288
  output: {
273
289
  schema: {
274
290
  type: 'object',
@@ -304,7 +320,7 @@ export function apply(ctx: Context, config: Config = {}): void {
304
320
  },
305
321
  },
306
322
  },
307
- render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
323
+ render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
308
324
  },
309
325
  timeoutMs,
310
326
  isConcurrencySafe: () => true,
@@ -329,6 +345,7 @@ export function apply(ctx: Context, config: Config = {}): void {
329
345
  parameters: {
330
346
  includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
331
347
  maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
348
+ coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
332
349
  },
333
350
  output: {
334
351
  schema: {
@@ -361,14 +378,24 @@ export function apply(ctx: Context, config: Config = {}): void {
361
378
  },
362
379
  },
363
380
  },
364
- render: (_args, value) => {
381
+ render: (args, value) => {
365
382
  const nodes = value.nodes as Array<{ ref: number; role: string; name: string; value?: string | null; states: string[]; depth: number; tag: string; x: number; y: number; frame?: boolean }>
383
+ // Built for reading, not for imaging: containment is shown as indentation
384
+ // (the depth was already collected and simply thrown away), an empty state
385
+ // list adds nothing, and coordinates appear only when asked for — a model
386
+ // working from the DOM never needs them, and they are the bulkiest part of
387
+ // every line.
388
+ const showCoords = args.coords === true
366
389
  const lines = nodes.map(n => {
367
390
  const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : ''
368
- return `[${n.ref}] ${n.role} "${n.name}"${valuePart} (${n.x},${n.y}) states=[${n.states.join(',')}]${n.frame === true ? ' (iframe)' : ''}`
391
+ const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)))
392
+ const coordsPart = showCoords ? ` (${n.x},${n.y})` : ''
393
+ const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : ''
394
+ return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`
369
395
  })
370
396
  const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`
371
- return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}` }]
397
+ const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)'
398
+ return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }]
372
399
  },
373
400
  },
374
401
  timeoutMs,
@@ -601,7 +628,7 @@ export function apply(ctx: Context, config: Config = {}): void {
601
628
 
602
629
  ctx.tools.register(defineTool({
603
630
  name: 'browser_click',
604
- description: 'Click in the shared browser. Two ways: (1) semantic target — pass target {by: css|text|xpath, value, index?} and the element is located, scrolled into view and clicked at its center; (2) viewport coordinates (x/y) — use with browser_screenshot when a vision model locates an element on the screenshot (covers icons, image buttons, and canvas that DOM locators cannot target). Provide exactly one of target or x/y.',
631
+ description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
605
632
  parameters: {
606
633
  target: {
607
634
  type: 'object',
@@ -613,8 +640,8 @@ export function apply(ctx: Context, config: Config = {}): void {
613
640
  },
614
641
  description: 'Locate the element semantically and click it (css/text/xpath).',
615
642
  },
616
- x: { type: 'number', description: 'Viewport x coordinate (CSS px), e.g. from a vision model reading the screenshot.' },
617
- y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
643
+ x: { type: 'number', description: 'Viewport x coordinate (CSS px), taken from a browser_screenshot read by a vision model. Prefer a semantic target — it works without image input.' },
644
+ y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
618
645
  },
619
646
  output: {
620
647
  schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
@@ -1089,7 +1116,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1089
1116
 
1090
1117
  ctx.tools.register(defineTool({
1091
1118
  name: 'browser_screenshot',
1092
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1119
+ description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1093
1120
  parameters: {
1094
1121
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1095
1122
  savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
@@ -1338,6 +1365,8 @@ export function apply(ctx: Context, config: Config = {}): void {
1338
1365
  parameters: {
1339
1366
  limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
1340
1367
  domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
1368
+ query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
1369
+ session: { type: 'string', description: 'Only visits recorded by this task/session label — use it to separate your own pages from another session\'s or the human\'s.' },
1341
1370
  },
1342
1371
  output: {
1343
1372
  schema: {
@@ -1384,7 +1413,14 @@ export function apply(ctx: Context, config: Config = {}): void {
1384
1413
  const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
1385
1414
  const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
1386
1415
  const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
1387
- const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} })
1416
+ const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined
1417
+ const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined
1418
+ const entries = browser.visited({
1419
+ limit,
1420
+ ...domain !== undefined ? { domain } : {},
1421
+ ...query !== undefined ? { query } : {},
1422
+ ...session !== undefined ? { session } : {},
1423
+ })
1388
1424
  return {
1389
1425
  count: entries.length,
1390
1426
  entries: entries.map(entry => ({