dsh-inner-browser-use 0.18.0 → 0.34.28
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/client.js +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +485 -0
- package/docs/ROADMAP.md +146 -0
- package/docs/VERIFY.md +14 -0
- package/index.js +320 -29
- package/package.json +9 -3
- package/read.js +403 -39
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +764 -115
package/tools.js
CHANGED
|
@@ -9,14 +9,113 @@
|
|
|
9
9
|
* has settled.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
12
14
|
import { homedir } from 'node:os'
|
|
13
|
-
import { join } from 'node:path'
|
|
14
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { basename, join } from 'node:path'
|
|
16
|
+
import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
15
17
|
import { VIEW } from './sidecar.js?v=1'
|
|
16
|
-
import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, labelSpot, REF_GONE } from './read.js?v=1'
|
|
18
|
+
import { readPage, renderPage, locate, covering, contextFor, identityOf, sameThing, differs, labelSpot, selectSpot, bounds, pictures, REF_GONE } from './read.js?v=1'
|
|
19
|
+
import { brief as askBrief, recordRound, lastRoundNote, lastRound, markRound, MARK_STATUS } from './review.js?v=1'
|
|
17
20
|
|
|
18
|
-
/**
|
|
19
|
-
export const
|
|
21
|
+
/** Which chat the running call belongs to, so each gets its own tab. */
|
|
22
|
+
export const sessionScope = new AsyncLocalStorage()
|
|
23
|
+
|
|
24
|
+
/** @returns {string} the chat id of the running call. */
|
|
25
|
+
function currentSession() {
|
|
26
|
+
return String(sessionScope.getStore() ?? '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Which project the running call belongs to, so its files stay together. */
|
|
30
|
+
export const workScope = new AsyncLocalStorage()
|
|
31
|
+
|
|
32
|
+
/** Where files a call produces land, under the user's home. */
|
|
33
|
+
export const ARTIFACT_ROOT = String(process.env.DSH_INNER_BROWSER_ARTIFACTS ?? '') !== ''
|
|
34
|
+
? String(process.env.DSH_INNER_BROWSER_ARTIFACTS)
|
|
35
|
+
: join(homedir(), '.dsh-inner-browser-use', 'artifacts')
|
|
36
|
+
|
|
37
|
+
/** How many files one kind keeps per chat; older ones go. */
|
|
38
|
+
export const ARTIFACT_KEEP = 40
|
|
39
|
+
|
|
40
|
+
/** Project folder name: the folder's own name, plus a short hash of its path. */
|
|
41
|
+
function projectKey() {
|
|
42
|
+
const cwd = String(workScope.getStore() ?? '')
|
|
43
|
+
if (cwd === '') return 'no-project'
|
|
44
|
+
const named = basename(cwd).toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/^-+|-+$/g, '')
|
|
45
|
+
return `${named === '' ? 'project' : named}-${createHash('sha1').update(cwd).digest('hex').slice(0, 6)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Chat folder name: the short id a person can match to the chat. */
|
|
49
|
+
function sessionKey() {
|
|
50
|
+
const id = currentSession().replace(/^session-/, '').replace(/[^a-z0-9-]/gi, '')
|
|
51
|
+
return id === '' ? 'no-chat' : id.slice(0, 8)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where one kind of file for this project and this chat goes.
|
|
56
|
+
* `~/.dsh-inner-browser-use/artifacts/<project>/<chat>/<kind>/`
|
|
57
|
+
* @param {string} kind - shots, pictures, pdf.
|
|
58
|
+
* @returns {string} absolute directory; not created yet.
|
|
59
|
+
*/
|
|
60
|
+
export function artifactDir(kind) {
|
|
61
|
+
return join(ARTIFACT_ROOT, projectKey(), sessionKey(), kind)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write one file where the project and the chat expect it, and keep the folder
|
|
66
|
+
* from growing without a limit.
|
|
67
|
+
* @param {string} kind - shots, pictures, pdf.
|
|
68
|
+
* @param {string} body - base64 body.
|
|
69
|
+
* @param {{name?: string, ext?: string, prefix?: string}} [options] - file name.
|
|
70
|
+
* @returns {Promise<string>} absolute path.
|
|
71
|
+
*/
|
|
72
|
+
export async function saveArtifact(kind, body, options = {}) {
|
|
73
|
+
const dir = artifactDir(kind)
|
|
74
|
+
await mkdir(dir, { recursive: true })
|
|
75
|
+
const ext = String(options.ext ?? '.png')
|
|
76
|
+
/* A name is a name: no separators, no runs of dots that could read as a path
|
|
77
|
+
climb, and no leading dot to hide the file. */
|
|
78
|
+
const wanted = String(options.name ?? '').replace(/[^\w.-]/g, '').replace(/\.(png|jpe?g|webp|pdf)$/i, '').replace(/\.{2,}/g, '.').replace(/^\.+/, '')
|
|
79
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)
|
|
80
|
+
const prefix = String(options.prefix ?? kind)
|
|
81
|
+
const file = join(dir, `${wanted === '' ? `${prefix}-${stamp}` : wanted}${ext}`)
|
|
82
|
+
await writeFile(file, Buffer.from(body, 'base64'))
|
|
83
|
+
await pruneArtifacts(dir, ARTIFACT_KEEP)
|
|
84
|
+
return file
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Keep the newest `keep` files in one folder: a browser session that runs all
|
|
89
|
+
* day does not leave a folder nobody can look through.
|
|
90
|
+
* @param {string} dir - folder to trim.
|
|
91
|
+
* @param {number} keep - how many files survive.
|
|
92
|
+
* @returns {Promise<void>} done.
|
|
93
|
+
*/
|
|
94
|
+
export async function pruneArtifacts(dir, keep) {
|
|
95
|
+
let names
|
|
96
|
+
try {
|
|
97
|
+
names = await readdir(dir)
|
|
98
|
+
} catch {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const listed = []
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
try {
|
|
104
|
+
listed.push({ name, at: (await stat(join(dir, name))).mtimeMs })
|
|
105
|
+
} catch {
|
|
106
|
+
/* a file that vanished between listing and reading is already gone */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (listed.length <= keep) return
|
|
110
|
+
listed.sort((left, right) => right.at - left.at)
|
|
111
|
+
for (const spare of listed.slice(keep)) {
|
|
112
|
+
try {
|
|
113
|
+
await unlink(join(dir, spare.name))
|
|
114
|
+
} catch {
|
|
115
|
+
/* best effort: a file in use stays */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
20
119
|
|
|
21
120
|
/** How many times a call found a control again after the page redrew it. */
|
|
22
121
|
const follows = { count: 0 }
|
|
@@ -25,6 +124,41 @@ const follows = { count: 0 }
|
|
|
25
124
|
export const SIDECAR_OFF = 'The sidecar browser is not running. Call sidecar_open with the address first.'
|
|
26
125
|
|
|
27
126
|
/** Output schema every tool returns. */
|
|
127
|
+
/** The picture half of a screenshot result: shown only when the route can see it. */
|
|
128
|
+
const SHOT_IMAGE = {
|
|
129
|
+
type: 'object', // ui-copy-ignore
|
|
130
|
+
properties: {
|
|
131
|
+
attachmentId: { type: 'string' },
|
|
132
|
+
mediaType: { type: 'string' },
|
|
133
|
+
bytes: { type: 'integer' }, // ui-copy-ignore
|
|
134
|
+
width: { type: 'integer' }, // ui-copy-ignore
|
|
135
|
+
height: { type: 'integer' }, // ui-copy-ignore
|
|
136
|
+
name: { type: 'string' }, // ui-copy-ignore
|
|
137
|
+
},
|
|
138
|
+
required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A screenshot's path, plus the picture when this model can look at it. */
|
|
143
|
+
export const SHOT_OUTPUT = {
|
|
144
|
+
schema: { // ui-copy-ignore
|
|
145
|
+
type: 'object', // ui-copy-ignore
|
|
146
|
+
properties: { text: { type: 'string' }, image: SHOT_IMAGE },
|
|
147
|
+
required: ['text'],
|
|
148
|
+
additionalProperties: false,
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* @param {unknown} _args - call arguments, unused.
|
|
152
|
+
* @param {any} value - tool value.
|
|
153
|
+
* @returns {any[]} the text, and the picture when there is one.
|
|
154
|
+
*/
|
|
155
|
+
render(_args, value) {
|
|
156
|
+
const blocks = [{ type: 'text', text: String(value?.text ?? '') }]
|
|
157
|
+
if (value?.image !== undefined) blocks.push({ type: 'image', attachment: value.image })
|
|
158
|
+
return blocks
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
28
162
|
const TEXT_OUTPUT = {
|
|
29
163
|
schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
|
|
30
164
|
/**
|
|
@@ -45,18 +179,21 @@ const TEXT_OUTPUT = {
|
|
|
45
179
|
* @param {any} page - sidecar page.
|
|
46
180
|
* @returns {Promise<string>} address, title, focus and any open dialog.
|
|
47
181
|
*/
|
|
182
|
+
export const BRIEF_JS = `(() => {
|
|
183
|
+
const a = document.activeElement
|
|
184
|
+
const secret = a !== null && typeof a === 'object' && (String(a.type ?? '') === 'password' || String(a.getAttribute === undefined ? '' : a.getAttribute('type') ?? '') === 'password')
|
|
185
|
+
const focus = a !== null && a !== document.body && a !== document.documentElement
|
|
186
|
+
? { name: String(a.getAttribute('aria-label') || a.getAttribute('placeholder') || (a.labels && a.labels[0] && a.labels[0].innerText) || a.innerText || a.name || a.tagName).trim().slice(0, 60), value: secret ? '' : String(a.value === undefined ? '' : a.value).slice(0, 60) }
|
|
187
|
+
: null
|
|
188
|
+
return { url: location.href, title: document.title.slice(0, 80), focus }
|
|
189
|
+
})()`
|
|
190
|
+
|
|
48
191
|
async function brief(page) {
|
|
49
192
|
const dialog = page.dialog
|
|
50
193
|
if (dialog !== null && dialog !== undefined) {
|
|
51
194
|
return `${String(page.url ?? '')} · dialog ${String(dialog.type ?? '')}: ${String(dialog.message ?? '')}`
|
|
52
195
|
}
|
|
53
|
-
const info = await evaluate(page,
|
|
54
|
-
const a = document.activeElement
|
|
55
|
-
const focus = a !== null && a !== document.body && a !== document.documentElement
|
|
56
|
-
? { name: String(a.getAttribute('aria-label') || a.getAttribute('placeholder') || (a.labels && a.labels[0] && a.labels[0].innerText) || a.innerText || a.name || a.tagName).trim().slice(0, 60), value: String(a.value === undefined ? '' : a.value).slice(0, 60) }
|
|
57
|
-
: null
|
|
58
|
-
return { url: location.href, title: document.title.slice(0, 80), focus }
|
|
59
|
-
})()`)
|
|
196
|
+
const info = await evaluate(page, BRIEF_JS)
|
|
60
197
|
const bits = [String(info?.url ?? '')]
|
|
61
198
|
if (String(info?.title ?? '') !== '') bits.push(String(info.title))
|
|
62
199
|
if (info?.focus) bits.push(`focus ${info.focus.name}${info.focus.value === '' ? '' : ` = ${info.focus.value}`}`)
|
|
@@ -188,6 +325,18 @@ export const KEYS = {
|
|
|
188
325
|
Space: { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
|
189
326
|
}
|
|
190
327
|
|
|
328
|
+
/* A game moves while a key is held, and the keys a game listens for are letters
|
|
329
|
+
and digits, not the ones a form needs. Each entry carries what a browser
|
|
330
|
+
reports: the `key` value, the physical `code`, and the legacy number old
|
|
331
|
+
engines still look at. */
|
|
332
|
+
for (const letter of 'abcdefghijklmnopqrstuvwxyz') {
|
|
333
|
+
KEYS[letter] = { key: letter, code: `Key${letter.toUpperCase()}`, keyCode: letter.toUpperCase().charCodeAt(0), text: letter }
|
|
334
|
+
KEYS[letter.toUpperCase()] = KEYS[letter]
|
|
335
|
+
}
|
|
336
|
+
for (const digit of '0123456789') {
|
|
337
|
+
KEYS[digit] = { key: digit, code: `Digit${digit}`, keyCode: digit.charCodeAt(0), text: digit }
|
|
338
|
+
}
|
|
339
|
+
|
|
191
340
|
/**
|
|
192
341
|
* Press a key: keyDown, then keyUp, with the character when the key types one.
|
|
193
342
|
* @param {any} page - sidecar page.
|
|
@@ -195,14 +344,37 @@ export const KEYS = {
|
|
|
195
344
|
* @param {number} [modifiers] - CDP modifier bits: Alt 1, Ctrl 2, Meta 4, Shift 8.
|
|
196
345
|
* @returns {Promise<void>} done.
|
|
197
346
|
*/
|
|
198
|
-
export async function pressKey(page, name, modifiers = 0) {
|
|
347
|
+
export async function pressKey(page, name, modifiers = 0, holdMs = 0) {
|
|
199
348
|
const key = KEYS[name] ?? KEYS[name.charAt(0).toUpperCase() + name.slice(1)]
|
|
200
349
|
if (key === undefined) throw new Error(`sidecar: no such key ${name}`) // ui-copy-ignore
|
|
201
350
|
const base = { key: key.key, code: key.code, windowsVirtualKeyCode: key.keyCode, nativeVirtualKeyCode: key.keyCode, modifiers }
|
|
202
351
|
await page.key('keyDown', key.text === undefined ? base : { ...base, text: key.text })
|
|
352
|
+
/* A key held down is how a game moves: down, wait, up — not a tap. */
|
|
353
|
+
if (holdMs > 0) await new Promise((settle) => setTimeout(settle, holdMs))
|
|
203
354
|
await page.key('keyUp', base)
|
|
204
355
|
}
|
|
205
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Press several keys at once and hold them: the diagonal a game expects, and a
|
|
359
|
+
* chord such as Shift+ArrowLeft. Releasing in reverse order is what a pair of
|
|
360
|
+
* hands does, and some engines only notice the last key that went up.
|
|
361
|
+
* @param {any} page - sidecar page.
|
|
362
|
+
* @param {string[]} names - key names, for example ['ArrowLeft', 'ArrowUp'].
|
|
363
|
+
* @param {number} [modifiers] - CDP modifier bits.
|
|
364
|
+
* @param {number} [holdMs] - how long every key stays down.
|
|
365
|
+
* @returns {Promise<void>} done.
|
|
366
|
+
*/
|
|
367
|
+
export async function pressChord(page, names, modifiers = 0, holdMs = 0) {
|
|
368
|
+
const keys = names.map((name) => {
|
|
369
|
+
const key = KEYS[name] ?? KEYS[name.charAt(0).toUpperCase() + name.slice(1)]
|
|
370
|
+
if (key === undefined) throw new Error(`sidecar: no such key ${name}`) // ui-copy-ignore
|
|
371
|
+
return { key, base: { key: key.key, code: key.code, windowsVirtualKeyCode: key.keyCode, nativeVirtualKeyCode: key.keyCode, modifiers } }
|
|
372
|
+
})
|
|
373
|
+
for (const one of keys) await page.key('keyDown', one.key.text === undefined ? one.base : { ...one.base, text: one.key.text })
|
|
374
|
+
if (holdMs > 0) await new Promise((settle) => setTimeout(settle, holdMs))
|
|
375
|
+
for (const one of [...keys].reverse()) await page.key('keyUp', one.base)
|
|
376
|
+
}
|
|
377
|
+
|
|
206
378
|
/**
|
|
207
379
|
* Empty the field that has focus, whatever this machine calls select-all:
|
|
208
380
|
* Command on macOS, Control elsewhere. A page can swallow the shortcut, so any
|
|
@@ -419,9 +591,15 @@ async function until(page, expression, timeout) {
|
|
|
419
591
|
async function findWords(page, words) {
|
|
420
592
|
const read = await readPage(page, { mode: 'text', maxChars: 1200, max: 200 })
|
|
421
593
|
const shown = read.controls.filter((control) => control.view !== false)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
594
|
+
/* The words may sit in separate inline nodes: <span>Sign</span><span>in</span>
|
|
595
|
+
reads as "Signin" to the page, so compare with the spaces taken out too. */
|
|
596
|
+
const squeeze = (text) => String(text).toLowerCase().replace(/\s+/g, '')
|
|
597
|
+
const wanted = squeeze(words)
|
|
598
|
+
const found = shown.find((control) => squeeze(control.name) === wanted) ?? shown.find((control) => squeeze(control.name).includes(wanted))
|
|
599
|
+
if (found === undefined) return null
|
|
600
|
+
/* When only the squeezed form matched, say the words the caller used: the
|
|
601
|
+
page spread them over nodes and "Signin" is not what anyone wrote. */
|
|
602
|
+
const hit = squeeze(found.name) === wanted && found.name.toLowerCase() !== words.toLowerCase() ? { ...found, name: words } : found
|
|
425
603
|
const spot = await locate(page, hit.ref)
|
|
426
604
|
if (spot.gone === true) return null
|
|
427
605
|
return { ...hit, x: Number(spot.x), y: Number(spot.y) }
|
|
@@ -438,12 +616,60 @@ const EDITABLE_KINDS = ['input', 'textarea', 'select', 'combobox', 'textbox', 's
|
|
|
438
616
|
*/
|
|
439
617
|
async function fieldSpot(page, words) {
|
|
440
618
|
const said = await evaluate(page, labelSpot(words))
|
|
441
|
-
if (said
|
|
442
|
-
const spot = said.
|
|
443
|
-
if (spot ===
|
|
619
|
+
if (said === undefined || said === null || said.error !== undefined) return null
|
|
620
|
+
const spot = typeof said === 'string' ? JSON.parse(said) : said
|
|
621
|
+
if (spot === null || spot.name === undefined || spot.name === '') return null
|
|
444
622
|
return { x: Number(spot.x), y: Number(spot.y), name: String(spot.name) }
|
|
445
623
|
}
|
|
446
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Where the field called these words is, clearing a banner over it first.
|
|
627
|
+
* @param {any} page - sidecar page.
|
|
628
|
+
* @param {string} words - what the field is called on screen.
|
|
629
|
+
* @returns {Promise<{x: number, y: number, name: string, lead: string}|null>} the click.
|
|
630
|
+
*/
|
|
631
|
+
async function fieldByName(page, words) {
|
|
632
|
+
const hit = await findWords(page, words)
|
|
633
|
+
const lead = hit === null ? '' : await coverLead(page, hit.ref)
|
|
634
|
+
if (hit !== null && EDITABLE_KINDS.includes(String(hit.kind))) {
|
|
635
|
+
const spot = await locate(page, hit.ref)
|
|
636
|
+
if (spot.gone !== true) return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
|
|
637
|
+
}
|
|
638
|
+
const field = await fieldSpot(page, words)
|
|
639
|
+
if (field !== null) return { ...field, lead }
|
|
640
|
+
if (hit === null) return null
|
|
641
|
+
const spot = await locate(page, hit.ref)
|
|
642
|
+
if (spot.gone === true) return null
|
|
643
|
+
return { x: Number(spot.x), y: Number(spot.y), name: String(hit.name), lead }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Click a spot, type into it, and optionally clear it first.
|
|
648
|
+
* @param {any} page - sidecar page.
|
|
649
|
+
* @param {{x: number, y: number}} point - where to click.
|
|
650
|
+
* @param {string} text - what to type.
|
|
651
|
+
* @param {{clear?: boolean, submit?: boolean}} options - how.
|
|
652
|
+
*/
|
|
653
|
+
async function writeAt(page, point, text, options = {}) {
|
|
654
|
+
/* Approach the control the way a hand does: a couple of small moves that
|
|
655
|
+
land in different places, not a teleport followed by a click. */
|
|
656
|
+
const drift = (by) => ({ x: point.x + by, y: point.y + Math.round(by / 2) })
|
|
657
|
+
await page.mouse('mouseMoved', drift(-14 - Math.round(Math.random() * 10)))
|
|
658
|
+
await wait(40 + Math.round(Math.random() * 45))
|
|
659
|
+
await page.mouse('mouseMoved', drift(-4 - Math.round(Math.random() * 4)))
|
|
660
|
+
await wait(25 + Math.round(Math.random() * 35))
|
|
661
|
+
await page.mouse('mouseMoved', point)
|
|
662
|
+
await wait(35 + Math.round(Math.random() * 60))
|
|
663
|
+
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
664
|
+
await wait(20 + Math.round(Math.random() * 40))
|
|
665
|
+
await page.mouse('mouseReleased', { ...point, clickCount: 1 })
|
|
666
|
+
await wait(90 + Math.round(Math.random() * 120))
|
|
667
|
+
if (options.clear === true) await clearFocus(page)
|
|
668
|
+
if (options.human === false) await page.insertText(text)
|
|
669
|
+
else await page.humanType(text)
|
|
670
|
+
if (options.submit === true) await pressKey(page, 'Enter')
|
|
671
|
+
}
|
|
672
|
+
|
|
447
673
|
/**
|
|
448
674
|
* Where the control behind a ref is now, following it across a re-render.
|
|
449
675
|
* The module-level sibling of findAgain, for runStep, which has no closure.
|
|
@@ -454,8 +680,11 @@ async function fieldSpot(page, words) {
|
|
|
454
680
|
*/
|
|
455
681
|
async function followRef(page, ref, known) {
|
|
456
682
|
const spot = await locate(page, ref)
|
|
457
|
-
if (spot.gone !== true) return spot
|
|
458
683
|
const was = Array.isArray(known) ? known.find((control) => Number(control.ref) === Number(ref)) : undefined
|
|
684
|
+
if (spot.gone !== true) {
|
|
685
|
+
const drift = differs(was, spot)
|
|
686
|
+
return drift === '' ? spot : { gone: true, why: drift }
|
|
687
|
+
}
|
|
459
688
|
if (was === undefined) return spot
|
|
460
689
|
const want = identityOf(was)
|
|
461
690
|
if (want.name === '') return spot
|
|
@@ -468,12 +697,40 @@ async function followRef(page, ref, known) {
|
|
|
468
697
|
return { ...next, followed: true, was: want.name }
|
|
469
698
|
}
|
|
470
699
|
|
|
700
|
+
/**
|
|
701
|
+
* One read state per chat, created the first time that chat reads.
|
|
702
|
+
* @param {Map<string, any>} store - per-chat states.
|
|
703
|
+
* @param {string} id - chat id.
|
|
704
|
+
* @param {any} fallback - state for calls that name no chat.
|
|
705
|
+
* @returns {any} the state of that chat.
|
|
706
|
+
*/
|
|
707
|
+
export function readStateFor(store, id, fallback) {
|
|
708
|
+
const key = String(id ?? '')
|
|
709
|
+
const known = store.get(key)
|
|
710
|
+
if (known !== undefined) return known
|
|
711
|
+
/* The one object the plugin was started with belongs to calls that name no
|
|
712
|
+
chat, so a chat that does name itself gets an empty one of its own. */
|
|
713
|
+
const fresh = key === '' ? fallback : { refs: [], at: 0 }
|
|
714
|
+
store.set(key, fresh)
|
|
715
|
+
return fresh
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* A step that could not be carried out. The batch stops here: the next step was
|
|
720
|
+
* written against a page that never arrived.
|
|
721
|
+
* @param {string} text - what happened.
|
|
722
|
+
* @returns {{text: string, blocked: boolean}} the step result.
|
|
723
|
+
*/
|
|
724
|
+
function stuck(text) {
|
|
725
|
+
return { text, blocked: true }
|
|
726
|
+
}
|
|
727
|
+
|
|
471
728
|
async function runStep(page, step, known) {
|
|
472
729
|
if (step.ref !== undefined) {
|
|
473
730
|
if (step.tap !== undefined || step.text !== undefined) {
|
|
474
731
|
const lead = await coverLead(page, Number(step.ref))
|
|
475
732
|
const spot = await followRef(page, Number(step.ref), known)
|
|
476
|
-
if (spot.gone === true) return `ref ${String(step.ref)}: ${REF_GONE}`
|
|
733
|
+
if (spot.gone === true) return stuck(`ref ${String(step.ref)}: ${REF_GONE}`)
|
|
477
734
|
const after = spot.followed === true ? ` (it was redrawn under a new number, used where it is now)` : ''
|
|
478
735
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
479
736
|
await page.mouse('mouseMoved', point)
|
|
@@ -482,17 +739,17 @@ async function runStep(page, step, known) {
|
|
|
482
739
|
if (step.tap !== undefined) return `${lead}clicked ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
|
|
483
740
|
await wait(80)
|
|
484
741
|
if (step.clear === true) await clearFocus(page)
|
|
485
|
-
await page.
|
|
742
|
+
await page.humanType(String(step.text))
|
|
486
743
|
if (step.submit === true) await pressKey(page, 'Enter')
|
|
487
744
|
return `${lead}typed into ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
|
|
488
745
|
}
|
|
489
746
|
}
|
|
490
747
|
if (step.tap !== undefined && !/^\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*$/.test(String(step.tap))) {
|
|
491
748
|
const hit = await findWords(page, String(step.tap))
|
|
492
|
-
if (hit === null) return `no control says ${JSON.stringify(String(step.tap))}`
|
|
749
|
+
if (hit === null) return stuck(`no control says ${JSON.stringify(String(step.tap))}`)
|
|
493
750
|
const lead = await coverLead(page, hit.ref)
|
|
494
751
|
const spot = await locate(page, hit.ref)
|
|
495
|
-
if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
|
|
752
|
+
if (spot.gone === true) return stuck(`ref ${String(hit.ref)}: ${REF_GONE}`)
|
|
496
753
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
497
754
|
await page.mouse('mouseMoved', point)
|
|
498
755
|
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
@@ -502,40 +759,10 @@ async function runStep(page, step, known) {
|
|
|
502
759
|
if (step.field !== undefined) {
|
|
503
760
|
/* The words may sit on the field or in a div above a bare input. Only a
|
|
504
761
|
click that lands on the field itself can be trusted with the typing. */
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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)}"`
|
|
762
|
+
const target = await fieldByName(page, String(step.field))
|
|
763
|
+
if (target === null) return stuck(`${String(step.field)}: ${REF_GONE}`)
|
|
764
|
+
await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
|
|
765
|
+
return `${target.lead}typed into "${target.name.slice(0, 60)}"`
|
|
539
766
|
}
|
|
540
767
|
if (step.tap !== undefined) {
|
|
541
768
|
const [x, y] = String(step.tap).split(',').map(Number)
|
|
@@ -547,7 +774,7 @@ async function runStep(page, step, known) {
|
|
|
547
774
|
}
|
|
548
775
|
if (step.text !== undefined) {
|
|
549
776
|
if (step.clear === true) await clearFocus(page)
|
|
550
|
-
await page.
|
|
777
|
+
await page.humanType(String(step.text))
|
|
551
778
|
if (step.submit === true) await pressKey(page, 'Enter')
|
|
552
779
|
return `typed ${String(step.text).slice(0, 60)}`
|
|
553
780
|
}
|
|
@@ -563,17 +790,17 @@ async function runStep(page, step, known) {
|
|
|
563
790
|
if (step.wait_text !== undefined) {
|
|
564
791
|
const needle = JSON.stringify(String(step.wait_text))
|
|
565
792
|
const ok = await until(page, `document.body ? document.body.innerText.includes(${needle}) : false`, Number(step.timeout ?? 8000))
|
|
566
|
-
return ok ? `saw ${String(step.wait_text)}` : `did not see ${String(step.wait_text)}`
|
|
793
|
+
return ok ? `saw ${String(step.wait_text)}` : stuck(`did not see ${String(step.wait_text)}`)
|
|
567
794
|
}
|
|
568
795
|
if (step.wait_gone !== undefined) {
|
|
569
796
|
const needle = JSON.stringify(String(step.wait_gone))
|
|
570
797
|
const ok = await until(page, `document.body ? !document.body.innerText.includes(${needle}) : true`, Number(step.timeout ?? 8000))
|
|
571
|
-
return ok ? `${String(step.wait_gone)} went away` : `${String(step.wait_gone)} stayed`
|
|
798
|
+
return ok ? `${String(step.wait_gone)} went away` : stuck(`${String(step.wait_gone)} stayed`)
|
|
572
799
|
}
|
|
573
800
|
if (step.wait_url !== undefined) {
|
|
574
801
|
const needle = JSON.stringify(String(step.wait_url))
|
|
575
802
|
const ok = await until(page, `location.href.includes(${needle})`, Number(step.timeout ?? 8000))
|
|
576
|
-
return ok ? `at ${String(step.wait_url)}` : `still at a different address`
|
|
803
|
+
return ok ? `at ${String(step.wait_url)}` : stuck(`still at a different address`)
|
|
577
804
|
}
|
|
578
805
|
if (step.open !== undefined) {
|
|
579
806
|
const landed = await page.navigate(String(step.open))
|
|
@@ -610,6 +837,22 @@ async function remember(page, refs, options) {
|
|
|
610
837
|
return read
|
|
611
838
|
}
|
|
612
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Two reads of the same page in the same shape say the same thing. Saying it
|
|
842
|
+
* again costs tokens and teaches nothing, so the second read can be one line.
|
|
843
|
+
* @param {any} before - the earlier read.
|
|
844
|
+
* @param {any} after - the read now.
|
|
845
|
+
* @returns {boolean} true when nothing a read would show has changed.
|
|
846
|
+
*/
|
|
847
|
+
export function sameRead(before, after) {
|
|
848
|
+
if (before === undefined || before === null || after === undefined || after === null) return false
|
|
849
|
+
if (before.url !== after.url || before.title !== after.title) return false
|
|
850
|
+
if (before.text !== after.text) return false
|
|
851
|
+
if (JSON.stringify(before.controls) !== JSON.stringify(after.controls)) return false
|
|
852
|
+
if ((before.focused?.ref ?? null) !== (after.focused?.ref ?? null)) return false
|
|
853
|
+
return true
|
|
854
|
+
}
|
|
855
|
+
|
|
613
856
|
/**
|
|
614
857
|
* Say only what changed since the previous read.
|
|
615
858
|
* @param {any} previous - the read before.
|
|
@@ -705,7 +948,140 @@ export async function extractRows(page, args) {
|
|
|
705
948
|
return `${rows.length} of ${Number(value.total)} rows matching ${select}:\n${said.join('\n')}${more}`
|
|
706
949
|
}
|
|
707
950
|
|
|
951
|
+
/**
|
|
952
|
+
* Say one thing to a box in the page and take the answer back. The wait ends
|
|
953
|
+
* when the answer has stopped changing, so a streaming reply is not cut off and
|
|
954
|
+
* a page that never answers does not hold the call open forever.
|
|
955
|
+
* @param {any} page - sidecar page.
|
|
956
|
+
* @param {{text?: string, ref?: number, submit?: boolean, timeout_ms?: number}} args - what to say.
|
|
957
|
+
* @returns {Promise<string>} the answer, or why there is none.
|
|
958
|
+
*/
|
|
959
|
+
/**
|
|
960
|
+
* Whether a box on the page still holds the words that were typed.
|
|
961
|
+
* A box that keeps them never sent them, which is what a signed-out page does.
|
|
962
|
+
* @param {any} page - sidecar page.
|
|
963
|
+
* @param {string} words - what was typed.
|
|
964
|
+
* @returns {Promise<boolean>} true when the words are still in a box.
|
|
965
|
+
*/
|
|
966
|
+
async function boxHolds(page, words) {
|
|
967
|
+
const wanted = String(words).slice(0, 24)
|
|
968
|
+
if (wanted === '') return false
|
|
969
|
+
const held = await evaluate(page, `(() => {
|
|
970
|
+
const wanted = ${JSON.stringify(wanted)}
|
|
971
|
+
for (const el of document.querySelectorAll('textarea, [contenteditable=""], [contenteditable="true"], input[type="text"], input[type="search"], input:not([type])')) {
|
|
972
|
+
if (String(el.value ?? el.innerText ?? '').trim().includes(wanted)) return true
|
|
973
|
+
}
|
|
974
|
+
return false
|
|
975
|
+
})()`)
|
|
976
|
+
return held === true
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Sends a line to whatever chat the page holds and waits for the answer to stop
|
|
981
|
+
* growing. The ref is followed the same way a click follows it, so a reply box
|
|
982
|
+
* that was redrawn since the read still gets the text.
|
|
983
|
+
* @param {any} page - sidecar page.
|
|
984
|
+
* @param {any} args - text, optional ref, optional timeout.
|
|
985
|
+
* @returns {Promise<string>} the answer.
|
|
986
|
+
*/
|
|
987
|
+
export async function askInPage(page, args) {
|
|
988
|
+
const said = String(args?.text ?? '').trim()
|
|
989
|
+
if (said === '') return 'Give me something to say.'
|
|
990
|
+
const budget = Math.max(3000, Math.min(180_000, Math.trunc(Number(args?.timeout_ms ?? 60_000)) || 60_000))
|
|
991
|
+
const before = await readPage(page, { mode: 'text', maxChars: 4000 })
|
|
992
|
+
let point = null
|
|
993
|
+
if (args?.ref !== undefined) {
|
|
994
|
+
const spot = await followRef(page, Number(args.ref))
|
|
995
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
996
|
+
point = { x: Number(spot.x), y: Number(spot.y) }
|
|
997
|
+
} else {
|
|
998
|
+
const at = await evaluate(page, `(() => {
|
|
999
|
+
let best = null
|
|
1000
|
+
for (const el of document.querySelectorAll('textarea, [contenteditable=""], [contenteditable="true"], input[type="text"], input[type="search"], input:not([type])')) {
|
|
1001
|
+
const b = el.getBoundingClientRect()
|
|
1002
|
+
const s = getComputedStyle(el)
|
|
1003
|
+
if (s.visibility === 'hidden' || s.display === 'none' || b.width < 80 || b.height < 18) continue
|
|
1004
|
+
if (b.bottom < 0 || b.top > innerHeight) continue
|
|
1005
|
+
if (best === null || b.width * b.height > best.area) best = { area: b.width * b.height, x: b.x + Math.min(24, b.width / 2), y: b.y + b.height / 2, name: String(el.getAttribute('placeholder') ?? el.getAttribute('aria-label') ?? '').slice(0, 40) }
|
|
1006
|
+
}
|
|
1007
|
+
return best === null ? null : { x: Math.round(best.x), y: Math.round(best.y), name: best.name }
|
|
1008
|
+
})()`)
|
|
1009
|
+
if (at === null || at === undefined) return 'No box to type in on this page. Call sidecar_view to see what is there.'
|
|
1010
|
+
point = { x: Number(at.x), y: Number(at.y) }
|
|
1011
|
+
}
|
|
1012
|
+
await page.mouse('mouseMoved', point)
|
|
1013
|
+
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
1014
|
+
await page.mouse('mouseReleased', { ...point, clickCount: 1 })
|
|
1015
|
+
await wait(60)
|
|
1016
|
+
await page.humanType(said)
|
|
1017
|
+
if (args?.submit !== false) await pressKey(page, 'Enter')
|
|
1018
|
+
await wait(500)
|
|
1019
|
+
if (args?.submit !== false && (await boxHolds(page, said))) {
|
|
1020
|
+
return `the words are still in the box 0.5s after pressing Enter: the page did not take the message. If it is waiting for a sign-in, sign in in the column, then ask again.`
|
|
1021
|
+
}
|
|
1022
|
+
const started = Date.now()
|
|
1023
|
+
const deadline = started + budget
|
|
1024
|
+
let last = before.text
|
|
1025
|
+
let fresh = ''
|
|
1026
|
+
let steady = 0
|
|
1027
|
+
let quiet = 0
|
|
1028
|
+
while (Date.now() < deadline) {
|
|
1029
|
+
await wait(500)
|
|
1030
|
+
const now = await readPage(page, { mode: 'text', maxChars: 4000 })
|
|
1031
|
+
if (now.text !== last) {
|
|
1032
|
+
last = now.text
|
|
1033
|
+
fresh = now.text
|
|
1034
|
+
steady = 0
|
|
1035
|
+
quiet = 0
|
|
1036
|
+
continue
|
|
1037
|
+
}
|
|
1038
|
+
if (fresh !== '') {
|
|
1039
|
+
steady += 1
|
|
1040
|
+
if (steady >= 3) break
|
|
1041
|
+
} else {
|
|
1042
|
+
quiet += 1
|
|
1043
|
+
if (quiet >= 4 && Date.now() - started > 4000) break
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const took = Date.now() - started
|
|
1047
|
+
/* A page that shows neither the words in a box nor the message itself never
|
|
1048
|
+
took it: the chips moved, the menu redrew, and none of that is an answer. */
|
|
1049
|
+
const echo = String(said.slice(0, 24))
|
|
1050
|
+
if (fresh !== '' && echo.length >= 8 && !fresh.includes(echo)) {
|
|
1051
|
+
const asking = /(sign in|log in|登录|注册)/i.test(fresh)
|
|
1052
|
+
return `the page changed but does not show the message: it did not take what was sent.${asking ? ' It is asking for a sign-in — sign in in the column, then ask again.' : ' Try sidecar_view to see what is in the way.'}`
|
|
1053
|
+
}
|
|
1054
|
+
/* A box that still holds the words is the page saying it did not take them:
|
|
1055
|
+
a signed-out page leaves them there, and the old wording sent the reader
|
|
1056
|
+
looking for an answer that was never asked for. */
|
|
1057
|
+
const stuck = fresh === '' && (await boxHolds(page, said))
|
|
1058
|
+
if (fresh === '') {
|
|
1059
|
+
if (stuck) return `sent it — no answer after ${Math.round(took / 100) / 10}s, and the words are still in the box: the page did not take the message. If it is waiting for a sign-in, sign in in the column, then ask again.`
|
|
1060
|
+
return `sent it — no answer after ${Math.round(took / 100) / 10}s; the page still says: ${before.text.slice(0, 400)}`
|
|
1061
|
+
}
|
|
1062
|
+
/* The answer may replace the words that were there, so cut at the last
|
|
1063
|
+
character the two reads share, not at the old length. */
|
|
1064
|
+
let shared = 0
|
|
1065
|
+
while (shared < fresh.length && shared < before.text.length && fresh[shared] === before.text[shared]) shared += 1
|
|
1066
|
+
const added = fresh.slice(shared)
|
|
1067
|
+
/* Lines are kept: a reviewer's list is one point per line, and flattening it
|
|
1068
|
+
would leave the next round with a paragraph instead of items. */
|
|
1069
|
+
const words = added.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim().slice(0, 2500)
|
|
1070
|
+
return `the answer came back in ${Math.round(took / 100) / 10}s:\n${words}`
|
|
1071
|
+
}
|
|
1072
|
+
|
|
708
1073
|
export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
1074
|
+
/* A ref belongs to the page it was read from, and two chats read two pages.
|
|
1075
|
+
One shared read state meant chat B's read replaced chat A's baseline, so A's
|
|
1076
|
+
next "what changed" was a diff against a page it never saw and a number from
|
|
1077
|
+
A could be matched against B's control. Each chat now keeps its own. */
|
|
1078
|
+
const byChat = new Map()
|
|
1079
|
+
/**
|
|
1080
|
+
* The read state of the chat this call speaks for.
|
|
1081
|
+
* @param {string} [id] - chat id; the current one by default.
|
|
1082
|
+
* @returns {{refs: any[], at: number, last?: any, lastShape?: string}} its state.
|
|
1083
|
+
*/
|
|
1084
|
+
const refsFor = (id = currentSession()) => readStateFor(byChat, id, refs)
|
|
709
1085
|
/* Reading, clicking and typing on Chrome's own error page is not driving the
|
|
710
1086
|
site: the text there is Chrome talking, the buttons reload. Those calls are
|
|
711
1087
|
refused with the error code instead. The tools that report the browser
|
|
@@ -713,14 +1089,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
713
1089
|
run, because they are how a stuck person finds out what happened. */
|
|
714
1090
|
const withRawPage = async (work) => {
|
|
715
1091
|
if (!sidecar.alive) return { text: SIDECAR_OFF }
|
|
716
|
-
const page = await sidecar.ensure()
|
|
1092
|
+
const page = await sidecar.ensure({ session: currentSession() })
|
|
717
1093
|
const text = await work(page)
|
|
718
1094
|
return { text: text + personNote(sidecar) }
|
|
719
1095
|
}
|
|
720
1096
|
/** Looking is still useful while the person has the page. */
|
|
721
1097
|
const withRead = async (work) => {
|
|
722
|
-
|
|
723
|
-
|
|
1098
|
+
/* A plugin reload makes a new driver while the browser it started keeps
|
|
1099
|
+
running. Refusing every read until someone opens a page again would throw
|
|
1100
|
+
away a browser that is still there: attach to it instead. */
|
|
1101
|
+
if (!sidecar.alive) {
|
|
1102
|
+
try {
|
|
1103
|
+
await sidecar.ensure({ session: currentSession() })
|
|
1104
|
+
} catch {
|
|
1105
|
+
return { text: SIDECAR_OFF }
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const page = await sidecar.ensure({ session: currentSession() })
|
|
724
1109
|
const dead = await unreachable(page)
|
|
725
1110
|
if (dead !== '') return { text: dead + personNote(sidecar) }
|
|
726
1111
|
const text = await work(page)
|
|
@@ -733,7 +1118,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
733
1118
|
return withRead(work)
|
|
734
1119
|
}
|
|
735
1120
|
const look = async (page, options) => {
|
|
736
|
-
const read = await remember(page,
|
|
1121
|
+
const read = await remember(page, refsFor(), options)
|
|
737
1122
|
const said = renderPage(read, options)
|
|
738
1123
|
return isCheckPage(read) ? `${said}\n${CHECK_NOTE}` : said
|
|
739
1124
|
}
|
|
@@ -754,8 +1139,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
754
1139
|
*/
|
|
755
1140
|
const findAgain = async (page, ref) => {
|
|
756
1141
|
const spot = await locate(page, ref)
|
|
757
|
-
|
|
758
|
-
|
|
1142
|
+
const known = (refsFor().refs ?? []).find((control) => Number(control.ref) === Number(ref))
|
|
1143
|
+
if (spot.gone !== true) {
|
|
1144
|
+
const drift = differs(known, spot)
|
|
1145
|
+
return drift === '' ? spot : { gone: true, why: drift }
|
|
1146
|
+
}
|
|
759
1147
|
if (known === undefined) return spot
|
|
760
1148
|
const want = identityOf(known)
|
|
761
1149
|
if (want.name === '') return spot
|
|
@@ -787,7 +1175,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
787
1175
|
isConcurrencySafe: () => false,
|
|
788
1176
|
/** @param {any} args - arguments. @param {any} exec - tool run. @returns {Promise<{text: string}>} what the page is. */
|
|
789
1177
|
execute: async (args, exec) => {
|
|
790
|
-
const
|
|
1178
|
+
const who = String(exec?.agent?.id ?? '')
|
|
1179
|
+
const page = await sidecar.ensure({ url: args?.url, headless: args?.headless, session: who })
|
|
791
1180
|
if (args?.url === undefined) await page.readTitle()
|
|
792
1181
|
/* A window on the person's screen is their business, not a surprise: say
|
|
793
1182
|
it, so nobody wonders what popped up. A site that refuses a windowless
|
|
@@ -795,7 +1184,6 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
795
1184
|
const windowed = sidecar.headless === false ? 'The browser is showing a window this time — a site only opens for a windowed one. Same profile, same logins.\n' : ''
|
|
796
1185
|
/* The tab appears in the calling chat and nowhere else: the person may
|
|
797
1186
|
be reading a different chat, and a browser there would be a surprise. */
|
|
798
|
-
const who = String(exec?.agent?.id ?? '')
|
|
799
1187
|
if (who !== '') host.want?.(who) // ui-copy-ignore
|
|
800
1188
|
await settle(page, 8000)
|
|
801
1189
|
const dead = await unreachable(page)
|
|
@@ -824,10 +1212,29 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
824
1212
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the read. */
|
|
825
1213
|
execute: async (args) => withRead(async (page) => {
|
|
826
1214
|
const options = { mode: String(args?.mode ?? 'text'), maxChars: Number(args?.max_chars ?? 4600) }
|
|
827
|
-
|
|
1215
|
+
/* Each chat keeps its own read state: a shared one let another chat's
|
|
1216
|
+
read replace this chat's baseline, so its numbers and its "what
|
|
1217
|
+
changed" answered about a page it never saw. */
|
|
1218
|
+
const state = refsFor()
|
|
1219
|
+
const read = await remember(page, state, options)
|
|
828
1220
|
const note = isCheckPage(read) ? `\n${CHECK_NOTE}` : ''
|
|
829
|
-
if (args?.changed_only === true &&
|
|
830
|
-
|
|
1221
|
+
if (args?.changed_only === true && state.last !== undefined) {
|
|
1222
|
+
/* The diff becomes the new baseline: a page that moved zero -> one
|
|
1223
|
+
should not report that move again on the next read. */
|
|
1224
|
+
const text = diffRead(state.last, read, options) + note
|
|
1225
|
+
state.last = read
|
|
1226
|
+
state.lastShape = JSON.stringify(options)
|
|
1227
|
+
return text
|
|
1228
|
+
}
|
|
1229
|
+
const before = state.last
|
|
1230
|
+
state.last = read
|
|
1231
|
+
/* Same page, same shape, same words: the numbers above still work, and
|
|
1232
|
+
repeating the page would only spend tokens. */
|
|
1233
|
+
if (sameRead(before, read) && state.lastShape === JSON.stringify(options)) {
|
|
1234
|
+
const moved = Number(before.scroll?.y ?? 0) !== Number(read.scroll?.y ?? 0) ? `, scrolled to ${Math.round(Number(read.scroll?.y ?? 0))}` : ''
|
|
1235
|
+
return `still at ${read.url}${moved} — nothing changed since the last read; the numbers above still work${note}`
|
|
1236
|
+
}
|
|
1237
|
+
state.lastShape = JSON.stringify(options)
|
|
831
1238
|
return renderPage(read, options) + note
|
|
832
1239
|
}),
|
|
833
1240
|
},
|
|
@@ -843,6 +1250,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
843
1250
|
y: { type: 'integer', description: 'Viewport y, when clicking a point.' },
|
|
844
1251
|
double: { type: 'boolean', description: 'Double-click.' },
|
|
845
1252
|
right: { type: 'boolean', description: 'Right-click, opening a context menu.' },
|
|
1253
|
+
hold_ms: { type: 'integer', description: 'Keep the button down this long before releasing: a press-and-hold a game or a drag handle needs.' },
|
|
846
1254
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
847
1255
|
},
|
|
848
1256
|
additionalProperties: false,
|
|
@@ -857,7 +1265,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
857
1265
|
let followed = ''
|
|
858
1266
|
if (args?.ref !== undefined) {
|
|
859
1267
|
const spot = await findAgain(page, Number(args.ref))
|
|
860
|
-
if (spot.gone === true) return REF_GONE
|
|
1268
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
861
1269
|
if (spot.followed === true) followed = `the page redrew, so "${String(spot.was).slice(0, 50)}" has a new number — used where it is now`
|
|
862
1270
|
point = { x: Number(spot.x), y: Number(spot.y) }
|
|
863
1271
|
} else if (String(args?.text ?? '') !== '') {
|
|
@@ -872,6 +1280,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
872
1280
|
if (args?.ref !== undefined && button === 'left') {
|
|
873
1281
|
const gate = await clearCover(page, Number(args.ref))
|
|
874
1282
|
cover = gate.note
|
|
1283
|
+
/* An overlay that would not go away leaves the control underneath:
|
|
1284
|
+
the click would land on the overlay, so it is not sent. */
|
|
1285
|
+
if (gate.cleared !== true) {
|
|
1286
|
+
return `${cover.trim()}\nNot clicked: the control is still covered. Call sidecar_view and handle the overlay first.`
|
|
1287
|
+
}
|
|
875
1288
|
if (gate.cleared && cover !== '') {
|
|
876
1289
|
const again = await locate(page, Number(args.ref))
|
|
877
1290
|
if (again.gone !== true) point = { x: Number(again.x), y: Number(again.y) }
|
|
@@ -879,6 +1292,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
879
1292
|
}
|
|
880
1293
|
await page.mouse('mouseMoved', point)
|
|
881
1294
|
await page.mouse('mousePressed', { ...point, clickCount: args?.double === true ? 2 : 1, button })
|
|
1295
|
+
const hold = Math.max(0, Math.min(60_000, Number(args?.hold_ms ?? 0)))
|
|
1296
|
+
if (hold > 0) await new Promise((settle) => setTimeout(settle, hold))
|
|
882
1297
|
await page.mouse('mouseReleased', { ...point, clickCount: args?.double === true ? 2 : 1, button })
|
|
883
1298
|
await settle(page)
|
|
884
1299
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 3000 }), `click ${label}`)
|
|
@@ -893,6 +1308,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
893
1308
|
properties: {
|
|
894
1309
|
text: { type: 'string', description: 'Text to type.' },
|
|
895
1310
|
ref: { type: 'integer', description: 'Control to click before typing.' },
|
|
1311
|
+
field: { type: 'string', description: 'The field is called this on screen, when you have no number for it.' },
|
|
896
1312
|
submit: { type: 'boolean', description: 'Press Enter after typing.' },
|
|
897
1313
|
clear: { type: 'boolean', description: 'Select all and delete before typing.' },
|
|
898
1314
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
@@ -907,11 +1323,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
907
1323
|
execute: async (args) => withPage(async (page) => {
|
|
908
1324
|
let cover = ''
|
|
909
1325
|
let followed = ''
|
|
1326
|
+
if (args?.field !== undefined && args?.ref === undefined) {
|
|
1327
|
+
const target = await fieldByName(page, String(args.field))
|
|
1328
|
+
if (target === null) return `${String(args.field)}: ${REF_GONE}`
|
|
1329
|
+
cover = target.lead
|
|
1330
|
+
await writeAt(page, { x: target.x, y: target.y }, String(args?.text ?? ''), { clear: args?.clear, submit: args?.submit === true })
|
|
1331
|
+
await settle(page)
|
|
1332
|
+
const called = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
|
|
1333
|
+
return called + cover + ` (typed into "${target.name.slice(0, 50)}")` // ui-copy-ignore
|
|
1334
|
+
}
|
|
910
1335
|
if (args?.ref !== undefined) {
|
|
911
1336
|
const gate = await clearCover(page, Number(args.ref))
|
|
912
1337
|
cover = gate.note
|
|
1338
|
+
if (gate.cleared !== true) {
|
|
1339
|
+
return `${cover.trim()}\nNot typed into: the field is still covered. Call sidecar_view and handle the overlay first.`
|
|
1340
|
+
}
|
|
913
1341
|
const spot = await findAgain(page, Number(args.ref))
|
|
914
|
-
if (spot.gone === true) return REF_GONE + cover
|
|
1342
|
+
if (spot.gone === true) return spot.why ?? REF_GONE + cover
|
|
915
1343
|
if (spot.followed === true) followed = `the page redrew, so "${String(spot.was).slice(0, 50)}" has a new number — typed where it is now`
|
|
916
1344
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
917
1345
|
await page.mouse('mouseMoved', point)
|
|
@@ -920,21 +1348,131 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
920
1348
|
await wait(80)
|
|
921
1349
|
}
|
|
922
1350
|
if (args?.clear === true) await clearFocus(page)
|
|
923
|
-
await page.
|
|
1351
|
+
await page.humanType(String(args?.text ?? ''))
|
|
924
1352
|
if (args?.submit === true) await pressKey(page, 'Enter')
|
|
925
1353
|
await settle(page)
|
|
926
1354
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
|
|
927
1355
|
return said + cover + (followed === '' ? '' : ` (${followed})`) // ui-copy-ignore
|
|
928
1356
|
}),
|
|
929
1357
|
},
|
|
1358
|
+
{
|
|
1359
|
+
name: 'sidecar_ask', // ui-copy-ignore
|
|
1360
|
+
description: 'Say something to a chat that is open in the page — a model, a support window, a search box — and read its answer back in the same call. Types into the box, presses Enter, then waits until the answer stops growing. Use it to get a review or a picture prompt answered without a read between every step.',
|
|
1361
|
+
parameters: {
|
|
1362
|
+
type: 'object', // ui-copy-ignore
|
|
1363
|
+
properties: {
|
|
1364
|
+
text: { type: 'string', description: 'What to say.' },
|
|
1365
|
+
ref: { type: 'integer', description: 'The box to type in, from sidecar_view. Omit to use the page\u2019s own chat box.' },
|
|
1366
|
+
submit: { type: 'boolean', description: 'Press Enter to send. Default true.' },
|
|
1367
|
+
timeout_ms: { type: 'integer', description: 'How long to wait for the answer. Default 60000.' },
|
|
1368
|
+
},
|
|
1369
|
+
required: ['text'], // ui-copy-ignore
|
|
1370
|
+
additionalProperties: false,
|
|
1371
|
+
},
|
|
1372
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
1373
|
+
timeoutMs: 180_000,
|
|
1374
|
+
isConcurrencySafe: () => false,
|
|
1375
|
+
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the answer. */
|
|
1376
|
+
execute: async (args) => withPage((page) => askInPage(page, args)),
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
name: 'sidecar_review', // ui-copy-ignore
|
|
1380
|
+
description: 'Ask the chat in the page to review a repository and take its review back as a list: builds the message, sends it, waits for the answer, and writes the round down so the next round knows what was already said. Use it for a review round, not for a one-off question (that is sidecar_ask).',
|
|
1381
|
+
parameters: {
|
|
1382
|
+
type: 'object', // ui-copy-ignore
|
|
1383
|
+
properties: {
|
|
1384
|
+
repo: { type: 'string', description: 'Address of the repository to review.' },
|
|
1385
|
+
commit: { type: 'string', description: 'The commit the review is about. Omit for the newest one.' },
|
|
1386
|
+
focus: { type: 'string', description: 'One thing to look at especially.' },
|
|
1387
|
+
diff: { type: 'string', description: 'The change to review, as a diff. Include it when the reviewer cannot open the repository itself.' },
|
|
1388
|
+
ref: { type: 'integer', description: 'The box to type in, from sidecar_view. Omit to use the page\u2019s own chat box.' },
|
|
1389
|
+
timeout_ms: { type: 'integer', description: 'How long to wait for the review. Default 120000.' },
|
|
1390
|
+
},
|
|
1391
|
+
required: ['repo'], // ui-copy-ignore
|
|
1392
|
+
additionalProperties: false,
|
|
1393
|
+
},
|
|
1394
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
1395
|
+
timeoutMs: 240_000,
|
|
1396
|
+
isConcurrencySafe: () => false,
|
|
1397
|
+
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the review. */
|
|
1398
|
+
execute: async (args) => withPage(async (page) => {
|
|
1399
|
+
const repo = String(args?.repo ?? '').trim()
|
|
1400
|
+
if (repo === '') return 'Which repository should the reviewer look at?'
|
|
1401
|
+
const commit = String(args?.commit ?? '').trim()
|
|
1402
|
+
/* What was settled last time travels with the question. */
|
|
1403
|
+
const before = lastRound()
|
|
1404
|
+
const already = Array.isArray(before?.marks) ? before.marks : []
|
|
1405
|
+
const asked = askBrief({ repo, commit, focus: String(args?.focus ?? ''), diff: String(args?.diff ?? ''), already })
|
|
1406
|
+
const started = Date.now()
|
|
1407
|
+
const told = await askInPage(page, { text: asked, ref: args?.ref, timeout_ms: args?.timeout_ms ?? 120_000 })
|
|
1408
|
+
const tookMs = Date.now() - started
|
|
1409
|
+
if (!String(told).startsWith('the answer came back in')) return told
|
|
1410
|
+
const answer = String(told).split('\n').slice(1).join('\n').trim()
|
|
1411
|
+
const { round } = recordRound({ commit, question: asked, answer, tookMs })
|
|
1412
|
+
/* A wall answers too, and its words are not advice: name it rather than
|
|
1413
|
+
call the login screen prose. */
|
|
1414
|
+
const wall = /(sign in|log in|登录|注册|verif|checking your browser)/i.test(answer)
|
|
1415
|
+
/* The question sitting there unanswered is not prose: say that instead. */
|
|
1416
|
+
const busy = asked.slice(0, 24)
|
|
1417
|
+
const waiting = round.points.length === 0 && busy.length >= 8 && answer.includes(busy)
|
|
1418
|
+
const list = round.points.length > 0
|
|
1419
|
+
? round.points.map((point, at) => `${at + 1}. ${point}`).join('\n')
|
|
1420
|
+
: waiting
|
|
1421
|
+
? `the page has the message but has not answered yet; it shows: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
|
|
1422
|
+
: wall
|
|
1423
|
+
? `the page is asking to sign in first, so there is no review yet: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
|
|
1424
|
+
: `It answered in prose; no list to work from. It said: ${answer.replace(/\s+/g, ' ').slice(0, 200)}`
|
|
1425
|
+
const settled = already.filter((mark) => mark.status !== 'open').length
|
|
1426
|
+
const carried = settled === 0 ? '' : `\n(${settled} earlier point${settled === 1 ? '' : 's'} left out as already handled)`
|
|
1427
|
+
return `review round: ${commit === '' ? 'newest commit' : commit.slice(0, 12)} in ${Math.round(tookMs / 100) / 10}s\n${list}${carried}`
|
|
1428
|
+
}),
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
name: 'sidecar_review_verdict', // ui-copy-ignore
|
|
1432
|
+
description: 'Record what happened to each point of the last review round, so the next round leaves settled points out: point numbers are the ones sidecar_review printed, and each mark is fixed, verified (checked in the code and the point does not apply), invalid, or open.',
|
|
1433
|
+
parameters: {
|
|
1434
|
+
type: 'object', // ui-copy-ignore
|
|
1435
|
+
properties: {
|
|
1436
|
+
mark: { // ui-copy-ignore
|
|
1437
|
+
type: 'array',
|
|
1438
|
+
description: 'One entry per point: which point, its status, and the evidence that backs it.',
|
|
1439
|
+
items: { // ui-copy-ignore
|
|
1440
|
+
type: 'object', // ui-copy-ignore
|
|
1441
|
+
properties: { // ui-copy-ignore
|
|
1442
|
+
point: { type: 'integer', description: 'The point number sidecar_review printed, starting at 1.' },
|
|
1443
|
+
status: { type: 'string', enum: MARK_STATUS, description: 'fixed, verified, invalid, or open.' },
|
|
1444
|
+
evidence: { type: 'string', description: 'Where the code shows it: file, test, or commit.' },
|
|
1445
|
+
},
|
|
1446
|
+
required: ['point', 'status'], // ui-copy-ignore
|
|
1447
|
+
additionalProperties: false,
|
|
1448
|
+
},
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
required: ['mark'], // ui-copy-ignore
|
|
1452
|
+
additionalProperties: false,
|
|
1453
|
+
},
|
|
1454
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
1455
|
+
timeoutMs: 10_000,
|
|
1456
|
+
isConcurrencySafe: () => false,
|
|
1457
|
+
/** @param {any} args - arguments. @returns {Promise<{text: string}>} what was recorded. */
|
|
1458
|
+
execute: async (args) => {
|
|
1459
|
+
const written = markRound({ marks: Array.isArray(args?.mark) ? args.mark : [] })
|
|
1460
|
+
if (written === null) return 'No review round to mark: play one with sidecar_review first.'
|
|
1461
|
+
const counts = new Map()
|
|
1462
|
+
for (const mark of written.marks) counts.set(mark.status, (counts.get(mark.status) ?? 0) + 1)
|
|
1463
|
+
const said = [...counts].map(([status, n]) => `${n} ${status}`).join(', ')
|
|
1464
|
+
return `review round ${String(written.round.commit || 'newest').slice(0, 12)}: recorded ${written.marks.length} of ${written.round.points.length} points (${said}).`
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
930
1467
|
{
|
|
931
1468
|
name: 'sidecar_key', // ui-copy-ignore
|
|
932
|
-
description: 'Press one key in the sidecar browser: Enter, Tab, Escape, Backspace, Delete,
|
|
1469
|
+
description: 'Press one key in the sidecar browser: Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, PageUp, PageDown, Space, letters and digits. Join keys with + to hold them together, and hold_ms to keep them down.',
|
|
933
1470
|
parameters: {
|
|
934
1471
|
type: 'object', // ui-copy-ignore
|
|
935
1472
|
properties: {
|
|
936
|
-
key: { type: 'string', description: 'Key name.' },
|
|
1473
|
+
key: { type: 'string', description: 'Key name, or several joined by + to hold them together (ArrowLeft+ArrowUp, Shift+ArrowLeft). Letters and digits work too.' },
|
|
937
1474
|
modifiers: { type: 'string', description: 'Held while pressing: Ctrl, Alt, Shift or Meta, joined by +.' },
|
|
1475
|
+
hold_ms: { type: 'integer', description: 'Keep the key down this long before releasing. Games move while a key is held.' },
|
|
938
1476
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
939
1477
|
},
|
|
940
1478
|
required: ['key'], // ui-copy-ignore
|
|
@@ -948,8 +1486,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
948
1486
|
const name = String(args?.key ?? 'Enter')
|
|
949
1487
|
const held = String(args?.modifiers ?? '')
|
|
950
1488
|
const mods = (held.includes('Alt') ? 1 : 0) + (held.includes('Ctrl') ? 2 : 0) + (held.includes('Meta') ? 4 : 0) + (held.includes('Shift') ? 8 : 0)
|
|
951
|
-
|
|
952
|
-
|
|
1489
|
+
const hold = Math.max(0, Math.min(60_000, Number(args?.hold_ms ?? 0)))
|
|
1490
|
+
const names = name.split('+').map((one) => one.trim()).filter((one) => one !== '')
|
|
1491
|
+
if (names.length > 1) await pressChord(page, names, mods, hold)
|
|
1492
|
+
else await pressKey(page, name, mods, hold)
|
|
1493
|
+
/* A held key is game input: waiting for the page to fall quiet after one
|
|
1494
|
+
costs more than the frame it was meant to drive. */
|
|
1495
|
+
if (hold === 0) await settle(page, 3000)
|
|
953
1496
|
return acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), `key ${name}`)
|
|
954
1497
|
}),
|
|
955
1498
|
},
|
|
@@ -995,7 +1538,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
995
1538
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
|
|
996
1539
|
execute: async (args) => withPage(async (page) => {
|
|
997
1540
|
const spot = await findAgain(page, Number(args?.ref))
|
|
998
|
-
if (spot.gone === true) return REF_GONE
|
|
1541
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
999
1542
|
const cursor = await page.hover({ x: Number(spot.x), y: Number(spot.y) })
|
|
1000
1543
|
await wait(400)
|
|
1001
1544
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2500 }), `hover ${String(args?.ref)} · ${cursor}`)
|
|
@@ -1079,9 +1622,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1079
1622
|
if (steps.length === 0) return 'Give at least one step.'
|
|
1080
1623
|
const report = []
|
|
1081
1624
|
for (const [index, step] of steps.entries()) {
|
|
1082
|
-
const said = await runStep(page, step ?? {}, refs
|
|
1083
|
-
|
|
1084
|
-
|
|
1625
|
+
const said = await runStep(page, step ?? {}, refsFor().refs)
|
|
1626
|
+
const text = typeof said === 'string' ? said : said.text
|
|
1627
|
+
report.push(`${index + 1}. ${text}`)
|
|
1628
|
+
/* A step that never landed makes every later step a guess, so the
|
|
1629
|
+
batch ends here instead of typing into a page that is not there. */
|
|
1630
|
+
if (typeof said !== 'string' && said.blocked === true) {
|
|
1631
|
+
return `${report.join('\n')}\nStopped after step ${index + 1}. Call sidecar_view and plan again from what the page shows now.`
|
|
1632
|
+
}
|
|
1085
1633
|
/* A step can navigate straight onto Chrome's error page — a mistyped
|
|
1086
1634
|
address, or a site that does not answer. The rest of the batch
|
|
1087
1635
|
would then type into "This site can't be reached", so it stops
|
|
@@ -1094,13 +1642,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1094
1642
|
const mode = String(args?.read ?? 'text')
|
|
1095
1643
|
if (mode === 'none') return report.join('\n')
|
|
1096
1644
|
const options = { mode: mode === 'changed' ? 'text' : mode, maxChars: Number(args?.max_chars ?? 3000) }
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1645
|
+
const state = refsFor()
|
|
1646
|
+
const read = await remember(page, state, options)
|
|
1647
|
+
if (mode === 'changed' && state.last !== undefined) {
|
|
1648
|
+
const diff = diffRead(state.last, read, options)
|
|
1649
|
+
state.last = read
|
|
1101
1650
|
return `${report.join('\n')}\n${diff}`
|
|
1102
1651
|
}
|
|
1103
|
-
|
|
1652
|
+
state.last = read
|
|
1104
1653
|
return `${report.join('\n')}\n\n${renderPage(read, options)}`
|
|
1105
1654
|
}),
|
|
1106
1655
|
},
|
|
@@ -1163,9 +1712,10 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1163
1712
|
type: 'object', // ui-copy-ignore
|
|
1164
1713
|
properties: {
|
|
1165
1714
|
ref: { type: 'integer', description: 'Number of the select control from sidecar_view.' },
|
|
1715
|
+
field: { type: 'string', description: 'The dropdown is called this on screen, when you have no number for it.' },
|
|
1166
1716
|
value: { type: 'string', description: 'Option label or value to choose.' },
|
|
1167
1717
|
},
|
|
1168
|
-
required: ['
|
|
1718
|
+
required: ['value'], // ui-copy-ignore
|
|
1169
1719
|
additionalProperties: false,
|
|
1170
1720
|
},
|
|
1171
1721
|
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
@@ -1173,8 +1723,17 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1173
1723
|
isConcurrencySafe: () => false,
|
|
1174
1724
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
|
|
1175
1725
|
execute: async (args) => withPage(async (page) => {
|
|
1726
|
+
if (args?.ref === undefined) {
|
|
1727
|
+
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
|
|
1728
|
+
const chosen = await evaluate(page, selectSpot(String(args.field), String(args?.value ?? '')))
|
|
1729
|
+
const said = typeof chosen === 'string' ? chosen : chosen?.error === undefined ? '' : String(chosen.error)
|
|
1730
|
+
if (said === '' || said.startsWith('no dropdown')) return said === '' ? 'the page did not answer; read it and try again.' : said // ui-copy-ignore
|
|
1731
|
+
await settle(page, 3000)
|
|
1732
|
+
if (args?.read === true) return `${said}\n${await look(page, { mode: 'text', maxChars: 2000 })}`
|
|
1733
|
+
return `${said} · ${await brief(page)}`
|
|
1734
|
+
}
|
|
1176
1735
|
const spot = await findAgain(page, Number(args?.ref))
|
|
1177
|
-
if (spot.gone === true) return REF_GONE
|
|
1736
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1178
1737
|
const wanted = JSON.stringify(String(args?.value ?? ''))
|
|
1179
1738
|
const route = await contextFor(page, Number(args?.ref))
|
|
1180
1739
|
const local = route?.local ?? Number(args?.ref)
|
|
@@ -1221,7 +1780,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1221
1780
|
if (route?.contextId !== undefined) uploadParams.contextId = route.contextId
|
|
1222
1781
|
const handle = await page.cdp.send('Runtime.evaluate', uploadParams, route?.sessionId)
|
|
1223
1782
|
const objectId = handle.result?.objectId
|
|
1224
|
-
if (objectId === undefined) return REF_GONE
|
|
1783
|
+
if (objectId === undefined) return spot.why ?? REF_GONE
|
|
1225
1784
|
const node = await page.cdp.send('DOM.describeNode', { objectId }, route?.sessionId)
|
|
1226
1785
|
const backendNodeId = node?.node?.backendNodeId
|
|
1227
1786
|
if (backendNodeId === undefined) return 'That control cannot take a file.'
|
|
@@ -1232,7 +1791,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1232
1791
|
},
|
|
1233
1792
|
{
|
|
1234
1793
|
name: 'sidecar_shot', // ui-copy-ignore
|
|
1235
|
-
description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path. Default is the visible screen; full takes the whole page, ref takes one control.',
|
|
1794
|
+
description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control.',
|
|
1236
1795
|
parameters: {
|
|
1237
1796
|
type: 'object', // ui-copy-ignore
|
|
1238
1797
|
properties: {
|
|
@@ -1243,25 +1802,109 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1243
1802
|
},
|
|
1244
1803
|
additionalProperties: false,
|
|
1245
1804
|
},
|
|
1246
|
-
output:
|
|
1805
|
+
output: SHOT_OUTPUT, // ui-copy-ignore
|
|
1247
1806
|
timeoutMs: 40_000,
|
|
1248
1807
|
isConcurrencySafe: () => false,
|
|
1249
|
-
/**
|
|
1250
|
-
|
|
1808
|
+
/**
|
|
1809
|
+
* A picture nobody can look at is only a file path: when the route declares
|
|
1810
|
+
* image input, the same call carries the PNG as an attachment so the next
|
|
1811
|
+
* step can be planned from what is actually on screen instead of guessing.
|
|
1812
|
+
* @param {any} args - arguments.
|
|
1813
|
+
* @param {any} [exec] - call context, for the route and its attachments.
|
|
1814
|
+
* @returns {Promise<any>} the saved path, and the picture when available.
|
|
1815
|
+
*/
|
|
1816
|
+
execute: async (args, exec) => {
|
|
1817
|
+
/** @type {{data: string, file: string}|undefined} */
|
|
1818
|
+
let taken
|
|
1819
|
+
const said = await withRead(async (page) => {
|
|
1251
1820
|
const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
|
|
1252
1821
|
let clip = undefined
|
|
1253
1822
|
if (args?.ref !== undefined) {
|
|
1254
1823
|
const spot = await findAgain(page, Number(args.ref))
|
|
1255
|
-
if (spot.gone === true) return REF_GONE
|
|
1256
|
-
|
|
1824
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1825
|
+
/* The picture is of the control, not of a guess around its middle:
|
|
1826
|
+
a canvas can be most of the page, a checkbox a few pixels. */
|
|
1827
|
+
const box = await evaluate(page, bounds(Number(args.ref)))
|
|
1828
|
+
const rect = typeof box === 'string' ? JSON.parse(box) : box
|
|
1829
|
+
if (rect?.gone === true) return spot.why ?? REF_GONE
|
|
1830
|
+
const pad = 8
|
|
1831
|
+
clip = {
|
|
1832
|
+
x: Math.max(0, Math.round(Number(rect.x) - pad)),
|
|
1833
|
+
y: Math.max(0, Math.round(Number(rect.y) - pad)),
|
|
1834
|
+
width: Math.max(8, Math.round(Number(rect.width) + pad * 2)),
|
|
1835
|
+
height: Math.max(8, Math.round(Number(rect.height) + pad * 2)),
|
|
1836
|
+
}
|
|
1257
1837
|
}
|
|
1258
1838
|
const data = await page.shot({ full: args?.full === true, rect: clip, scale })
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
const name = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-${Date.now()}`
|
|
1262
|
-
const file = join(dir, name.endsWith('.png') ? name : `${name}.png`)
|
|
1263
|
-
await writeFile(file, Buffer.from(data, 'base64'))
|
|
1839
|
+
const file = await saveArtifact('shots', data, { name: args?.name, prefix: 'shot' })
|
|
1840
|
+
taken = { data, file }
|
|
1264
1841
|
return file
|
|
1842
|
+
})
|
|
1843
|
+
if (taken === undefined) return said
|
|
1844
|
+
let image
|
|
1845
|
+
try {
|
|
1846
|
+
image = await host.showShot?.(taken.data, taken.file.split('/').pop() ?? 'shot.png', exec) // ui-copy-ignore
|
|
1847
|
+
} catch {
|
|
1848
|
+
image = undefined
|
|
1849
|
+
}
|
|
1850
|
+
/* The route may not carry pictures at all: then the file path is all
|
|
1851
|
+
this call can give, and saying so is better than a silent path the
|
|
1852
|
+
next step assumes was looked at. */
|
|
1853
|
+
if (image === undefined) return { text: `${said.text}\nno picture reached this result — sidecar_view reads the words here, sidecar_grab returns a picture as a file` }
|
|
1854
|
+
return { text: said.text, image }
|
|
1855
|
+
},
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
name: 'sidecar_grab', // ui-copy-ignore
|
|
1859
|
+
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.',
|
|
1860
|
+
parameters: {
|
|
1861
|
+
type: 'object', // ui-copy-ignore
|
|
1862
|
+
properties: {
|
|
1863
|
+
ref: { type: 'integer', description: 'Only the picture at or inside this control.' },
|
|
1864
|
+
name: { type: 'string', description: 'File name without extension.' },
|
|
1865
|
+
count: { type: 'integer', description: 'How many, largest first. Default 1, at most 4.' },
|
|
1866
|
+
},
|
|
1867
|
+
additionalProperties: false,
|
|
1868
|
+
},
|
|
1869
|
+
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
1870
|
+
timeoutMs: 40_000,
|
|
1871
|
+
isConcurrencySafe: () => false,
|
|
1872
|
+
/** @param {any} args - arguments. @returns {Promise<string>} the saved paths, one per line. */
|
|
1873
|
+
execute: async (args) => withRead(async (page) => {
|
|
1874
|
+
const said = await evaluate(page, pictures(args?.ref, args?.count))
|
|
1875
|
+
let found
|
|
1876
|
+
try {
|
|
1877
|
+
found = JSON.parse(typeof said === 'string' ? said : JSON.stringify(said))
|
|
1878
|
+
} catch {
|
|
1879
|
+
found = { pictures: [] }
|
|
1880
|
+
}
|
|
1881
|
+
const rows = []
|
|
1882
|
+
const base = String(args?.name ?? '').replace(/[^\w.-]/g, '')
|
|
1883
|
+
const list = Array.isArray(found?.pictures) ? found.pictures : []
|
|
1884
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
1885
|
+
const item = list[index]
|
|
1886
|
+
const body = String(item?.data ?? '')
|
|
1887
|
+
const mark = body.indexOf(',')
|
|
1888
|
+
if (mark < 0) continue
|
|
1889
|
+
const type = String(item?.type ?? 'image/png')
|
|
1890
|
+
const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
|
|
1891
|
+
const file = await saveArtifact('pictures', body.slice(mark + 1), {
|
|
1892
|
+
name: base === '' ? '' : `${base}${list.length > 1 ? `-${index + 1}` : ''}`,
|
|
1893
|
+
ext: `.${ext}`,
|
|
1894
|
+
prefix: 'picture',
|
|
1895
|
+
})
|
|
1896
|
+
const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
|
|
1897
|
+
/* Say what the picture looks like in words: a model that cannot open
|
|
1898
|
+
a file still needs to know a chart came back blank. */
|
|
1899
|
+
const flat = Number(item.flat) || 0
|
|
1900
|
+
const colors = Number(item.colors) || 0
|
|
1901
|
+
const look = flat >= 0.9 ? 'mostly one colour' : colors <= 3 ? 'a few colours' : 'a picture with detail'
|
|
1902
|
+
rows.push(`${file} ${size} ${look}`)
|
|
1903
|
+
}
|
|
1904
|
+
if (rows.length === 0) {
|
|
1905
|
+
return list.length === 0 ? 'There is no picture on the page.' : 'That picture could not be saved.'
|
|
1906
|
+
}
|
|
1907
|
+
return rows.join('\n')
|
|
1265
1908
|
}),
|
|
1266
1909
|
},
|
|
1267
1910
|
{
|
|
@@ -1275,13 +1918,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1275
1918
|
execute: async () => {
|
|
1276
1919
|
const state = await sidecar.state()
|
|
1277
1920
|
const owner = String(host.owner?.() ?? '') // ui-copy-ignore
|
|
1921
|
+
const session = currentSession()
|
|
1922
|
+
const seats = String(host.seats?.() ?? '?') // ui-copy-ignore
|
|
1923
|
+
const mine = sidecar.pageFor(session)
|
|
1278
1924
|
const rows = [
|
|
1279
1925
|
state.alive ? 'running' : 'stopped',
|
|
1926
|
+
`chat ${session === '' ? '(no chat id)' : session}`,
|
|
1927
|
+
`this chat's tab ${mine === null ? 'none' : String(mine.url)}`, // ui-copy-ignore
|
|
1928
|
+
`tabs in the browser ${String(sidecar.pages.size)}`,
|
|
1929
|
+
`tab list ${[...sidecar.pages.entries()].map(([key, each]) => `${key.slice(-8)}=${String(each.url)}=${each.cdp?.closed === true ? 'closed' : 'open'}`).join(' | ')}`, // ui-copy-ignore
|
|
1930
|
+
`default tab ${String(sidecar.page?.url)}`, // ui-copy-ignore
|
|
1280
1931
|
state.headless === false ? 'showing a window' : 'no window',
|
|
1281
1932
|
`profile ${String(state.profile)}`,
|
|
1282
1933
|
`url ${String(state.url)}`,
|
|
1283
1934
|
`title ${String(state.title)}`,
|
|
1284
1935
|
`viewers ${String(state.viewers)}`,
|
|
1936
|
+
`listening chats ${seats}`, // ui-copy-ignore
|
|
1937
|
+
`mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
|
|
1285
1938
|
owner === '' ? 'no owner' : `owner ${owner}`,
|
|
1286
1939
|
`refs followed ${String(follows.count)}`,
|
|
1287
1940
|
holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
|
|
@@ -1343,7 +1996,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1343
1996
|
let start = { x: Number(args?.x ?? NaN), y: Number(args?.y ?? NaN) }
|
|
1344
1997
|
if (args?.from !== undefined) {
|
|
1345
1998
|
const spot = await findAgain(page, Number(args.from))
|
|
1346
|
-
if (spot.gone === true) return REF_GONE
|
|
1999
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1347
2000
|
start = { x: Number(spot.x), y: Number(spot.y) }
|
|
1348
2001
|
}
|
|
1349
2002
|
const end = { x: Number(args?.to_x ?? NaN), y: Number(args?.to_y ?? NaN) }
|
|
@@ -1388,11 +2041,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1388
2041
|
if (checkRoute?.contextId !== undefined) checkParams.contextId = checkRoute.contextId
|
|
1389
2042
|
const probe = await page.cdp.send('Runtime.evaluate', checkParams, checkRoute?.sessionId)
|
|
1390
2043
|
const now = probe.result?.value
|
|
1391
|
-
if (now === null || now === undefined) return REF_GONE
|
|
2044
|
+
if (now === null || now === undefined) return spot.why ?? REF_GONE
|
|
1392
2045
|
const want = args?.checked === undefined ? !Boolean(now.checked) : args.checked === true
|
|
1393
2046
|
if (Boolean(now.checked) !== want) {
|
|
1394
2047
|
const spot = await findAgain(page, Number(args.ref))
|
|
1395
|
-
if (spot.gone === true) return REF_GONE
|
|
2048
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1396
2049
|
await page.mouse('mouseMoved', { x: Number(spot.x), y: Number(spot.y) })
|
|
1397
2050
|
await page.mouse('mousePressed', { x: Number(spot.x), y: Number(spot.y) })
|
|
1398
2051
|
await page.mouse('mouseReleased', { x: Number(spot.x), y: Number(spot.y) })
|
|
@@ -1614,18 +2267,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1614
2267
|
execute: async (args) => withRawPage(async (page) => {
|
|
1615
2268
|
const action = String(args?.action ?? 'list')
|
|
1616
2269
|
if (action === 'list') {
|
|
1617
|
-
const rows = await sidecar.tabs()
|
|
2270
|
+
const rows = await sidecar.tabs(currentSession())
|
|
1618
2271
|
if (rows.length === 0) return 'no tabs'
|
|
1619
2272
|
return rows.map((row, i) => `${i}${row.current ? ' *' : ''} ${row.title || row.url} · ${row.url}`).join('\n')
|
|
1620
2273
|
}
|
|
1621
2274
|
const index = Number(args?.index ?? 0)
|
|
1622
2275
|
if (action === 'switch') {
|
|
1623
|
-
const next = await sidecar.useTab(index)
|
|
2276
|
+
const next = await sidecar.useTab(index, currentSession())
|
|
1624
2277
|
return `tab ${index} · ${String(next?.url ?? '')}`
|
|
1625
2278
|
}
|
|
1626
2279
|
if (action === 'close') {
|
|
1627
|
-
await sidecar.closeTab(index)
|
|
1628
|
-
return `closed tab ${index} · ${String(sidecar.page?.url ?? '')}`
|
|
2280
|
+
await sidecar.closeTab(index, currentSession())
|
|
2281
|
+
return `closed tab ${index} · ${String((sidecar.pageFor(currentSession()) ?? sidecar.page)?.url ?? '')}`
|
|
1629
2282
|
}
|
|
1630
2283
|
throw new Error(`sidecar: unknown action ${action}`)
|
|
1631
2284
|
}),
|
|
@@ -1641,11 +2294,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1641
2294
|
/** @returns {Promise<{text: string}>} where the PDF is. */
|
|
1642
2295
|
execute: async () => withRawPage(async (page) => {
|
|
1643
2296
|
const data = await page.pdf()
|
|
1644
|
-
|
|
1645
|
-
await mkdir(dir, { recursive: true })
|
|
1646
|
-
const file = join(dir, `sidecar-${Date.now()}.pdf`)
|
|
1647
|
-
await writeFile(file, Buffer.from(data, 'base64'))
|
|
1648
|
-
return file
|
|
2297
|
+
return saveArtifact('pdf', data, { ext: '.pdf', prefix: 'page' })
|
|
1649
2298
|
}),
|
|
1650
2299
|
},
|
|
1651
2300
|
]
|