dsh-inner-browser-use 0.18.0 → 0.27.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.
package/docs/BENCH.md CHANGED
@@ -232,3 +232,112 @@ Measured with `/tmp/probe-unseen.mjs` (tool layer, example.com, profile
232
232
  `probe-tool`): 1344 ms with nothing watching and no column asked for, 3236 ms in
233
233
  the first version of the same wait that did not skip the case where no column was
234
234
  ever requested — the whole difference was the wasted 1.2 s.
235
+
236
+ ## Phase 9 — finding the field behind the words, 0.19.0
237
+
238
+ A read of a `/bare` form (two div labels, two bare inputs, no aria anywhere on
239
+ the fields) and one `{field: "Work email"}` step:
240
+
241
+ | what | result |
242
+ | --- | --- |
243
+ | the click lands on the field, not on the div | yes, value `me@x.y` in `input[name=wk]` |
244
+ | the second, untouched field | still empty |
245
+ | the tool's own words | `typed into "Work email"` |
246
+ | worst case when nothing matches | one extra in-page query, then the old behaviour |
247
+
248
+ The query walks the open shadow roots (capped at 400) and, per visible editable
249
+ element, builds a list of candidate names from real labels, `aria-labelledby`,
250
+ `aria-label`, `placeholder`, `title`, `name`, `label[for]`, the element right
251
+ before it, and its wrapper's text — then scores exact (3) / contains (2) /
252
+ reverse-contains (1).
253
+
254
+ ## Phase 10 — typing by name, 0.20.0
255
+
256
+ | what the caller has | what it used to take | now |
257
+ | --- | --- | --- |
258
+ | a name for the field, no number | read the page, find the number, then type | one `sidecar_type` with `field`, one round trip |
259
+ | the field's words live in a div | click landed on the div, text went wherever focus was | resolved to the field, `typed into "Work email"` |
260
+ | a banner over the field | typed under the banner | banner cleared first, then typed |
261
+
262
+ Breadth check `type-by-words` on the `/bare` form: value lands in
263
+ `input[name=wk]`, the page does not move (no stray Enter).
264
+
265
+ ## Phase 11 — choosing a dropdown by name, 0.21.0
266
+
267
+ `/bare` now carries a `select[name=seats]` whose only name on screen is a div
268
+ saying `Seats`. One `sidecar_select` with `field: "Seats"`, `value: "Two"`:
269
+
270
+ | what | result |
271
+ | --- | --- |
272
+ | the value after the call | `2` |
273
+ | the tool's own words | `chose Two in Seats` (on-screen name, not `name="seats"`) |
274
+ | a wrong option | answered with the first 12 options instead of a page read |
275
+ | round trips to do it | one; no read beforehand, no read after |
276
+
277
+ The naming rules (`labelSpot` and `selectSpot`) now live in one shared in-page
278
+ block, so the two steps cannot drift apart.
279
+
280
+ ## Phase 12 — words split across nodes, 0.22.0
281
+
282
+ `/words` now carries `<button><span>Sign</span><span>in</span></button>`, which
283
+ the page reports as `Signin`.
284
+
285
+ | what | result |
286
+ | --- | --- |
287
+ | `{text: "Sign in"}` before | not found; caller had to fall back to a number |
288
+ | `{text: "Sign in"}` now | clicked, title changes, `ok click "Sign in"` |
289
+ | extra work | none: the same single control list, one comparison changed |
290
+
291
+ Exact equality still wins over the squeezed form, so `Sign in` never lands on
292
+ `Sign up` when both exist.
293
+
294
+ ## Phase 13 — the field behind the box, 0.23.0
295
+
296
+ `/bare` gains the date-picker shape: a visible rounded box with a transparent
297
+ `<input>` laid over it, its words in a div two levels up.
298
+
299
+ | what | before | now |
300
+ | --- | --- | --- |
301
+ | `{field: "Birthday"}` | not found, fell back to clicking the div | found the hidden input, clicked its visible box |
302
+ | value after one `sidecar_do` step | whatever already had focus | `2001-02-03` in `#bd`, `typed into "Birthday"` |
303
+ | extra round trips | read the page, guess a number | none |
304
+
305
+ The visible-box rule: a candidate is usable when it, or its nearest visible
306
+ ancestor, has a real box — so a 0-opacity input still counts if it covers
307
+ something the person can see.
308
+
309
+ ## Phase 15 — the answer is the picture, 0.25.0
310
+
311
+ `/pic` is one 140x90 PNG and nothing else.
312
+
313
+ | what | result |
314
+ | --- | --- |
315
+ | `sidecar_grab` | 6 ms, `picture-check.png`, 140x90 |
316
+ | before | three round trips: list the images, ask for the full address, fetch it |
317
+
318
+ A picture page has no text at all, so a read of it costs one line and no tokens;
319
+ the picture comes back as a file path the person can open.
320
+
321
+ ## Phase 16 — say what the picture looks like, 0.26.0
322
+
323
+ Two 140x90 PNGs: one a gradient, one a single colour.
324
+
325
+ | page | what `sidecar_grab` answers | time |
326
+ | --- | --- | --- |
327
+ | `/pic` | `... 140x90 a picture with detail` | 21 ms (first, page cold) |
328
+ | `/flat` | `... 140x90 mostly one colour` | 4 ms |
329
+
330
+ The sampling is 32x32 inside the page, so it costs no round trip and its result
331
+ travels in the same line as the path.
332
+
333
+ ## Phase 17 — the answer is a picture, in the read itself, 0.27.0
334
+
335
+ | page | read line | extra round trips |
336
+ | --- | --- | --- |
337
+ | `/pic` (gradient, no words) | `picture 140x90 a picture with detail "Chart" — sidecar_grab saves it as a file` | 0 |
338
+ | `/flat` (single colour) | `picture 140x90 mostly one colour "Chart" — …` | 0 |
339
+ | `/words` (text page) | no picture line | 0 |
340
+
341
+ The sampling is 32x32 inside the page, in the same call that reads the page, so a
342
+ blank chart is visible before anything is saved.
343
+
package/docs/ROADMAP.md CHANGED
@@ -174,3 +174,61 @@ Next in this direction: the drawing half could re-attach by itself. A tab that
174
174
  reloads re-subscribes on its own, but a tab that never loaded the half cannot be
