dsh-inner-browser-use 0.13.0 → 0.18.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/read.js ADDED
@@ -0,0 +1,718 @@
1
+ /**
2
+ * The read model: what the page is, said in as few tokens as the truth allows.
3
+ *
4
+ * Two rules shape this file.
5
+ *
6
+ * 1. A number means the same control twice. Playwright MCP and browser-use both
7
+ * hand out refs, and both pay for it by making the model re-read after every
8
+ * action. Here the page keeps a registry, so a ref survives a read, a
9
+ * checkbox and a tool boundary; only a navigation drops it, and a click on a
10
+ * dead ref says so instead of clicking something else.
11
+ *
12
+ * 2. Text is read from the viewport, not the document. A 60-paragraph article
13
+ * is 2.4k tokens if the whole page is printed. The person is looking at a
14
+ * screenful, the model is acting on a screenful, and scrolling is a call it
15
+ * can make on purpose.
16
+ */
17
+
18
+ /** Name of the page-side registry. */
19
+ const REGISTRY = '__dshRead'
20
+
21
+ /** Bumped when the page-side half changes, so a stale page is re-seeded. */
22
+ const VERSION = 2
23
+
24
+ /** Interactive elements worth numbering, in the order a reader would meet them. */
25
+ const SELECTOR = [
26
+ 'a[href]',
27
+ 'button',
28
+ 'input:not([type="hidden"])',
29
+ 'select',
30
+ 'textarea',
31
+ 'summary',
32
+ 'label[for]',
33
+ '[contenteditable="true"]',
34
+ '[tabindex]:not([tabindex="-1"])',
35
+ '[role="button"]',
36
+ '[role="link"]',
37
+ '[role="tab"]',
38
+ '[role="checkbox"]',
39
+ '[role="radio"]',
40
+ '[role="switch"]',
41
+ '[role="combobox"]',
42
+ '[role="menuitem"]',
43
+ '[role="option"]',
44
+ '[role="textbox"]',
45
+ '[role="searchbox"]',
46
+ ].join(',')
47
+
48
+ /** How a role is written in the compact read: short enough to cost little. */
49
+ const SIGIL = { // ui-copy-ignore
50
+ a: 'link', // ui-copy-ignore
51
+ button: 'btn', // ui-copy-ignore
52
+ input: 'input', // ui-copy-ignore
53
+ select: 'select', // ui-copy-ignore
54
+ textarea: 'textarea', // ui-copy-ignore
55
+ summary: 'summary', // ui-copy-ignore
56
+ label: 'label', // ui-copy-ignore
57
+ role: 'role', // ui-copy-ignore
58
+ }
59
+
60
+ /**
61
+ * The page-side half. Installed on every read; it is a no-op when the registry
62
+ * is already there, which is what makes refs survive reads.
63
+ * @param {string} selector - interactive selector.
64
+ * @param {number} version - registry version.
65
+ * @returns {string} an expression to evaluate.
66
+ */
67
+ function seed(selector, version) {
68
+ return `(() => {
69
+ const w = window
70
+ const current = w.${REGISTRY}
71
+ if (current && current.version === ${version}) return 'kept'
72
+ const refs = new Map()
73
+ const ids = new WeakMap()
74
+ let seq = 0
75
+ w.${REGISTRY} = {
76
+ version: ${version},
77
+ refs,
78
+ /**
79
+ * Give an element the number it already has, or the next free one.
80
+ * @param {Element} el - element to number.
81
+ * @returns {number} its ref.
82
+ */
83
+ id(el) {
84
+ const known = ids.get(el)
85
+ if (known !== undefined && refs.get(known) === el) return known
86
+ seq += 1
87
+ ids.set(el, seq)
88
+ refs.set(seq, el)
89
+ return seq
90
+ },
91
+ /** @param {Element} el - element to forget. */
92
+ drop(el) {
93
+ const known = ids.get(el)
94
+ if (known !== undefined) refs.delete(known)
95
+ },
96
+ /**
97
+ * @param {number} ref - number from a read.
98
+ * @returns {{el: Element, ref: number} | null} the element, if still here.
99
+ */
100
+ find(ref) {
101
+ const el = refs.get(ref)
102
+ if (!el || !el.isConnected) { refs.delete(ref); return null }
103
+ return { el, ref }
104
+ },
105
+ selector: ${JSON.stringify(selector)},
106
+ }
107
+ return 'seeded'
108
+ })()`
109
+ }
110
+
111
+ /**
112
+ * Read the page. One evaluate, one JSON, no round trip per control.
113
+ * @param {string} mode - what to include: text, links, forms, full.
114
+ * @param {number} budget - characters of page text to keep.
115
+ * @param {number} max - controls to number at most.
116
+ * @returns {string} an expression to evaluate.
117
+ */
118
+ function snapshot(mode, budget, max) {
119
+ const wanted = JSON.stringify(mode)
120
+ return `(() => {
121
+ const reg = window.${REGISTRY}
122
+ if (!reg) return JSON.stringify({ broken: true })
123
+ const mode = ${wanted}
124
+ const budget = ${budget}
125
+ const max = ${max}
126
+ const name = (el) => {
127
+ const label = el.labels && el.labels[0] ? String(el.labels[0].innerText ?? '') : ''
128
+ const pointing = String(el.getAttribute?.('aria-labelledby') ?? '')
129
+ const named = pointing.split(/\s+/).filter(Boolean).map((id) => {
130
+ const other = el.ownerDocument.getElementById(id)
131
+ return other === null ? '' : String(other.innerText ?? other.textContent ?? '')
132
+ }).join(' ').replace(/\s+/g, ' ').trim()
133
+ const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
134
+ const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
135
+ const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
136
+ return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
137
+ }
138
+ const box = (el) => el.getBoundingClientRect()
139
+ const shown = (el) => {
140
+ const b = box(el)
141
+ if (b.width < 2 || b.height < 2) return false
142
+ const s = getComputedStyle(el)
143
+ if (s.visibility === 'hidden' || s.display === 'none') return false
144
+ if (Number(s.opacity) <= 0.05) return false
145
+ return true
146
+ }
147
+ const inView = (b) => b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth
148
+ /* Web components keep their controls in a shadow root, where
149
+ querySelectorAll on the document never looks. Open roots are collected
150
+ first, so a control inside one is numbered like any other. */
151
+ const roots = [document]
152
+ for (let i = 0; i < roots.length; i += 1) {
153
+ for (const el of roots[i].querySelectorAll('*')) {
154
+ if (el.shadowRoot) roots.push(el.shadowRoot)
155
+ }
156
+ }
157
+ const each = (fn) => {
158
+ for (const root of roots) for (const el of root.querySelectorAll(reg.selector)) fn(el)
159
+ }
160
+ const role = (el) => {
161
+ const explicit = el.getAttribute?.('role')
162
+ if (explicit) return explicit
163
+ const tag = el.tagName.toLowerCase()
164
+ if (tag === 'a') return 'link'
165
+ if (tag === 'summary') return 'button'
166
+ if (tag === 'input') {
167
+ const type = String(el.type ?? 'text')
168
+ if (type === 'checkbox' || type === 'radio' || type === 'submit' || type === 'button' || type === 'search') return type
169
+ return 'input'
170
+ }
171
+ return tag
172
+ }
173
+ const controls = []
174
+ const seen = new Set()
175
+ each((el) => {
176
+ if (controls.length >= max * 3) return
177
+ const b = box(el)
178
+ if (b.width < 2 || b.height < 2) return
179
+ if (!shown(el)) return
180
+ const label = name(el)
181
+ if (label === '' && el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return
182
+ const kind = role(el)
183
+ if (mode === 'links' && kind !== 'link') return
184
+ if (mode === 'forms' && kind !== 'input' && kind !== 'select' && kind !== 'textarea' && kind !== 'btn' && kind !== 'button' && kind !== 'combobox' && kind !== 'checkbox' && kind !== 'radio') return
185
+ const view = inView(b)
186
+ /* Offscreen controls are numbered but sorted last: the person is acting on
187
+ what is on screen, and a scroll should not renumber everything. */
188
+ controls.push({
189
+ ref: reg.id(el),
190
+ kind,
191
+ name: label,
192
+ value: el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '').slice(0, 80) : '',
193
+ checked: el.checked === true,
194
+ disabled: el.disabled === true || el.getAttribute?.('aria-disabled') === 'true',
195
+ view,
196
+ x: Math.round(b.left + b.width / 2),
197
+ y: Math.round(b.top + b.height / 2),
198
+ href: kind === 'link' ? String(el.href ?? '').slice(0, 120) : '',
199
+ })
200
+ seen.add(el)
201
+ })
202
+ /* Refs that pointed at elements now gone are dropped, so a later number
203
+ cannot quietly mean a different control. */
204
+ for (const ref of Array.from(reg.refs.keys())) {
205
+ const el = reg.refs.get(ref)
206
+ if (!el || !el.isConnected) reg.refs.delete(ref)
207
+ }
208
+ controls.sort((a, b) => Number(b.view) - Number(a.view))
209
+ /* Only the first screenful and a bit is worth a line each: an offscreen
210
+ control is a scroll away, and a hundred dead rows cost the reader tokens. */
211
+ const picked = controls.slice(0, max)
212
+ const parts = []
213
+ let used = 0
214
+ let full = 0
215
+ const take = (text) => {
216
+ if (text === '') return
217
+ full += text.length
218
+ if (used >= budget) return
219
+ const room = budget - used
220
+ parts.push(room >= text.length ? text : text.slice(0, room))
221
+ used += Math.min(room, text.length)
222
+ }
223
+ const main = document.querySelector('main, article, [role="main"]') ?? document.body
224
+ const texts = [main, ...roots.slice(1)]
225
+ if (mode !== 'links' && mode !== 'forms') {
226
+ const emitted = new Set()
227
+ for (const root of texts) {
228
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
229
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
230
+ const text = String(node.nodeValue ?? '').replace(/\\s+/g, ' ').trim()
231
+ const parent = node.parentElement
232
+ if (text === '' || !parent || emitted.has(parent)) continue
233
+ const b = box(parent)
234
+ const style = getComputedStyle(parent)
235
+ const fits = style.display !== 'none' && style.visibility !== 'hidden' && b.width > 1 && b.height > 1
236
+ if (!fits || !inView(b)) continue
237
+ emitted.add(parent)
238
+ take(text)
239
+ if (parent.tagName === 'P' || parent.tagName === 'DIV' || parent.tagName === 'LI' || /^H[1-6]$/.test(parent.tagName)) take('\\n')
240
+ }
241
+ }
242
+ }
243
+ return JSON.stringify({
244
+ url: location.href, // ui-copy-ignore
245
+ title: document.title, // ui-copy-ignore
246
+ text: parts.join(' ').replace(/\\n /g, '\\n').replace(/ {2,}/g, ' ').trim(),
247
+ textChars: full,
248
+ scroll: { y: Math.round(scrollY), height: Math.round(document.documentElement.scrollHeight), viewport: Math.round(innerHeight) },
249
+ focused: (() => { // ui-copy-ignore
250
+ const el = document.activeElement
251
+ if (!el || el === document.body) return null
252
+ return { ref: reg.id(el), kind: role(el), name: name(el) }
253
+ })(),
254
+ controls: picked,
255
+ })
256
+ })()`
257
+ }
258
+
259
+ /**
260
+ * Resolve a ref at action time, so a page that moved under the last read is
261
+ * clicked where the control is now, not where it was.
262
+ * @param {number} ref - ref from a read.
263
+ * @returns {string} an expression to evaluate.
264
+ */
265
+ export function resolve(ref) {
266
+ return `(() => {
267
+ const reg = window.${REGISTRY}
268
+ if (!reg) return JSON.stringify({ gone: true })
269
+ const hit = reg.find(${Number(ref)})
270
+ if (!hit) return JSON.stringify({ gone: true })
271
+ const el = hit.el
272
+ const b = el.getBoundingClientRect()
273
+ if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
274
+ if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' })
275
+ const after = el.getBoundingClientRect()
276
+ const s = getComputedStyle(el)
277
+ return JSON.stringify({ gone: false, x: Math.round(after.left + after.width / 2), y: Math.round(after.top + after.height / 2), name: String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 80), kind: String(el.tagName).toLowerCase(), hidden: s.visibility === 'hidden' || s.display === 'none', disabled: el.disabled === true })
278
+ })()`
279
+ }
280
+
281
+ /**
282
+ * What sits on top of a control at click time, when it is not the control.
283
+ * A cookie banner or a modal over the thing the agent meant to click is the
284
+ * commonest quiet failure on real pages: the click lands, nothing happens.
285
+ * @param {number} ref - ref from a read.
286
+ * @returns {string} an expression that answers `{tag, text, dismiss}` or an empty string.
287
+ */
288
+ export function covering(ref) {
289
+ return `(() => {
290
+ const reg = window.${REGISTRY}
291
+ if (!reg) return ''
292
+ const hit = reg.find(${Number(ref)})
293
+ if (!hit) return ''
294
+ const el = hit.el
295
+ const b = el.getBoundingClientRect()
296
+ const x = Math.round(b.left + b.width / 2)
297
+ const y = Math.round(b.top + b.height / 2)
298
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return ''
299
+ const top = document.elementFromPoint(x, y)
300
+ if (top === null || top === el || el.contains(top) || top.contains(el)) return ''
301
+ const raw = String(top.innerText ?? top.value ?? top.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 80)
302
+ /* The thing in the way usually carries its own way out, and taking it is the
303
+ step the agent would take next anyway. Find that control while the overlay
304
+ is already being measured, so clearing it costs no extra round trip. The
305
+ words are a narrow safe list on purpose: an overlay that offers to delete,
306
+ pay or confirm is reported, never dismissed on the person's behalf. */
307
+ let scope = top
308
+ for (let node = top.parentElement; node !== null && node !== document.body; node = node.parentElement) {
309
+ const r = node.getBoundingClientRect()
310
+ if (r.width >= innerWidth * 0.6 && r.height >= innerHeight * 0.5) scope = node
311
+ }
312
+ const box = scope.getBoundingClientRect()
313
+ const wide = box.width >= innerWidth * 0.6 && box.height >= innerHeight * 0.5
314
+ const claimed = String(scope.innerText ?? '').replace(/\\s+/g, ' ').slice(0, 400)
315
+ const risky = /(delete|remove|purchase|payment|pay now|buy now|sign out|log out|transfer|删除|支付|购买|付款|注销|退出登录|转账)/i.test(claimed)
316
+ const words = /^(accept|accept all|agree|allow|allow all|ok|okay|got it|close|dismiss|i agree|接受|同意|允许|允许全部|关闭|确定|知道了|好的)$/i
317
+ let dismiss = null
318
+ if (wide && !risky) {
319
+ const cands = scope.querySelectorAll('button,[role=button],a,input[type=button],input[type=submit]')
320
+ for (const node of [scope, ...cands]) {
321
+ if (node === el || el.contains(node) || node.contains(el)) continue
322
+ const name = String(node.innerText ?? node.value ?? node.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 40)
323
+ if (name === '' || !words.test(name)) continue
324
+ const r = node.getBoundingClientRect()
325
+ if (r.width < 2 || r.height < 2) continue
326
+ if (r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue
327
+ dismiss = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name }
328
+ break
329
+ }
330
+ }
331
+ return JSON.stringify({ tag: String(top.tagName).toLowerCase(), text: raw, dismiss })
332
+ })()`
333
+ }
334
+
335
+ /** Where refs of controls inside iframes start. Main-frame refs stay small. */
336
+ const FRAME_BASE = 900_000
337
+
338
+ /** Room for the refs of one frame between FRAME_BASE steps. */
339
+ const FRAME_SLOT = 100_000
340
+
341
+ /** A frame is addressed either by its world or by the session of its own process. */
342
+ function asRoute(route) {
343
+ if (route === undefined || route === null) return {}
344
+ if (typeof route === 'number') return { contextId: route }
345
+ return route
346
+ }
347
+
348
+ /**
349
+ * Evaluate in a page, in one frame of it, or in the process of a frame Chrome
350
+ * keeps apart.
351
+ * @param {any} page - sidecar page.
352
+ * @param {string} expression - JavaScript.
353
+ * @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
354
+ * @returns {Promise<any>} the JSON-safe value.
355
+ */
356
+ async function evaluateIn(page, expression, route) {
357
+ const where = asRoute(route)
358
+ const params = { expression, returnByValue: true }
359
+ if (where.contextId !== undefined) params.contextId = where.contextId
360
+ const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
361
+ return result?.value
362
+ }
363
+
364
+ /** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
365
+ async function seedPage(page, route) {
366
+ await evaluateIn(page, seed(SELECTOR, VERSION), route)
367
+ }
368
+
369
+ /**
370
+ * @param {any} page - sidecar page.
371
+ * @param {string} mode - read mode.
372
+ * @param {number} budget - text budget.
373
+ * @param {number} max - control cap.
374
+ * @param {any} [route] - world to read.
375
+ * @returns {Promise<any>} the snapshot.
376
+ */
377
+ async function evaluateSnapshot(page, mode, budget, max, route) {
378
+ const raw = String((await evaluateIn(page, snapshot(mode, budget, max), route)) ?? '{}')
379
+ try {
380
+ return JSON.parse(raw)
381
+ } catch {
382
+ return { broken: true }
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Where an iframe sits on the page. A ref inside a frame is resolved against
388
+ * the frame's own viewport, so its point needs the frame's corner added.
389
+ * @param {any} page - sidecar page.
390
+ * @param {{slot?: number, contextId?: number, sessionId?: string}} route - the frame.
391
+ * @returns {Promise<{x: number, y: number}>} the offset.
392
+ */
393
+ async function frameOffset(page, route) {
394
+ const where = asRoute(route)
395
+ if (where.contextId !== undefined) {
396
+ const own = await evaluateIn(page, `(() => { try {
397
+ const frame = window.frameElement
398
+ if (!frame) return null
399
+ const b = frame.getBoundingClientRect()
400
+ return { x: Math.round(b.left), y: Math.round(b.top) }
401
+ } catch (error) { return null } })()`, where)
402
+ if (own !== null && own !== undefined && Number.isFinite(own.x)) return own
403
+ }
404
+ if (where.frameId !== undefined && typeof page.frameBox === 'function') {
405
+ const own = await page.frameBox(where.frameId).catch(() => null)
406
+ if (own !== null) return own
407
+ }
408
+ const index = Math.max(0, Number(where.slot ?? 1) - 1)
409
+ const fallback = await evaluateIn(page, `(() => {
410
+ const frame = document.querySelectorAll('iframe')[${index}]
411
+ if (!frame) return null
412
+ const b = frame.getBoundingClientRect()
413
+ return { x: Math.round(b.left), y: Math.round(b.top) }
414
+ })()`)
415
+ if (fallback !== null && fallback !== undefined && Number.isFinite(fallback.x)) return fallback
416
+ return { x: 0, y: 0 }
417
+ }
418
+
419
+ /**
420
+ * Where a frame's refs live: its slot in the frame tree, and how to reach it —
421
+ * by world when it shares the page's process, by session when Chrome runs it
422
+ * apart.
423
+ * @param {any} page - sidecar page.
424
+ * @param {any} frame - one entry from the frame list.
425
+ * @param {number} slot - its position under the main frame.
426
+ * @returns {{slot: number, contextId?: number, sessionId?: string, frameId?: string}|null} the route.
427
+ */
428
+ function routeOf(page, frame, slot) {
429
+ const sessionId = frame.contextId === undefined && typeof page.childSession === 'function' ? page.childSession(frame.frameId) : undefined
430
+ if (frame.contextId === undefined && sessionId === undefined) return null
431
+ return { slot, contextId: frame.contextId, sessionId, frameId: frame.frameId }
432
+ }
433
+
434
+ /**
435
+ * Read the frames under the page, so a control inside an iframe is numbered
436
+ * like any other. A frame Chrome runs in its own process is read through the
437
+ * session of that process; the corner it sits at still comes from the page.
438
+ * @param {any} page - sidecar page.
439
+ * @param {string} mode - read mode.
440
+ * @param {number} budget - characters of text to keep.
441
+ * @returns {Promise<{text: string, controls: any[]}>} what the frames add.
442
+ */
443
+ async function readFrames(page, mode, budget) {
444
+ if (typeof page.frameList !== 'function') return { text: '', controls: [] }
445
+ let frames = []
446
+ try {
447
+ frames = await page.frameList()
448
+ } catch (error) {
449
+ return { text: '', controls: [] }
450
+ }
451
+ const children = frames.slice(1)
452
+ const controls = []
453
+ let text = ''
454
+ for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
455
+ const route = routeOf(page, children[slot], slot + 1)
456
+ if (route === null) continue
457
+ try {
458
+ await seedPage(page, route)
459
+ const inside = await evaluateSnapshot(page, mode, Math.min(budget, 600), 40, route)
460
+ if (inside?.broken === true) continue
461
+ const offset = await frameOffset(page, route)
462
+ const tag = FRAME_BASE + slot * FRAME_SLOT
463
+ for (const item of inside.controls ?? []) {
464
+ controls.push({ ...item, ref: tag + Number(item.ref), frame: slot + 1, x: Number(item.x ?? 0) + offset.x, y: Number(item.y ?? 0) + offset.y })
465
+ }
466
+ if (text.length < 400 && typeof inside.text === 'string' && inside.text !== '') text += `${text === '' ? '' : '\n'}${inside.text.slice(0, 400)}`
467
+ } catch (error) {
468
+ continue
469
+ }
470
+ }
471
+ return { text, controls }
472
+ }
473
+
474
+ /** Decode a ref into the frame it lives in and the number that frame knows. */
475
+ function splitFrameRef(ref) {
476
+ const number = Number(ref)
477
+ if (!Number.isFinite(number) || number < FRAME_BASE) return null
478
+ const slot = Math.floor((number - FRAME_BASE) / FRAME_SLOT)
479
+ return { slot, local: number - FRAME_BASE - slot * FRAME_SLOT }
480
+ }
481
+
482
+ /**
483
+ * Where a ref lives, for a tool that has to look the element up itself.
484
+ * @param {any} page - sidecar page.
485
+ * @param {number} ref - ref from a read.
486
+ * @returns {Promise<{slot: number, local: number, contextId?: number, sessionId?: string, frameId?: string}|null>} the route, or nothing for the page itself.
487
+ */
488
+ export async function contextFor(page, ref) {
489
+ const split = splitFrameRef(ref)
490
+ if (split === null) return null
491
+ if (typeof page.frameList !== 'function') return null
492
+ const frames = await page.frameList().catch(() => [])
493
+ const frame = frames[split.slot + 1]
494
+ if (frame === undefined) return null
495
+ const route = routeOf(page, frame, split.slot + 1)
496
+ if (route === null) return null
497
+ return { ...route, local: split.local }
498
+ }
499
+
500
+ /**
501
+ * Read the page and hand back the shape the renderer wants.
502
+ * @param {any} page - sidecar page.
503
+ * @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
504
+ * @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
505
+ */
506
+ export async function readPage(page, options = {}) {
507
+ const mode = String(options.mode ?? 'text')
508
+ const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
509
+ const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
510
+ await seedPage(page)
511
+ let read = await evaluateSnapshot(page, mode, maxChars, max)
512
+ if (read?.broken === true) {
513
+ await seedPage(page)
514
+ read = await evaluateSnapshot(page, mode, maxChars, max)
515
+ }
516
+ const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
517
+ if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
518
+ if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
519
+ return {
520
+ url: String(read?.url ?? page.url),
521
+ title: String(read?.title ?? page.title),
522
+ text: String(read?.text ?? ''),
523
+ textChars: Number(read?.textChars ?? 0),
524
+ scroll: read?.scroll,
525
+ focused: read?.focused ?? null,
526
+ controls: Array.isArray(read?.controls) ? read.controls : [],
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Resolve a ref into a point, at the moment of the action.
532
+ * @param {any} page - sidecar page.
533
+ * @param {number} ref - ref from a read.
534
+ * @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
535
+ */
536
+ export async function locate(page, ref) {
537
+ const split = splitFrameRef(ref)
538
+ let route = null
539
+ if (split !== null) {
540
+ route = await contextFor(page, ref)
541
+ if (route === null) return { gone: true }
542
+ }
543
+ const said = await evaluateIn(page, resolve(split === null ? ref : route.local), route).catch(() => undefined)
544
+ let spot = { gone: true }
545
+ try {
546
+ spot = JSON.parse(String(said ?? '{"gone":true}'))
547
+ } catch (error) {
548
+ spot = { gone: true }
549
+ }
550
+ if (split === null || spot.gone === true) return spot
551
+ const offset = await frameOffset(page, route)
552
+ return { ...spot, x: Number(spot.x ?? 0) + offset.x, y: Number(spot.y ?? 0) + offset.y }
553
+ }
554
+
555
+ /** Roles that read better without a sigil. */
556
+ function sigilOf(control) {
557
+ const kind = String(control.kind ?? '')
558
+ if (kind === 'link') return 'link'
559
+ if (kind === 'button') return 'btn'
560
+ if (kind === 'input' || kind === 'search') return 'input'
561
+ if (kind === 'checkbox' || kind === 'radio') return kind
562
+ return SIGIL[kind] ?? kind ?? 'el'
563
+ }
564
+
565
+ /**
566
+ * Render a read as compact text, controls first, then the viewport's words.
567
+ * @param {{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}} read - page read.
568
+ * @param {{mode?: string, maxChars?: number}} [options] - how it was read.
569
+ * @returns {string} the text a model sees.
570
+ */
571
+ export function renderPage(read, options = {}) {
572
+ const mode = String(options.mode ?? 'text')
573
+ const maxChars = Number(options.maxChars ?? 6000)
574
+ const lines = [read.url]
575
+ if (read.title !== '') lines.push(`# ${read.title}`)
576
+ const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
577
+ const hidden = read.controls.length - shown.length
578
+ for (const control of shown) {
579
+ let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
580
+ if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
581
+ else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
582
+ if (control.disabled) line += ' (disabled)'
583
+ if (mode === 'links' && control.href !== '') line += ` → ${control.href}`
584
+ lines.push(line)
585
+ }
586
+ if (hidden > 0 && mode !== 'links' && mode !== 'forms') lines.push(`… ${hidden} more controls off screen`)
587
+ if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
588
+ const body = read.text
589
+ if (body !== '') {
590
+ lines.push('')
591
+ lines.push(body)
592
+ if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
593
+ }
594
+ if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
595
+ lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
596
+ }
597
+ return lines.join('\n').slice(0, Math.max(600, maxChars) + 600)
598
+ }
599
+
600
+ /** A ref that was valid on the previous read and is no longer here. */
601
+ export const REF_GONE = 'That control is gone: the page changed since the read. Call sidecar_view and use the new number.'
602
+
603
+ /**
604
+ * The control itself, not its number: what it is, what it is called, and where
605
+ * it sat. Kept from the read that produced the ref so a re-render can be
606
+ * survived without asking the person to read the page again.
607
+ * @param {any} control - a control from a read.
608
+ * @returns {{kind: string, name: string, href: string, inert: boolean}} what to look for.
609
+ */
610
+ export function identityOf(control) {
611
+ const kind = String(control?.kind ?? '')
612
+ const name = String(control?.name ?? '')
613
+ const href = String(control?.href ?? '')
614
+ const inert = ['input', 'select', 'textarea', 'password', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(kind)
615
+ return { kind, name, href, inert }
616
+ }
617
+
618
+ /**
619
+ * Whether a control read now is the one described by an identity: the same
620
+ * words, and not a container that happens to contain them.
621
+ * @param {{kind: string, name: string, href: string, inert: boolean}} want - identity.
622
+ * @param {any} control - a control from the current read.
623
+ * @returns {boolean} true when this is the same control.
624
+ */
625
+ export function sameThing(want, control) {
626
+ /* Nothing to look for is not a licence to click whatever is nearest. */
627
+ if (want.name === '') return false
628
+ if (String(control?.name ?? '') !== want.name) return false
629
+ if (String(control?.kind ?? '') !== want.kind) return false
630
+ /* A field and the button beside it can share their words; a field never
631
+ becomes a button, so the kind must match exactly rather than loosely. */
632
+ if (want.inert && !['input', 'select', 'textarea', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(String(control?.kind ?? ''))) return false
633
+ if (want.href !== '' && String(control?.href ?? '') !== want.href) return false
634
+ return true
635
+ }
636
+
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({})
654
+ const roots = [document]
655
+ for (const el of document.querySelectorAll('*')) {
656
+ if (roots.length > 400) break
657
+ if (el.shadowRoot) roots.push(el.shadowRoot)
658
+ }
659
+ const norm = (s) => String(s ?? '').replace(/\\s+/g, ' ').trim()
660
+ const short = (s) => { const t = norm(s); return t !== '' && t.length <= 80 ? t : '' }
661
+ const editable = (el) => {
662
+ const tag = el.tagName
663
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true
664
+ if (tag === 'INPUT') {
665
+ const type = String(el.type ?? 'text').toLowerCase()
666
+ return !['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'range', 'color'].includes(type)
667
+ }
668
+ if (el.isContentEditable === true) return true
669
+ return ['textbox', 'searchbox', 'combobox'].includes(String(el.getAttribute('role') ?? ''))
670
+ }
671
+ const shown = (el) => {
672
+ const b = el.getBoundingClientRect()
673
+ if (b.width < 2 || b.height < 2) return false
674
+ const s = getComputedStyle(el)
675
+ return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05
676
+ }
677
+ const names = (el) => {
678
+ const out = []
679
+ const add = (v) => { const t = short(v); if (t !== '') out.push(t) }
680
+ if (el.labels) for (const l of el.labels) add(l.innerText ?? l.textContent)
681
+ for (const id of String(el.getAttribute('aria-labelledby') ?? '').split(/\\s+/).filter(Boolean)) {
682
+ const other = document.getElementById(id)
683
+ if (other) add(other.innerText ?? other.textContent)
684
+ }
685
+ add(el.getAttribute('aria-label'))
686
+ add(el.getAttribute('placeholder'))
687
+ add(el.getAttribute('title'))
688
+ add(el.getAttribute('name'))
689
+ if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
690
+ const prev = el.previousElementSibling
691
+ if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
692
+ const parent = el.parentElement
693
+ if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
694
+ return out
695
+ }
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
708
+ }
709
+ if (score === 0) continue
710
+ if (best === null || score > best.score) best = { el, score, name }
711
+ }
712
+ }
713
+ if (best === null) return JSON.stringify({})
714
+ 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 })
717
+ })()`
718
+ }