dsh-inner-browser-use 0.27.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 +376 -0
- package/docs/ROADMAP.md +88 -0
- package/index.js +320 -29
- package/package.json +5 -2
- package/read.js +111 -10
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +635 -83
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, selectSpot, bounds, pictures, 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
|
|
@@ -479,12 +651,22 @@ async function fieldByName(page, words) {
|
|
|
479
651
|
* @param {{clear?: boolean, submit?: boolean}} options - how.
|
|
480
652
|
*/
|
|
481
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))
|
|
482
661
|
await page.mouse('mouseMoved', point)
|
|
662
|
+
await wait(35 + Math.round(Math.random() * 60))
|
|
483
663
|
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
664
|
+
await wait(20 + Math.round(Math.random() * 40))
|
|
484
665
|
await page.mouse('mouseReleased', { ...point, clickCount: 1 })
|
|
485
|
-
await wait(
|
|
666
|
+
await wait(90 + Math.round(Math.random() * 120))
|
|
486
667
|
if (options.clear === true) await clearFocus(page)
|
|
487
|
-
await page.insertText(text)
|
|
668
|
+
if (options.human === false) await page.insertText(text)
|
|
669
|
+
else await page.humanType(text)
|
|
488
670
|
if (options.submit === true) await pressKey(page, 'Enter')
|
|
489
671
|
}
|
|
490
672
|
|
|
@@ -498,8 +680,11 @@ async function writeAt(page, point, text, options = {}) {
|
|
|
498
680
|
*/
|
|
499
681
|
async function followRef(page, ref, known) {
|
|
500
682
|
const spot = await locate(page, ref)
|
|
501
|
-
if (spot.gone !== true) return spot
|
|
502
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
|
+
}
|
|
503
688
|
if (was === undefined) return spot
|
|
504
689
|
const want = identityOf(was)
|
|
505
690
|
if (want.name === '') return spot
|
|
@@ -512,12 +697,40 @@ async function followRef(page, ref, known) {
|
|
|
512
697
|
return { ...next, followed: true, was: want.name }
|
|
513
698
|
}
|
|
514
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
|
+
|
|
515
728
|
async function runStep(page, step, known) {
|
|
516
729
|
if (step.ref !== undefined) {
|
|
517
730
|
if (step.tap !== undefined || step.text !== undefined) {
|
|
518
731
|
const lead = await coverLead(page, Number(step.ref))
|
|
519
732
|
const spot = await followRef(page, Number(step.ref), known)
|
|
520
|
-
if (spot.gone === true) return `ref ${String(step.ref)}: ${REF_GONE}`
|
|
733
|
+
if (spot.gone === true) return stuck(`ref ${String(step.ref)}: ${REF_GONE}`)
|
|
521
734
|
const after = spot.followed === true ? ` (it was redrawn under a new number, used where it is now)` : ''
|
|
522
735
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
523
736
|
await page.mouse('mouseMoved', point)
|
|
@@ -526,17 +739,17 @@ async function runStep(page, step, known) {
|
|
|
526
739
|
if (step.tap !== undefined) return `${lead}clicked ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
|
|
527
740
|
await wait(80)
|
|
528
741
|
if (step.clear === true) await clearFocus(page)
|
|
529
|
-
await page.
|
|
742
|
+
await page.humanType(String(step.text))
|
|
530
743
|
if (step.submit === true) await pressKey(page, 'Enter')
|
|
531
744
|
return `${lead}typed into ${String(step.ref)} ${String(spot.name ?? '')}`.trim() + after
|
|
532
745
|
}
|
|
533
746
|
}
|
|
534
747
|
if (step.tap !== undefined && !/^\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*$/.test(String(step.tap))) {
|
|
535
748
|
const hit = await findWords(page, String(step.tap))
|
|
536
|
-
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))}`)
|
|
537
750
|
const lead = await coverLead(page, hit.ref)
|
|
538
751
|
const spot = await locate(page, hit.ref)
|
|
539
|
-
if (spot.gone === true) return `ref ${String(hit.ref)}: ${REF_GONE}`
|
|
752
|
+
if (spot.gone === true) return stuck(`ref ${String(hit.ref)}: ${REF_GONE}`)
|
|
540
753
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
541
754
|
await page.mouse('mouseMoved', point)
|
|
542
755
|
await page.mouse('mousePressed', { ...point, clickCount: 1 })
|
|
@@ -547,7 +760,7 @@ async function runStep(page, step, known) {
|
|
|
547
760
|
/* The words may sit on the field or in a div above a bare input. Only a
|
|
548
761
|
click that lands on the field itself can be trusted with the typing. */
|
|
549
762
|
const target = await fieldByName(page, String(step.field))
|
|
550
|
-
if (target === null) return `${String(step.field)}: ${REF_GONE}`
|
|
763
|
+
if (target === null) return stuck(`${String(step.field)}: ${REF_GONE}`)
|
|
551
764
|
await writeAt(page, { x: target.x, y: target.y }, String(step.text ?? ''), { clear: step.clear, submit: step.submit })
|
|
552
765
|
return `${target.lead}typed into "${target.name.slice(0, 60)}"`
|
|
553
766
|
}
|
|
@@ -561,7 +774,7 @@ async function runStep(page, step, known) {
|
|
|
561
774
|
}
|
|
562
775
|
if (step.text !== undefined) {
|
|
563
776
|
if (step.clear === true) await clearFocus(page)
|
|
564
|
-
await page.
|
|
777
|
+
await page.humanType(String(step.text))
|
|
565
778
|
if (step.submit === true) await pressKey(page, 'Enter')
|
|
566
779
|
return `typed ${String(step.text).slice(0, 60)}`
|
|
567
780
|
}
|
|
@@ -577,17 +790,17 @@ async function runStep(page, step, known) {
|
|
|
577
790
|
if (step.wait_text !== undefined) {
|
|
578
791
|
const needle = JSON.stringify(String(step.wait_text))
|
|
579
792
|
const ok = await until(page, `document.body ? document.body.innerText.includes(${needle}) : false`, Number(step.timeout ?? 8000))
|
|
580
|
-
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)}`)
|
|
581
794
|
}
|
|
582
795
|
if (step.wait_gone !== undefined) {
|
|
583
796
|
const needle = JSON.stringify(String(step.wait_gone))
|
|
584
797
|
const ok = await until(page, `document.body ? !document.body.innerText.includes(${needle}) : true`, Number(step.timeout ?? 8000))
|
|
585
|
-
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`)
|
|
586
799
|
}
|
|
587
800
|
if (step.wait_url !== undefined) {
|
|
588
801
|
const needle = JSON.stringify(String(step.wait_url))
|
|
589
802
|
const ok = await until(page, `location.href.includes(${needle})`, Number(step.timeout ?? 8000))
|
|
590
|
-
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`)
|
|
591
804
|
}
|
|
592
805
|
if (step.open !== undefined) {
|
|
593
806
|
const landed = await page.navigate(String(step.open))
|
|
@@ -624,6 +837,22 @@ async function remember(page, refs, options) {
|
|
|
624
837
|
return read
|
|
625
838
|
}
|
|
626
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
|
+
|
|
627
856
|
/**
|
|
628
857
|
* Say only what changed since the previous read.
|
|
629
858
|
* @param {any} previous - the read before.
|
|
@@ -719,7 +948,140 @@ export async function extractRows(page, args) {
|
|
|
719
948
|
return `${rows.length} of ${Number(value.total)} rows matching ${select}:\n${said.join('\n')}${more}`
|
|
720
949
|
}
|
|
721
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
|
+
|
|
722
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)
|
|
723
1085
|
/* Reading, clicking and typing on Chrome's own error page is not driving the
|
|
724
1086
|
site: the text there is Chrome talking, the buttons reload. Those calls are
|
|
725
1087
|
refused with the error code instead. The tools that report the browser
|
|
@@ -727,14 +1089,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
727
1089
|
run, because they are how a stuck person finds out what happened. */
|
|
728
1090
|
const withRawPage = async (work) => {
|
|
729
1091
|
if (!sidecar.alive) return { text: SIDECAR_OFF }
|
|
730
|
-
const page = await sidecar.ensure()
|
|
1092
|
+
const page = await sidecar.ensure({ session: currentSession() })
|
|
731
1093
|
const text = await work(page)
|
|
732
1094
|
return { text: text + personNote(sidecar) }
|
|
733
1095
|
}
|
|
734
1096
|
/** Looking is still useful while the person has the page. */
|
|
735
1097
|
const withRead = async (work) => {
|
|
736
|
-
|
|
737
|
-
|
|
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() })
|
|
738
1109
|
const dead = await unreachable(page)
|
|
739
1110
|
if (dead !== '') return { text: dead + personNote(sidecar) }
|
|
740
1111
|
const text = await work(page)
|
|
@@ -747,7 +1118,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
747
1118
|
return withRead(work)
|
|
748
1119
|
}
|
|
749
1120
|
const look = async (page, options) => {
|
|
750
|
-
const read = await remember(page,
|
|
1121
|
+
const read = await remember(page, refsFor(), options)
|
|
751
1122
|
const said = renderPage(read, options)
|
|
752
1123
|
return isCheckPage(read) ? `${said}\n${CHECK_NOTE}` : said
|
|
753
1124
|
}
|
|
@@ -768,8 +1139,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
768
1139
|
*/
|
|
769
1140
|
const findAgain = async (page, ref) => {
|
|
770
1141
|
const spot = await locate(page, ref)
|
|
771
|
-
|
|
772
|
-
|
|
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
|
+
}
|
|
773
1147
|
if (known === undefined) return spot
|
|
774
1148
|
const want = identityOf(known)
|
|
775
1149
|
if (want.name === '') return spot
|
|
@@ -801,7 +1175,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
801
1175
|
isConcurrencySafe: () => false,
|
|
802
1176
|
/** @param {any} args - arguments. @param {any} exec - tool run. @returns {Promise<{text: string}>} what the page is. */
|
|
803
1177
|
execute: async (args, exec) => {
|
|
804
|
-
const
|
|
1178
|
+
const who = String(exec?.agent?.id ?? '')
|
|
1179
|
+
const page = await sidecar.ensure({ url: args?.url, headless: args?.headless, session: who })
|
|
805
1180
|
if (args?.url === undefined) await page.readTitle()
|
|
806
1181
|
/* A window on the person's screen is their business, not a surprise: say
|
|
807
1182
|
it, so nobody wonders what popped up. A site that refuses a windowless
|
|
@@ -809,7 +1184,6 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
809
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' : ''
|
|
810
1185
|
/* The tab appears in the calling chat and nowhere else: the person may
|
|
811
1186
|
be reading a different chat, and a browser there would be a surprise. */
|
|
812
|
-
const who = String(exec?.agent?.id ?? '')
|
|
813
1187
|
if (who !== '') host.want?.(who) // ui-copy-ignore
|
|
814
1188
|
await settle(page, 8000)
|
|
815
1189
|
const dead = await unreachable(page)
|
|
@@ -838,10 +1212,29 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
838
1212
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the read. */
|
|
839
1213
|
execute: async (args) => withRead(async (page) => {
|
|
840
1214
|
const options = { mode: String(args?.mode ?? 'text'), maxChars: Number(args?.max_chars ?? 4600) }
|
|
841
|
-
|
|
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)
|
|
842
1220
|
const note = isCheckPage(read) ? `\n${CHECK_NOTE}` : ''
|
|
843
|
-
if (args?.changed_only === true &&
|
|
844
|
-
|
|
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)
|
|
845
1238
|
return renderPage(read, options) + note
|
|
846
1239
|
}),
|
|
847
1240
|
},
|
|
@@ -857,6 +1250,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
857
1250
|
y: { type: 'integer', description: 'Viewport y, when clicking a point.' },
|
|
858
1251
|
double: { type: 'boolean', description: 'Double-click.' },
|
|
859
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.' },
|
|
860
1254
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
861
1255
|
},
|
|
862
1256
|
additionalProperties: false,
|
|
@@ -871,7 +1265,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
871
1265
|
let followed = ''
|
|
872
1266
|
if (args?.ref !== undefined) {
|
|
873
1267
|
const spot = await findAgain(page, Number(args.ref))
|
|
874
|
-
if (spot.gone === true) return REF_GONE
|
|
1268
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
875
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`
|
|
876
1270
|
point = { x: Number(spot.x), y: Number(spot.y) }
|
|
877
1271
|
} else if (String(args?.text ?? '') !== '') {
|
|
@@ -886,6 +1280,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
886
1280
|
if (args?.ref !== undefined && button === 'left') {
|
|
887
1281
|
const gate = await clearCover(page, Number(args.ref))
|
|
888
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
|
+
}
|
|
889
1288
|
if (gate.cleared && cover !== '') {
|
|
890
1289
|
const again = await locate(page, Number(args.ref))
|
|
891
1290
|
if (again.gone !== true) point = { x: Number(again.x), y: Number(again.y) }
|
|
@@ -893,6 +1292,8 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
893
1292
|
}
|
|
894
1293
|
await page.mouse('mouseMoved', point)
|
|
895
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))
|
|
896
1297
|
await page.mouse('mouseReleased', { ...point, clickCount: args?.double === true ? 2 : 1, button })
|
|
897
1298
|
await settle(page)
|
|
898
1299
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 3000 }), `click ${label}`)
|
|
@@ -934,8 +1335,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
934
1335
|
if (args?.ref !== undefined) {
|
|
935
1336
|
const gate = await clearCover(page, Number(args.ref))
|
|
936
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
|
+
}
|
|
937
1341
|
const spot = await findAgain(page, Number(args.ref))
|
|
938
|
-
if (spot.gone === true) return REF_GONE + cover
|
|
1342
|
+
if (spot.gone === true) return spot.why ?? REF_GONE + cover
|
|
939
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`
|
|
940
1344
|
const point = { x: Number(spot.x), y: Number(spot.y) }
|
|
941
1345
|
await page.mouse('mouseMoved', point)
|
|
@@ -944,21 +1348,131 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
944
1348
|
await wait(80)
|
|
945
1349
|
}
|
|
946
1350
|
if (args?.clear === true) await clearFocus(page)
|
|
947
|
-
await page.
|
|
1351
|
+
await page.humanType(String(args?.text ?? ''))
|
|
948
1352
|
if (args?.submit === true) await pressKey(page, 'Enter')
|
|
949
1353
|
await settle(page)
|
|
950
1354
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), 'type')
|
|
951
1355
|
return said + cover + (followed === '' ? '' : ` (${followed})`) // ui-copy-ignore
|
|
952
1356
|
}),
|
|
953
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
|
+
},
|
|
954
1467
|
{
|
|
955
1468
|
name: 'sidecar_key', // ui-copy-ignore
|
|
956
|
-
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.',
|
|
957
1470
|
parameters: {
|
|
958
1471
|
type: 'object', // ui-copy-ignore
|
|
959
1472
|
properties: {
|
|
960
|
-
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.' },
|
|
961
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.' },
|
|
962
1476
|
read: { type: 'boolean', description: 'Read the page after the action, instead of a one-line confirmation.' },
|
|
963
1477
|
},
|
|
964
1478
|
required: ['key'], // ui-copy-ignore
|
|
@@ -972,8 +1486,13 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
972
1486
|
const name = String(args?.key ?? 'Enter')
|
|
973
1487
|
const held = String(args?.modifiers ?? '')
|
|
974
1488
|
const mods = (held.includes('Alt') ? 1 : 0) + (held.includes('Ctrl') ? 2 : 0) + (held.includes('Meta') ? 4 : 0) + (held.includes('Shift') ? 8 : 0)
|
|
975
|
-
|
|
976
|
-
|
|
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)
|
|
977
1496
|
return acted(page, args, () => look(page, { mode: 'text', maxChars: 2000 }), `key ${name}`)
|
|
978
1497
|
}),
|
|
979
1498
|
},
|
|
@@ -1019,7 +1538,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1019
1538
|
/** @param {any} args - arguments. @returns {Promise<{text: string}>} the page after. */
|
|
1020
1539
|
execute: async (args) => withPage(async (page) => {
|
|
1021
1540
|
const spot = await findAgain(page, Number(args?.ref))
|
|
1022
|
-
if (spot.gone === true) return REF_GONE
|
|
1541
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1023
1542
|
const cursor = await page.hover({ x: Number(spot.x), y: Number(spot.y) })
|
|
1024
1543
|
await wait(400)
|
|
1025
1544
|
const said = await acted(page, args, () => look(page, { mode: 'text', maxChars: 2500 }), `hover ${String(args?.ref)} · ${cursor}`)
|
|
@@ -1103,9 +1622,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1103
1622
|
if (steps.length === 0) return 'Give at least one step.'
|
|
1104
1623
|
const report = []
|
|
1105
1624
|
for (const [index, step] of steps.entries()) {
|
|
1106
|
-
const said = await runStep(page, step ?? {}, refs
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
+
}
|
|
1109
1633
|
/* A step can navigate straight onto Chrome's error page — a mistyped
|
|
1110
1634
|
address, or a site that does not answer. The rest of the batch
|
|
1111
1635
|
would then type into "This site can't be reached", so it stops
|
|
@@ -1118,13 +1642,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1118
1642
|
const mode = String(args?.read ?? 'text')
|
|
1119
1643
|
if (mode === 'none') return report.join('\n')
|
|
1120
1644
|
const options = { mode: mode === 'changed' ? 'text' : mode, maxChars: Number(args?.max_chars ?? 3000) }
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1125
1650
|
return `${report.join('\n')}\n${diff}`
|
|
1126
1651
|
}
|
|
1127
|
-
|
|
1652
|
+
state.last = read
|
|
1128
1653
|
return `${report.join('\n')}\n\n${renderPage(read, options)}`
|
|
1129
1654
|
}),
|
|
1130
1655
|
},
|
|
@@ -1208,7 +1733,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1208
1733
|
return `${said} · ${await brief(page)}`
|
|
1209
1734
|
}
|
|
1210
1735
|
const spot = await findAgain(page, Number(args?.ref))
|
|
1211
|
-
if (spot.gone === true) return REF_GONE
|
|
1736
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1212
1737
|
const wanted = JSON.stringify(String(args?.value ?? ''))
|
|
1213
1738
|
const route = await contextFor(page, Number(args?.ref))
|
|
1214
1739
|
const local = route?.local ?? Number(args?.ref)
|
|
@@ -1255,7 +1780,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1255
1780
|
if (route?.contextId !== undefined) uploadParams.contextId = route.contextId
|
|
1256
1781
|
const handle = await page.cdp.send('Runtime.evaluate', uploadParams, route?.sessionId)
|
|
1257
1782
|
const objectId = handle.result?.objectId
|
|
1258
|
-
if (objectId === undefined) return REF_GONE
|
|
1783
|
+
if (objectId === undefined) return spot.why ?? REF_GONE
|
|
1259
1784
|
const node = await page.cdp.send('DOM.describeNode', { objectId }, route?.sessionId)
|
|
1260
1785
|
const backendNodeId = node?.node?.backendNodeId
|
|
1261
1786
|
if (backendNodeId === undefined) return 'That control cannot take a file.'
|
|
@@ -1266,7 +1791,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1266
1791
|
},
|
|
1267
1792
|
{
|
|
1268
1793
|
name: 'sidecar_shot', // ui-copy-ignore
|
|
1269
|
-
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.',
|
|
1270
1795
|
parameters: {
|
|
1271
1796
|
type: 'object', // ui-copy-ignore
|
|
1272
1797
|
properties: {
|
|
@@ -1277,21 +1802,31 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1277
1802
|
},
|
|
1278
1803
|
additionalProperties: false,
|
|
1279
1804
|
},
|
|
1280
|
-
output:
|
|
1805
|
+
output: SHOT_OUTPUT, // ui-copy-ignore
|
|
1281
1806
|
timeoutMs: 40_000,
|
|
1282
1807
|
isConcurrencySafe: () => false,
|
|
1283
|
-
/**
|
|
1284
|
-
|
|
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) => {
|
|
1285
1820
|
const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
|
|
1286
1821
|
let clip = undefined
|
|
1287
1822
|
if (args?.ref !== undefined) {
|
|
1288
1823
|
const spot = await findAgain(page, Number(args.ref))
|
|
1289
|
-
if (spot.gone === true) return REF_GONE
|
|
1824
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1290
1825
|
/* The picture is of the control, not of a guess around its middle:
|
|
1291
1826
|
a canvas can be most of the page, a checkbox a few pixels. */
|
|
1292
1827
|
const box = await evaluate(page, bounds(Number(args.ref)))
|
|
1293
1828
|
const rect = typeof box === 'string' ? JSON.parse(box) : box
|
|
1294
|
-
if (rect?.gone === true) return REF_GONE
|
|
1829
|
+
if (rect?.gone === true) return spot.why ?? REF_GONE
|
|
1295
1830
|
const pad = 8
|
|
1296
1831
|
clip = {
|
|
1297
1832
|
x: Math.max(0, Math.round(Number(rect.x) - pad)),
|
|
@@ -1301,13 +1836,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1301
1836
|
}
|
|
1302
1837
|
}
|
|
1303
1838
|
const data = await page.shot({ full: args?.full === true, rect: clip, scale })
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
const name = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-${Date.now()}`
|
|
1307
|
-
const file = join(dir, name.endsWith('.png') ? name : `${name}.png`)
|
|
1308
|
-
await writeFile(file, Buffer.from(data, 'base64'))
|
|
1839
|
+
const file = await saveArtifact('shots', data, { name: args?.name, prefix: 'shot' })
|
|
1840
|
+
taken = { data, file }
|
|
1309
1841
|
return file
|
|
1310
|
-
|
|
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
|
+
},
|
|
1311
1856
|
},
|
|
1312
1857
|
{
|
|
1313
1858
|
name: 'sidecar_grab', // ui-copy-ignore
|
|
@@ -1334,9 +1879,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1334
1879
|
found = { pictures: [] }
|
|
1335
1880
|
}
|
|
1336
1881
|
const rows = []
|
|
1337
|
-
const
|
|
1338
|
-
await mkdir(dir, { recursive: true })
|
|
1339
|
-
const base = String(args?.name ?? '').replace(/[^\w.-]/g, '') || `sidecar-picture-${Date.now()}`
|
|
1882
|
+
const base = String(args?.name ?? '').replace(/[^\w.-]/g, '')
|
|
1340
1883
|
const list = Array.isArray(found?.pictures) ? found.pictures : []
|
|
1341
1884
|
for (let index = 0; index < list.length; index += 1) {
|
|
1342
1885
|
const item = list[index]
|
|
@@ -1345,8 +1888,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1345
1888
|
if (mark < 0) continue
|
|
1346
1889
|
const type = String(item?.type ?? 'image/png')
|
|
1347
1890
|
const ext = type.includes('jpeg') ? 'jpg' : type.includes('webp') ? 'webp' : type.includes('svg') ? 'svg' : 'png'
|
|
1348
|
-
const file =
|
|
1349
|
-
|
|
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
|
+
})
|
|
1350
1896
|
const size = `${Math.round(Number(item.width) || 0)}x${Math.round(Number(item.height) || 0)}`
|
|
1351
1897
|
/* Say what the picture looks like in words: a model that cannot open
|
|
1352
1898
|
a file still needs to know a chart came back blank. */
|
|
@@ -1372,13 +1918,23 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1372
1918
|
execute: async () => {
|
|
1373
1919
|
const state = await sidecar.state()
|
|
1374
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)
|
|
1375
1924
|
const rows = [
|
|
1376
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
|
|
1377
1931
|
state.headless === false ? 'showing a window' : 'no window',
|
|
1378
1932
|
`profile ${String(state.profile)}`,
|
|
1379
1933
|
`url ${String(state.url)}`,
|
|
1380
1934
|
`title ${String(state.title)}`,
|
|
1381
1935
|
`viewers ${String(state.viewers)}`,
|
|
1936
|
+
`listening chats ${seats}`, // ui-copy-ignore
|
|
1937
|
+
`mirror owner ${owner === '' ? 'none' : owner}`, // ui-copy-ignore
|
|
1382
1938
|
owner === '' ? 'no owner' : `owner ${owner}`,
|
|
1383
1939
|
`refs followed ${String(follows.count)}`,
|
|
1384
1940
|
holdText(sidecar) === '' ? 'person free' : `person using it (${String(Math.ceil(Number(sidecar.holding()) / 1000))} s left)`,
|
|
@@ -1440,7 +1996,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1440
1996
|
let start = { x: Number(args?.x ?? NaN), y: Number(args?.y ?? NaN) }
|
|
1441
1997
|
if (args?.from !== undefined) {
|
|
1442
1998
|
const spot = await findAgain(page, Number(args.from))
|
|
1443
|
-
if (spot.gone === true) return REF_GONE
|
|
1999
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1444
2000
|
start = { x: Number(spot.x), y: Number(spot.y) }
|
|
1445
2001
|
}
|
|
1446
2002
|
const end = { x: Number(args?.to_x ?? NaN), y: Number(args?.to_y ?? NaN) }
|
|
@@ -1485,11 +2041,11 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1485
2041
|
if (checkRoute?.contextId !== undefined) checkParams.contextId = checkRoute.contextId
|
|
1486
2042
|
const probe = await page.cdp.send('Runtime.evaluate', checkParams, checkRoute?.sessionId)
|
|
1487
2043
|
const now = probe.result?.value
|
|
1488
|
-
if (now === null || now === undefined) return REF_GONE
|
|
2044
|
+
if (now === null || now === undefined) return spot.why ?? REF_GONE
|
|
1489
2045
|
const want = args?.checked === undefined ? !Boolean(now.checked) : args.checked === true
|
|
1490
2046
|
if (Boolean(now.checked) !== want) {
|
|
1491
2047
|
const spot = await findAgain(page, Number(args.ref))
|
|
1492
|
-
if (spot.gone === true) return REF_GONE
|
|
2048
|
+
if (spot.gone === true) return spot.why ?? REF_GONE
|
|
1493
2049
|
await page.mouse('mouseMoved', { x: Number(spot.x), y: Number(spot.y) })
|
|
1494
2050
|
await page.mouse('mousePressed', { x: Number(spot.x), y: Number(spot.y) })
|
|
1495
2051
|
await page.mouse('mouseReleased', { x: Number(spot.x), y: Number(spot.y) })
|
|
@@ -1711,18 +2267,18 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1711
2267
|
execute: async (args) => withRawPage(async (page) => {
|
|
1712
2268
|
const action = String(args?.action ?? 'list')
|
|
1713
2269
|
if (action === 'list') {
|
|
1714
|
-
const rows = await sidecar.tabs()
|
|
2270
|
+
const rows = await sidecar.tabs(currentSession())
|
|
1715
2271
|
if (rows.length === 0) return 'no tabs'
|
|
1716
2272
|
return rows.map((row, i) => `${i}${row.current ? ' *' : ''} ${row.title || row.url} · ${row.url}`).join('\n')
|
|
1717
2273
|
}
|
|
1718
2274
|
const index = Number(args?.index ?? 0)
|
|
1719
2275
|
if (action === 'switch') {
|
|
1720
|
-
const next = await sidecar.useTab(index)
|
|
2276
|
+
const next = await sidecar.useTab(index, currentSession())
|
|
1721
2277
|
return `tab ${index} · ${String(next?.url ?? '')}`
|
|
1722
2278
|
}
|
|
1723
2279
|
if (action === 'close') {
|
|
1724
|
-
await sidecar.closeTab(index)
|
|
1725
|
-
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 ?? '')}`
|
|
1726
2282
|
}
|
|
1727
2283
|
throw new Error(`sidecar: unknown action ${action}`)
|
|
1728
2284
|
}),
|
|
@@ -1738,11 +2294,7 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
|
|
|
1738
2294
|
/** @returns {Promise<{text: string}>} where the PDF is. */
|
|
1739
2295
|
execute: async () => withRawPage(async (page) => {
|
|
1740
2296
|
const data = await page.pdf()
|
|
1741
|
-
|
|
1742
|
-
await mkdir(dir, { recursive: true })
|
|
1743
|
-
const file = join(dir, `sidecar-${Date.now()}.pdf`)
|
|
1744
|
-
await writeFile(file, Buffer.from(data, 'base64'))
|
|
1745
|
-
return file
|
|
2297
|
+
return saveArtifact('pdf', data, { ext: '.pdf', prefix: 'page' })
|
|
1746
2298
|
}),
|
|
1747
2299
|
},
|
|
1748
2300
|
]
|