175
175
  told to — that is a load-order problem, so the fix belongs in how the panel half
176
176
  is delivered, not in the wait.
177
+
178
+ ## Phase 10 — words are not where the field is, 0.19.0
179
+
180
+ The tool used to treat "the control that says Email" as "the field called
181
+ Email". Real forms separate the two, so the writing step now resolves the words
182
+ to the element that takes text, and says which one it used.
183
+
184
+ Next in this direction: the same resolution for `sidecar_type` when the caller
185
+ names a field by its words instead of a ref, and for date/time pickers where the
186
+ visible box is a div and the real input is hidden beside it.
187
+
188
+ ## Phase 11 — say where, not which number, 0.20.0
189
+
190
+ Typing by name is in. The same resolution is what other steps should be able to
191
+ use when the caller knows the destination but not the number — the next place
192
+ worth doing this is `sidecar_select` (choose an option by the field it belongs
193
+ to), and after that the date/time case where the visible box is a div and the
194
+ real input sits hidden beside it.
195
+
196
+ ## Phase 12 — the same words, the same place, 0.21.0
197
+
198
+ `sidecar_select` joined `sidecar_type` and `sidecar_do` on the shared resolver.
199
+ Left in this direction: the date/time case where the box the user sees is a div
200
+ and the real input is hidden beside it, and `sidecar_click` on a control whose
201
+ words are split across sibling nodes (`<span>Sign</span><span>in</span>`).
202
+
203
+ ## Phase 13 — the page's spelling is not the caller's, 0.22.0
204
+
205
+ Whitespace is normalised in both halves now (tool side and in-page side). Next
206
+ in this line: the date/time box where the visible box is a div and the real
207
+ input is hidden beside it, which needs the resolver to accept a hidden target
208
+ and click the thing in front of it.
209
+
210
+ ## Phase 14 — behind the box, 0.23.0
211
+
212
+ Both halves now resolve a name to a place: visible field, hidden field behind a
213
+ visible box, words split over nodes, words outside the wrapper. Next: a page
214
+ whose visible box is a `<canvas>` or an `<iframe>` — nothing in the tree to
215
+ name, so the fallback has to be a picture plus a click, not a name lookup.
216
+
217
+ ## Phase 16 — a file, not a path, 0.25.0
218
+
219
+ `sidecar_grab` returns a path. Next: say one line about the picture (size, and
220
+ whether it is mostly one colour) so a model that cannot open files still learns
221
+ "the chart is blank" without a full read.
222
+
223
+ ## Phase 17 — a picture nobody can see, 0.26.0
224
+
225
+ The description is coarse on purpose. Next: when a page's answer is a picture,
226
+ let a read say so in one line ("the answer is a picture, and it is blank") so a
227
+ model that cannot open files does not need the tool at all to notice a failure.
228
+
229
+ ## Phase 18 — a picture that never arrived, 0.27.0
230
+
231
+ A picture is only an answer if it loaded. Next: report a large picture element
232
+ whose source failed (`naturalWidth === 0`) as a broken picture rather than
233
+ staying silent, since that is the commonest way a generated image "disappears".
234
+
package/docs/VERIFY.md CHANGED
@@ -149,3 +149,17 @@ hand on the mirror: while the person is using it a read still works
149
149
  (`hold-refuses-action`), and `sidecar_resume` hands the page back so the same
150
150
  click goes through (`resume-lets-go`).
151
151
 
152
+ ## Who the mirror serves
153
+
154
+ The bar that matters most cannot be reached from `node --test`, because it is
155
+ about a column that belongs to one conversation. With DSH running:
156
+
157
+ ```sh
158
+ node scripts/live-safety.mjs http://127.0.0.1:49552
159
+ ```
160
+
161
+ It reads the state, then tries the two ways in — a frame stream and a
162
+ navigation — as a session that is not the owner. Both must answer 403, and the
163
+ owner's stream must answer 200. Three `true` lines is the bar; `open · owner
164
+ none` as the first line only means nothing is on screen, and the two refusals
165
+ still have to hold.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.18.0",
3
+ "version": "0.27.0",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -48,7 +48,10 @@
48
48
  "live": "node scripts/live.mjs"
49
49
  },
50
50
  "license": "MIT",
51
- "author": "regrevia",
51
+ "author": {
52
+ "name": "regrevia",
53
+ "url": "https://github.com/regrevia"
54
+ },
52
55
  "repository": {
53
56
  "type": "git",
54
57
  "url": "git+https://github.com/regrevia/dsh-inner-browser-use.git"
package/read.js CHANGED
@@ -43,6 +43,9 @@ const SELECTOR = [
43
43
  '[role="option"]',
44
44
  '[role="textbox"]',
45
45
  '[role="searchbox"]',
46
+ /* A drawing surface has no words in the tree, but it is the whole page in
47
+ some products: number it, and let a picture be taken of it. */
48
+ 'canvas',
46
49
  ].join(',')
47
50
 
48
51
  /** How a role is written in the compact read: short enough to cost little. */
@@ -133,6 +136,10 @@ function snapshot(mode, budget, max) {
133
136
  const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
134
137
  const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
135
138
  const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
139
+ if (el.tagName === 'CANVAS' && label === '' && own === '' && text === '' && value === '') {
140
+ const b = el.getBoundingClientRect()
141
+ return 'drawing ' + Math.round(b.width) + 'x' + Math.round(b.height)
142
+ }
136
143
  return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
137
144
  }
138
145
  const box = (el) => el.getBoundingClientRect()
@@ -240,6 +247,59 @@ function snapshot(mode, budget, max) {
240
247
  }
241
248
  }
242
249
  }
250
+ /* A page with almost no words and one big picture is answering with the
251
+ picture. Say so here, in the same round trip, so a model that cannot open
252
+ files still learns that something came back: and sample it small to tell a
253
+ blank chart from a chart. */
254
+ const picture = (() => {
255
+ if (full >= 40) return null
256
+ let best = null
257
+ const missing = []
258
+ for (const el of document.querySelectorAll('img,canvas')) {
259
+ const b = box(el)
260
+ if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
261
+ const s = getComputedStyle(el)
262
+ if (s.visibility === 'hidden' || s.display === 'none') continue
263
+ /* A picture slot that never got its picture is the commonest way a
264
+ generated image goes missing, and it is silent by default. */
265
+ if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
266
+ missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
267
+ continue
268
+ }
269
+ const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
270
+ const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
271
+ if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
272
+ }
273
+ if (best === null) return missing.length === 0 ? null : { missing }
274
+ /* What the read hands back about one picture: not a screen of fields. */
275
+ const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
276
+ said.kind = best.kind
277
+ said.width = best.w
278
+ said.height = best.h
279
+ said.colors = 0
280
+ said.flat = 0
281
+ try {
282
+ const board = document.createElement('canvas')
283
+ board.width = 32
284
+ board.height = 32
285
+ const pen = board.getContext('2d', { willReadFrequently: true })
286
+ if (pen === null) return said
287
+ pen.drawImage(best.el, 0, 0, 32, 32)
288
+ const dots = pen.getImageData(0, 0, 32, 32).data
289
+ const tally = new Map()
290
+ for (let at = 0; at < dots.length; at += 4) {
291
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
292
+ tally.set(key, (tally.get(key) || 0) + 1)
293
+ }
294
+ let most = 0
295
+ for (const count of tally.values()) most = Math.max(most, count)
296
+ said.colors = tally.size
297
+ said.flat = Math.round((most / 1024) * 100) / 100
298
+ } catch {
299
+ /* A picture from elsewhere cannot be sampled: report its size only. */
300
+ }
301
+ return said
302
+ })()
243
303
  return JSON.stringify({
244
304
  url: location.href, // ui-copy-ignore
245
305
  title: document.title, // ui-copy-ignore
@@ -252,6 +312,7 @@ function snapshot(mode, budget, max) {
252
312
  return { ref: reg.id(el), kind: role(el), name: name(el) }
253
313
  })(),
254
314
  controls: picked,
315
+ picture,
255
316
  })
256
317
  })()`
257
318
  }
@@ -278,6 +339,119 @@ export function resolve(ref) {
278
339
  })()`
279
340
  }
280
341
 
342
+ /**
343
+ * How big the control is and where it sits, so a picture can be of it alone.
344
+ * @param {number} ref - ref from a read.
345
+ * @returns {string} an expression that answers with the rectangle.
346
+ */
347
+ export function bounds(ref) {
348
+ return `(() => {
349
+ const reg = window.${REGISTRY}
350
+ if (!reg) return JSON.stringify({ gone: true })
351
+ const hit = reg.find(${Number(ref)})
352
+ if (!hit) return JSON.stringify({ gone: true })
353
+ const el = hit.el
354
+ if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
355
+ const b = el.getBoundingClientRect()
356
+ if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
357
+ return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
358
+ })()`
359
+ }
360
+
361
+ /**
362
+ * The pictures on the page, as files a person can open.
363
+ * A generated image or a chart is often the whole answer, and there is no
364
+ * wording in the tree that stands for it: take the picture itself.
365
+ * @param {number} [ref] - only pictures at or inside this control.
366
+ * @param {number} [count] - how many, largest first. Default 1.
367
+ * @returns {string} an expression that answers with `{pictures: [...]}`.
368
+ */
369
+ export function pictures(ref, count) {
370
+ const scope = Number(ref)
371
+ const limit = Math.max(1, Math.min(4, Number(count) || 1))
372
+ return `(async () => {
373
+ const reg = window.${REGISTRY}
374
+ const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
375
+ const picked = []
376
+ const seen = new Set()
377
+ const add = (el, kind) => {
378
+ if (el === null || el === undefined || seen.has(el)) return
379
+ seen.add(el)
380
+ const b = el.getBoundingClientRect()
381
+ const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
382
+ const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
383
+ if (w < 48 || h < 48) return
384
+ if (b.width < 8 || b.height < 8) return
385
+ const s = getComputedStyle(el)
386
+ if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
387
+ picked.push({ el, kind, w, h, area: w * h })
388
+ }
389
+ const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
390
+ for (const node of roots) {
391
+ if (node === document || node === where) continue
392
+ const tag = node.tagName
393
+ if (tag === 'IMG') add(node, 'img')
394
+ else if (tag === 'CANVAS') add(node, 'canvas')
395
+ }
396
+ if (where && where.tagName === 'IMG') add(where, 'img')
397
+ if (where && where.tagName === 'CANVAS') add(where, 'canvas')
398
+ picked.sort((a, b) => b.area - a.area)
399
+ /* A picture nobody can look at still has to be describable: how much of it is
400
+ one colour tells a blank chart from a chart. Sampled at 32x32, which is
401
+ enough for that and costs nothing. */
402
+ const look = async (data) => {
403
+ try {
404
+ const img = new Image()
405
+ img.src = data
406
+ await img.decode()
407
+ const board = document.createElement('canvas')
408
+ board.width = 32
409
+ board.height = 32
410
+ const pen = board.getContext('2d', { willReadFrequently: true })
411
+ if (pen === null) return null
412
+ pen.drawImage(img, 0, 0, 32, 32)
413
+ const dots = pen.getImageData(0, 0, 32, 32).data
414
+ const tally = new Map()
415
+ for (let at = 0; at < dots.length; at += 4) {
416
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
417
+ tally.set(key, (tally.get(key) || 0) + 1)
418
+ }
419
+ let most = 0
420
+ for (const count of tally.values()) most = Math.max(most, count)
421
+ return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
422
+ } catch {
423
+ return null
424
+ }
425
+ }
426
+ const out = []
427
+ for (const item of picked.slice(0, ${limit})) {
428
+ let data = ''
429
+ let type = 'image/png'
430
+ try {
431
+ if (item.kind === 'canvas') {
432
+ data = item.el.toDataURL('image/png')
433
+ } else {
434
+ const url = item.el.currentSrc || item.el.src || ''
435
+ const answer = await fetch(url, { credentials: 'include' })
436
+ const blob = await answer.blob()
437
+ type = blob.type || type
438
+ data = await new Promise((done, fail) => {
439
+ const reader = new FileReader()
440
+ reader.onload = () => done(String(reader.result))
441
+ reader.onerror = () => fail(new Error('unreadable'))
442
+ reader.readAsDataURL(blob)
443
+ })
444
+ }
445
+ } catch {
446
+ data = ''
447
+ }
448
+ const feel = data ? await look(data) : null
449
+ out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
450
+ }
451
+ return JSON.stringify({ pictures: out })
452
+ })()`
453
+ }
454
+
281
455
  /**
282
456
  * What sits on top of a control at click time, when it is not the control.
283
457
  * A cookie banner or a modal over the thing the agent meant to click is the
@@ -521,6 +695,7 @@ export async function readPage(page, options = {}) {
521
695
  title: String(read?.title ?? page.title),
522
696
  text: String(read?.text ?? ''),
523
697
  textChars: Number(read?.textChars ?? 0),
698
+ picture: read?.picture ?? null,
524
699
  scroll: read?.scroll,
525
700
  focused: read?.focused ?? null,
526
701
  controls: Array.isArray(read?.controls) ? read.controls : [],
@@ -591,6 +766,18 @@ export function renderPage(read, options = {}) {
591
766
  lines.push(body)
592
767
  if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
593
768
  }
769
+ if (read.picture && Array.isArray(read.picture.missing)) {
770
+ /* Something was meant to be here and is not: say that instead of nothing. */
771
+ for (const gone of read.picture.missing.slice(0, 2)) {
772
+ const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
773
+ lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
774
+ }
775
+ } else if (read.picture) {
776
+ const pic = read.picture
777
+ const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
778
+ const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
779
+ lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
780
+ }
594
781
  if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
595
782
  lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
596
783
  }
@@ -634,23 +821,8 @@ export function sameThing(want, control) {
634
821
  return true
635
822
  }
636
823
 
637
- /**
638
- * The field a label belongs to, when nothing formally connects the two.
639
- *
640
- * `{field: "Email"}` used to mean "click the control that says Email". On a real
641
- * form the words live in a div above a bare input, so the click landed on the
642
- * text and the typing went wherever focus already was — the classic silent wrong
643
- * field. This finds the *editable* element the words point at: a real label, an
644
- * aria name, a placeholder, a name attribute, the text right before it, or the
645
- * text of the small wrapper it sits in.
646
- * @param {string} words - what the field is called.
647
- * @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
648
- */
649
- export function labelSpot(words) {
650
- const wanted = JSON.stringify(String(words ?? '').toLowerCase())
651
- return `(() => {
652
- const want = ${wanted}
653
- if (want === '') return JSON.stringify({})
824
+ /** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
825
+ const NAME_PRELUDE = `
654
826
  const roots = [document]
655
827
  for (const el of document.querySelectorAll('*')) {
656
828
  if (roots.length > 400) break
@@ -685,34 +857,125 @@ export function labelSpot(words) {
685
857
  add(el.getAttribute('aria-label'))
686
858
  add(el.getAttribute('placeholder'))
687
859
  add(el.getAttribute('title'))
688
- add(el.getAttribute('name'))
689
860
  if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
690
861
  const prev = el.previousElementSibling
691
862
  if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
692
863
  const parent = el.parentElement
693
864
  if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
865
+ /* Wrapped a couple of levels deep (a styled box hiding a real input), the
866
+ words sit before the wrapper, not before the input. */
867
+ let up = parent
868
+ for (let depth = 0; depth < 3 && up; depth += 1) {
869
+ const before = up.previousElementSibling
870
+ if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
871
+ up = up.parentElement
872
+ }
873
+ /* The attribute is the last resort for a name on screen: it is often the
874
+ developer's word for the field, not the one the user reads. */
875
+ add(el.getAttribute('name'))
694
876
  return out
695
877
  }
696
- let best = null
697
- for (const root of roots) {
698
- for (const el of root.querySelectorAll('input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]')) {
699
- if (editable(el) !== true || shown(el) !== true) continue
700
- let score = 0
701
- let name = ''
702
- for (const nm of names(el)) {
703
- const low = nm.toLowerCase()
704
- if (low === want) score = Math.max(score, 3)
705
- else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
706
- else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
707
- if (score > 0 && name === '') name = nm
878
+ const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
879
+ const scored = (el, want) => {
880
+ let score = 0
881
+ let name = ''
882
+ const flat = squeeze(want)
883
+ for (const nm of names(el)) {
884
+ const low = nm.toLowerCase()
885
+ if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
886
+ else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
887
+ else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
888
+ else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
889
+ if (score > 0 && name === '') name = nm
890
+ }
891
+ return { score, name }
892
+ }
893
+ const boxOf = (el) => {
894
+ for (let node = el; node; node = node.parentElement) {
895
+ const b = node.getBoundingClientRect()
896
+ if (b.width < 8 || b.height < 8) continue
897
+ const s = getComputedStyle(node)
898
+ if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
899
+ }
900
+ return null
901
+ }
902
+ const bestOf = (selector, want, hiddenOk) => {
903
+ let best = null
904
+ for (const root of roots) {
905
+ for (const el of root.querySelectorAll(selector)) {
906
+ if (hiddenOk !== true && shown(el) !== true) continue
907
+ const hit = scored(el, want)
908
+ if (hit.score === 0) continue
909
+ if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
708
910
  }
709
- if (score === 0) continue
710
- if (best === null || score > best.score) best = { el, score, name }
711
911
  }
912
+ return best
913
+ }
914
+ `
915
+
916
+ /**
917
+ * The field a label belongs to, when nothing formally connects the two.
918
+ * ...
919
+ */
920
+ /**
921
+ * The field a label belongs to, when nothing formally connects the two.
922
+ *
923
+ * `{field: "Email"}` used to mean "click the control that says Email". On a real
924
+ * form the words live in a div above a bare input, so the click landed on the
925
+ * text and the typing went wherever focus already was — the classic silent wrong
926
+ * field. This finds the *editable* element the words point at: a real label, an
927
+ * aria name, a placeholder, a name attribute, the text right before it, or the
928
+ * text of the small wrapper it sits in.
929
+ * @param {string} words - what the field is called.
930
+ * @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
931
+ */
932
+ export function labelSpot(words) {
933
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
934
+ return `(() => {
935
+ const want = ${wanted}
936
+ if (want === '') return JSON.stringify({})
937
+ ${NAME_PRELUDE}
938
+ const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
939
+ let best = bestOf(selector, want)
940
+ let box = best === null ? null : boxOf(best.el)
941
+ let behind = false
942
+ if (best === null || box === null) {
943
+ /* The field a date picker shows is often a transparent box over a styled
944
+ div: the input stays out of sight, but the words still name it. */
945
+ const under = bestOf(selector, want, true)
946
+ const place = under === null ? null : boxOf(under.el)
947
+ if (under !== null && place !== null) { best = under; box = place; behind = true }
712
948
  }
713
- if (best === null) return JSON.stringify({})
949
+ if (best === null || box === null) return JSON.stringify({})
950
+ if (editable(best.el) !== true) return JSON.stringify({})
714
951
  best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
715
- const b = best.el.getBoundingClientRect()
716
- return JSON.stringify({ x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2), name: best.name, score: best.score })
952
+ const fresh = boxOf(best.el) ?? box
953
+ return JSON.stringify({ x: Math.round(fresh.left + fresh.width / 2), y: Math.round(fresh.top + fresh.height / 2), name: best.name, score: best.score, behind })
954
+ })()`
955
+ }
956
+
957
+ /**
958
+ * The dropdown the words belong to, with the wanted option already chosen.
959
+ * @param {string} words - what the dropdown is called on screen.
960
+ * @param {string} value - option label or value.
961
+ * @returns {string} an expression that picks the option and says what it did.
962
+ */
963
+ export function selectSpot(words, value) {
964
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
965
+ const wanted2 = JSON.stringify(String(value ?? ''))
966
+ return `(() => {
967
+ const want = ${wanted}
968
+ const wanted = ${wanted2}
969
+ if (want === '') return 'no dropdown was named'
970
+ ${NAME_PRELUDE}
971
+ const best = bestOf('select', want)
972
+ if (best === null) return 'no dropdown says ' + JSON.stringify(want)
973
+ const select = best.el
974
+ const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
975
+ if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
976
+ select.value = option.value
977
+ select.dispatchEvent(new Event('input', { bubbles: true }))
978
+ select.dispatchEvent(new Event('change', { bubbles: true }))
979
+ return 'chose ' + option.text.trim() + ' in ' + best.name
717
980
  })()`
718
981
  }
package/tools.js CHANGED
@@ -13,7 +13,7 @@ import { homedir } from 'node:os'
13
13
  import { join } from 'node:path'
14
14
  import { mkdir, writeFile } from 'node:fs/promises'
15
15
  import { VIEW } from './sidecar.js?v=1'
16
- import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, REF_GONE } from './read.js?v=1'
16
+ import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, selectSpot, bounds, pictures, REF_GONE } from './read.js?v=1'
17
17
 
18
18
  /** Where a screenshot from `sidecar_shot` lands, under the user's home. */
19
19
  export const SHOT_DIR = join('.dsh-inner-browser-use', 'screenshots')
@@ -419,9 +419,15 @@ async function until(page, expression, timeout) {
419
419
  async function findWords(page, words) {
420
420
  const read = await readPage(page, { mode: 'text', maxChars: 1200, max: 200 })
421
421
  const shown = read.controls.filter((control) => control.view !== false)
422
- const wanted = words.toLowerCase()
423
- const hit = shown.find((control) => control.name.toLowerCase() === wanted) ?? shown.find((control) => control.name.toLowerCase().includes(wanted))
424
- if (hit === undefined) return null
422
+ /* The words may sit in separate inline nodes: <span>Sign</span><span>in</span>
423
+ reads as "Signin" to the page, so compare with the spaces taken out too. */
424
+ const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
425
+ const wanted = squeeze(words)
426
+ const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
427
+ if (found === undefined) return null
428
+ /* When only the squeezed form matched, say the words the caller used: the
429
+ page spread them over nodes and "Signin" is not what anyone wrote. */
430
+ const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
425
431
  const spot = await locate(page, hit.ref)
426
432
  if (spot.gone === true) return null
427
433
  return { ...hit, x: Number(spot.x), y: Number(spot.y) }
@@ -438,12 +444,50 @@ const EDITABLE_KINDS = ['input', 'textarea', 'select', 'combobox', 'textbox', 's
438
444
  */
439
445
  async function fieldSpot(page, words) {
440
446
  const said = await evaluate(page, labelSpot(words))
441
- if (said.error !== undefined || said.result === undefined) return null
442
- const spot = said.result
443
- if (spot === undefined || spot === null || spot.name === undefined) return null
447
+ if (said === undefined || said === null || said.error !== undefined) return null
448
+ const spot = typeof said === 'string' ? JSON.parse(said) : said
449
+ if (spot === null || spot.name === undefined || spot.name === '') return null
444
450
  return { x: Number(spot.x), y: Number(spot.y), name: String(spot.name) }
445
451
  }
446
452
 
453
+ /**
454
+ * Where the field called these words is, clearing a banner over it first.
455
+ * @param {any} page - sidecar page.
456
+ * @param {string} words - what the field is called on screen.
457
+ * @returns {Promise<{x: number, y: number, name: string, lead: string}|null>} the click.
458
+ */
459
+ async function fieldByName(page, words) {
460
+ const hit = await findWords(page, words)
461
+ const lead = hit === null ? '' : await coverLead(page, hit.ref)
462
+ if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
463
+ const spot = await locate(page, hit.ref)
464
+ if (spot.gone !== true) return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
465
+ }
466
+ const field = await fieldSpot(page, words)
467
+ if (field !== null) return { ...field, lead }
468
+ if (hit === null) return null
469
+ const spot = await locate(page, hit.ref)
470
+ if (spot.gone === true) return null
471
+ return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
472
+ }
473
+
474
+ /**
475
+ * Click a spot, type into it, and optionally clear it first.
476
+ * @param {any} page - sidecar page.
477
+ * @param {{x: number, y: number}} point - where to click.
478
+ * @param {string} text - what to type.
479
+ * @param {{clear?: boolean, submit?: boolean}} options - how.
480
+ */
481
+ async function writeAt(page, point, text, options = {}) {
482
+ await page.mouse('mouseMoved', point)
483
+ await page.mouse('mousePressed', { ...point, clickCount: 1 })
484
+ await page.mouse('mouseReleased', { ...point, clickCount: 1 })
485
+ await wait(80)
486
+ if (options.clear === true) await clearFocus(page)
487
+ await page.insertText(text)
488
+ if (options.submit === true) await pressKey(page, 'Enter')
489
+ }
490
+
447
491
  /**
448
492
  * Where the control behind a ref is now, following it across a re-render.
449
493
  * The module-level sibling of findAgain, for runStep, which has no closure.
@@ -502,40 +546,10 @@ async function runStep(page, step, known) {
502
546
  if (step.field !== undefined) {
503
547
  /* The words may sit on the field or in a div above a bare input. Only a
504
548
  click that lands on the field itself can be trusted with the typing. */
505
- const hit = await findWords(page, String(step.field))
506
- let lead = ''
507
- let point = null
508
- let called = ''
509
- if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
510
- lead = await coverLead(page, hit.ref)
511
- const spot = await locate(page, hit.ref)
512
- if (spot.gone !== true) {
513
- point = { x: Number(spot.x), y: Number(spot.y) }
514
- called = String(hit.name)
515
- }
516
- }
517
- if (point === null) {
518
- const field = await fieldSpot(page, String(step.field))
519
- if (field !== null) {
520
- point = { x: field.x, y: field.y }
521
- called = field.name
522
- }
523
- }
524
- if (point === null) {
525
- if (hit === null) return `no control says ${JSON.stringify(String(step.field))}`
526
- const spot = await locate(page, hit.ref)
527
- if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
528
- point = { x: Number(spot.x), y: Number(spot.y) }
529
- called = String(hit.name)
530
- }
531
- await page.mouse('mouseMoved', point)
532
- await page.mouse('mousePressed', { ...point, clickCount: 1 })
533
- await page.mouse('mouseReleased', { ...point, clickCount: 1 })
534
- await wait(80)
535
- if (step.clear === true) await clearFocus(page)
536
- await page.insertText(String(step.text ?? ''))
537
- if (step.submit === true) await pressKey(page, 'Enter')
538
- return `${lead}typed into "${called.slice(0, 60)}"`
549
+ const target = await fieldByName(page, String(step.field))
550
+ if (target === null) return `${String(step.field)}: ${REF_GONE}`
551
+ await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
552
+ return `${target.lead}typed into "${target.name.slice(0, 60)}"`
539
553
  }
540
554
  if (step.tap !== undefined) {
541
555
  const [x, y] = String(step.tap).split(',').map(Number)
@@ -893,6 +907,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
893
907
  properties: {
894
908
  text: { type: 'string', description: 'Text to type.' },
895
909
  ref: { type: 'integer', description: 'Control to click before typing.' },
910
+ field: { type: 'string', description: 'The field is called this on screen, when you have no number for it.' },
896
911
  submit: { type: 'boolean', description: 'Press Enter after typing.' },
897
912
  clear: { type: 'boolean', description: 'Select all and delete before typing.' },
898
913
  read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
@@ -907,6 +922,15 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
907
922
  execute: async (args) => withPage(async (page) => {
908
923
  let cover = ''
909
924
  let followed = ''
925
+ if (args?.field !== undefined && args?.ref === undefined) {
926
+ const target = await fieldByName(page, String(args.field))
927
+ if (target === null) return `${String(args.field)}: ${REF_GONE}`
928
+ cover = target.lead
929
+ await writeAt(page, { x: target.x, y: target.y }, String(args?.text ?? ''), { clear: args?.clear, submit: args?.submit === true })
930
+ await settle(page)
931
+ const called = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
932
+ return called + cover + ` (typed into "${target.name.slice(0, 50)}")` // ui-copy-ignore
933
+ }
910
934
  if (args?.ref !== undefined) {
911
935
  const gate = await clearCover(page, Number(args.ref))
912
936
  cover = gate.note
@@ -1163,9 +1187,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1163
1187
  type: 'object', // ui-copy-ignore
1164
1188
  properties: {
1165
1189
  ref: { type: 'integer', description: 'Number of the select control from sidecar_view.' },
1190
+ field: { type: 'string', description: 'The dropdown is called this on screen, when you have no number for it.' },
1166
1191
  value: { type: 'string', description: 'Option label or value to choose.' },
1167
1192
  },
1168
- required: ['ref', 'value'], // ui-copy-ignore
1193
+ required: ['value'], // ui-copy-ignore
1169
1194
  additionalProperties: false,
1170
1195
  },
1171
1196
  output: TEXT_OUTPUT, // ui-copy-ignore
@@ -1173,6 +1198,15 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1173
1198
  isConcurrencySafe: () => false,
1174
1199
  /** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
1175
1200
  execute: async (args) => withPage(async (page) => {
1201
+ if (args?.ref === undefined) {
1202
+ if (args?.field === undefined) return 'name the dropdown: pass its number from sidecar_view, or the words on screen that name it.' // ui-copy-ignore
1203
+ const chosen = await evaluate(page, selectSpot(String(args.field), String(args?.value ?? '')))
1204
+ const said = typeof chosen === 'string' ? chosen : chosen?.error === undefined ? '' : String(chosen.error)
1205
+ if (said === '' || said.startsWith('no dropdown')) return said === '' ? 'the page did not answer; read it and try again.' : said // ui-copy-ignore
1206
+ await settle(page, 3000)
1207
+ if (args?.read === true) return `${said}\n${await look(page, { mode: 'text', maxChars: 2000 })}`
1208
+ return `${said} · ${await brief(page)}`
1209
+ }
1176
1210
  const spot = await findAgain(page, Number(args?.ref))
1177
1211
  if (spot.gone === true) return REF_GONE
1178
1212
  const wanted = JSON.stringify(String(args?.value ?? ''))
@@ -1253,7 +1287,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1253
1287
  if (args?.ref !== undefined) {
1254
1288
  const spot = await findAgain(page, Number(args.ref))
1255
1289
  if (spot.gone === true) return REF_GONE
1256
- clip = { x: Math.max(0, Number(spot.x) - 20), y: Math.max(0, Number(spot.y) - 20), width: 220, height: 140 }
1290
+ /* The picture is of the control, not of a guess around its middle:
1291
+ a canvas can be most of the page, a checkbox a few pixels. */
1292
+ const box = await evaluate(page, bounds(Number(args.ref)))
1293
+ const rect = typeof box === 'string' ? JSON.parse(box) : box
1294
+ if (rect?.gone === true) return REF_GONE
1295
+ const pad = 8
1296
+ clip = {
1297
+ x: Math.max(0, Math.round(Number(rect.x) - pad)),
1298
+ y: Math.max(0, Math.round(Number(rect.y) - pad)),
1299
+ width: Math.max(8, Math.round(Number(rect.width) + pad * 2)),
1300
+ height: Math.max(8, Math.round(Number(rect.height) + pad * 2)),
1301
+ }
1257
1302
  }
1258
1303
  const data = await page.shot({ full: args?.full === true, rect: clip, scale })
1259
1304
  const dir = join(homedir(), SHOT_DIR)
@@ -1264,6 +1309,58 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
1264
1309
  return file
1265
1310
  }),
1266
1311
  },
1312
+ {
1313
+ name: 'sidecar_grab', // ui-copy-ignore
1314
+ description: 'Save the picture on the page as a file: an image, a chart, a drawing surface. Picks the largest one, or the one inside a given control.',
1315
+ parameters: {
1316
+ type: 'object', // ui-copy-ignore
1317
+ properties: {
1318
+ ref: { type: 'integer', description: 'Only the picture at or inside this control.' },
1319
+ name: { type: 'string', description: 'File name without extension.' },
1320
+ count: { type: 'integer', description: 'How many, largest first. Default 1, at most 4.' },
1321
+ },
1322
+ additionalProperties: false,
1323
+ },
1324
+ output: TEXT_OUTPUT, // ui-copy-ignore
1325
+ timeoutMs: 40_000,
1326
+ isConcurrencySafe: () => false,
1327
+ /** @param {any} args - arguments. @returns {Promise<string>} the saved paths, one per line. */
1328
+ execute: async (args) => withRead(async (page) => {
1329
+ const said = await evaluate(page, pictures(args?.ref, args?.count))
1330
+ let found
1331
+ try {
1332
+ found = JSON.parse(typeof said === 'string' ? said : JSON.stringify(said))
1333
+ } catch {
1334
+ found = { pictures: [] }
1335
+ }
1336
+ const rows = []
1337
+ const dir = join(homedir(), SHOT_DIR)
1338
+ await mkdir(dir, { recursive: true })
1339
+ const base = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-picture-${Date.now()}`
1340
+ const list = Array.isArray(found?.pictures) ? found.pictures : []
1341
+ for (let index = 0; index < list.length; index += 1) {
1342
+ const item = list[index]
1343
+ const body = String(item?.data ?? '')
1344
+ const mark = body.indexOf(',')
1345
+ if (mark < 0) continue
1346
+ const type = String(item?.type ?? 'image/png')
1347
+ const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
1348
+ const file = join(dir, `${base}${list.length > 1 ? `-${index + 1}` : ''}.${ext}`)
1349
+ await writeFile(file, Buffer.from(body.slice(mark + 1), 'base64'))
1350
+ const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
1351
+ /* Say what the picture looks like in words: a model that cannot open
1352
+ a file still needs to know a chart came back blank. */
1353
+ const flat = Number(item.flat) || 0
1354
+ const colors = Number(item.colors) || 0
1355
+ const look = flat >= 0.9 ? 'mostly one colour' : colors <= 3 ? 'a few colours' : 'a picture with detail'
1356
+ rows.push(`${file} ${size} ${look}`)
1357
+ }
1358
+ if (rows.length === 0) {
1359
+ return list.length === 0 ? 'There is no picture on the page.' : 'That picture could not be saved.'
1360
+ }
1361
+ return rows.join('\n')
1362
+ }),
1363
+ },
1267
1364
  {
1268
1365
  name: 'sidecar_state', // ui-copy-ignore
1269
1366
  description: 'Report the sidecar browser: whether it runs, its profile, address, title, how many people are watching its mirror, and how many times a call had to find a control again after the page redrew it.',