@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/python.js ADDED
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Finding a Python 3.12+ and building the managed environment from it.
3
+ *
4
+ * The engine is a Python package (`jev_ultrafast`) with its own Python dependencies
5
+ * (`browser_harness`), and nothing npm installs builds a Python environment. So the server treats
6
+ * the engine as a runtime prerequisite it checks and — when it owns the environment — builds:
7
+ * this module is the bottom half of that, and answers three questions. Which `uv` exists, which
8
+ * `uv`-managed or system Python exists, and can one of them create a virtualenv for the sidecar.
9
+ *
10
+ * `uv` is preferred because it is the cheap path in both directions: `uv venv --python 3.12` also
11
+ * works on a machine with no suitable Python at all (uv may fetch one), and its `uv pip install`
12
+ * reads the same hash-pinned requirements file as pip does. A Python 3.12+ with no uv is the other
13
+ * supported shape: `python -m venv` plus pip. One rule matters more than the strategy: every
14
+ * interpreter is **probed** before it is used, so a macOS `python3` stub, an app-store shim, or a
15
+ * Python older than 3.12 is reported instead of being handed to `venv`.
16
+ *
17
+ * @module browser-use-mcp/python
18
+ */
19
+
20
+ import { spawn } from 'node:child_process'
21
+ import { rmSync, statSync } from 'node:fs'
22
+ import { delimiter, join } from 'node:path'
23
+
24
+ /** The oldest Python the engine and its sidecar run on; both declare `>=3.12`. */
25
+ export const PYTHON_MINIMUM = '3.12'
26
+
27
+ const MINIMUM = [3, 12]
28
+
29
+ /** How long one interpreter has to answer the version probe before it is passed over. */
30
+ export const PROBE_TIMEOUT_MS = 15000
31
+
32
+ /** How long `python -m venv` may take when the caller names no budget. */
33
+ export const VENV_TIMEOUT_MS = 120000
34
+
35
+ /** How long uv may take: it is allowed to download a CPython before it creates the environment. */
36
+ export const UV_VENV_TIMEOUT_MS = 300000
37
+
38
+ /**
39
+ * The Python uv is asked to create the managed environment with.
40
+ *
41
+ * A fixed version rather than the newest installed one: the requirements are pinned and tested on
42
+ * 3.12, and uv finds that version on PATH or fetches it, so the same environment is built on every
43
+ * machine instead of depending on which Pythons happen to be newest.
44
+ */
45
+ export const ENVIRONMENT_PYTHON_VERSION = '3.12'
46
+
47
+ const PYTHON_INSTALLER = process.platform === 'win32'
48
+ ? 'winget install Python.Python.3.12'
49
+ : process.platform === 'darwin'
50
+ ? 'brew install python@3.12'
51
+ : 'your distribution\u2019s python3.12 package (apt install python3.12 python3.12-venv)'
52
+
53
+ const UV_INSTALLER = process.platform === 'win32'
54
+ ? 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'
55
+ : 'curl -LsSf https://astral.sh/uv/install.sh | sh (or brew install uv)'
56
+
57
+ /** The fix for "nothing on this machine can build the environment". */
58
+ const NOTHING_TO_BUILD_WITH = `install Python ${PYTHON_MINIMUM}+ (${PYTHON_INSTALLER}) or uv (${UV_INSTALLER})`
59
+
60
+ const PYTHON_VENV_FIX = `that Python cannot build a virtualenv: install its venv package (for example `
61
+ + `python3.12-venv on Linux), or uv (${UV_INSTALLER})`
62
+
63
+ function isFile(path) {
64
+ try {
65
+ return statSync(path).isFile()
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The `uv` binary on PATH, or `undefined`.
73
+ *
74
+ * PATH is the only place it is looked for on purpose: an installer puts it somewhere the user's
75
+ * shell can see, and inventing absolute locations would make the search depend on this machine.
76
+ * (The Windows installer ships `uv.exe`, hence the platform name.)
77
+ * @param {NodeJS.ProcessEnv} [env] - environment whose PATH is searched, `process.env` by default.
78
+ * @returns {string|undefined}
79
+ */
80
+ export function findUv(env = process.env) {
81
+ const name = process.platform === 'win32' ? 'uv.exe' : 'uv'
82
+ for (const directory of String(env.PATH ?? '').split(delimiter)) {
83
+ if (directory === '') continue
84
+ const candidate = join(directory, name)
85
+ if (isFile(candidate)) return candidate
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ /**
91
+ * The interpreter inside a virtualenv.
92
+ * @param {string} environment - the virtualenv directory.
93
+ * @returns {string}
94
+ */
95
+ export function environmentPython(environment) {
96
+ return process.platform === 'win32'
97
+ ? join(environment, 'Scripts', 'python.exe')
98
+ : join(environment, 'bin', 'python')
99
+ }
100
+
101
+ /**
102
+ * The interpreter names a stock machine may expose on PATH.
103
+ *
104
+ * Versioned names first, highest first: on POSIX the newest installed Python is the likeliest to
105
+ * satisfy `>=3.12` and to be a real interpreter rather than a shim. Windows installs expose one
106
+ * `python.exe` (the versioned names are a POSIX convention), which is also why the Windows path
107
+ * can only ever use "the base Python".
108
+ */
109
+ function systemPythonNames() {
110
+ return process.platform === 'win32'
111
+ ? ['python.exe']
112
+ : ['python3.14', 'python3.13', 'python3.12', 'python3']
113
+ }
114
+
115
+ /**
116
+ * The ways this machine can build the environment, best first, each with the reason it is here.
117
+ *
118
+ * The configured `pythonPath`, when there is one, is an explicit user choice and comes first. uv
119
+ * comes next: it is not an interpreter — `probePython` must never be pointed at it — but it is the
120
+ * cheapest environment builder, because `uv venv --python 3.12` pins the version instead of taking
121
+ * whatever a PATH entry happens to be. The system Pythons come last, still in version order: they
122
+ * are what a machine without uv has, and each is probed before `createEnvironment` uses it.
123
+ *
124
+ * There are no absolute paths in this list; every system candidate is resolved through PATH by the
125
+ * spawn that probes it, which is what keeps the same code correct under Homebrew, pyenv, a Windows
126
+ * installer, or a shell that prepends its own bin directory.
127
+ * @param {object} [config] - resolved server configuration; only `config.pythonPath` is read.
128
+ * @returns {{command: string, prefix: string[], source: string}[]}
129
+ */
130
+ export function basePythons(config = {}) {
131
+ const candidates = []
132
+ const add = (command, prefix, source) => {
133
+ if (candidates.some(item => item.command === command && item.prefix.join(' ') === prefix.join(' '))) return
134
+ candidates.push({ command, prefix, source })
135
+ }
136
+ const configured = String(config.pythonPath ?? '').trim()
137
+ if (configured !== '') add(configured, [], 'pythonPath')
138
+ const uv = findUv()
139
+ if (uv !== undefined) add(uv, [], 'uv')
140
+ for (const name of systemPythonNames()) add(name, [], 'system python')
141
+ return candidates
142
+ }
143
+
144
+ /** Whether a probe's version is new enough to run the engine. */
145
+ function supported(probe) {
146
+ if (probe.major !== MINIMUM[0]) return probe.major > MINIMUM[0]
147
+ return probe.minor >= MINIMUM[1]
148
+ }
149
+
150
+ /** What to ask an interpreter: its own version, in the shape the rest of this module reads. */
151
+ const VERSION_PROBE = [
152
+ 'import json, sys',
153
+ 'json.dump({"version": ".".join(str(part) for part in sys.version_info[:3]),',
154
+ ' "major": sys.version_info[0], "minor": sys.version_info[1]}, sys.stdout)',
155
+ ].join('\n')
156
+
157
+ /**
158
+ * Ask one interpreter what version it is.
159
+ *
160
+ * The probe is deliberately tiny: spawning a Python is the expensive part, and an interpreter that
161
+ * cannot answer this simple program cannot be trusted to build an environment either. Timeouts,
162
+ * spawn failures, and nonzero exits all become a `failure` string, so a broken `python3` on PATH is
163
+ * reported rather than thrown.
164
+ * @param {string} command - interpreter to run (a path or a PATH name).
165
+ * @param {string[]} [prefix] - arguments before `-c`, for launchers such as Windows' `py`.
166
+ * @returns {Promise<{version: string, major: number, minor: number}|{failure: string}>}
167
+ */
168
+ export function probePython(command, prefix = []) {
169
+ return new Promise(resolve => {
170
+ let child
171
+ try {
172
+ child = spawn(command, [...prefix, '-c', VERSION_PROBE], {
173
+ stdio: ['ignore', 'pipe', 'pipe'],
174
+ windowsHide: true,
175
+ })
176
+ } catch (error) {
177
+ resolve({ failure: `${command} could not be started: ${error.message}` })
178
+ return
179
+ }
180
+ let out = ''
181
+ let err = ''
182
+ let settled = false
183
+ const settle = value => {
184
+ if (settled) return
185
+ settled = true
186
+ clearTimeout(timer)
187
+ resolve(value)
188
+ }
189
+ const timer = setTimeout(() => {
190
+ child.kill('SIGKILL')
191
+ settle({ failure: `did not answer within ${Math.round(PROBE_TIMEOUT_MS / 1000)}s` })
192
+ }, PROBE_TIMEOUT_MS)
193
+ child.stdout.on('data', chunk => { out += String(chunk) })
194
+ child.stderr.on('data', chunk => { err += String(chunk) })
195
+ child.on('error', error => {
196
+ settle({ failure: `${command} could not be started: ${error.message}` })
197
+ })
198
+ child.on('close', code => {
199
+ if (code !== 0) {
200
+ const detail = err.trim().split('\n').filter(Boolean).at(-1) ?? `exit status ${code}`
201
+ settle({ failure: detail })
202
+ return
203
+ }
204
+ let report
205
+ try {
206
+ report = JSON.parse(out)
207
+ } catch {
208
+ settle({ failure: `answered something unreadable: ${out.slice(0, 120)}` })
209
+ return
210
+ }
211
+ if (typeof report.major !== 'number' || typeof report.minor !== 'number'
212
+ || typeof report.version !== 'string') {
213
+ settle({ failure: `answered something unexpected: ${out.slice(0, 120)}` })
214
+ return
215
+ }
216
+ settle({ version: report.version, major: report.major, minor: report.minor })
217
+ })
218
+ })
219
+ }
220
+
221
+ /**
222
+ * One command, its output as lines, and how it ended.
223
+ *
224
+ * `timeoutMs` is the whole budget this command has: it is asked to stop, then killed. The caller
225
+ * decides what an exit code means; this only reports what happened. Output is streamed through
226
+ * `onLine` so an install can show progress without buffering everything until the end.
227
+ * @param {string} command
228
+ * @param {string[]} args
229
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, onLine?: (line: string) => void, timeoutMs?: number}} [options]
230
+ * @returns {Promise<{code: number|undefined, signal: string|undefined, timedOut: boolean,
231
+ * spawnError: string|undefined}>}
232
+ */
233
+ export function runCommand(command, args, options = {}) {
234
+ return new Promise(resolve => {
235
+ let child
236
+ try {
237
+ child = spawn(command, args, {
238
+ cwd: options.cwd,
239
+ env: options.env,
240
+ stdio: ['ignore', 'pipe', 'pipe'],
241
+ windowsHide: true,
242
+ })
243
+ } catch (error) {
244
+ resolve({ code: undefined, signal: undefined, timedOut: false, spawnError: error.message })
245
+ return
246
+ }
247
+ const pending = { stdout: '', stderr: '' }
248
+ const take = (stream, chunk) => {
249
+ const parts = (pending[stream] + String(chunk)).split(/\r\n|\r|\n/u)
250
+ pending[stream] = parts.pop()
251
+ for (const part of parts) {
252
+ if (part === '') continue
253
+ try {
254
+ options.onLine?.(part)
255
+ } catch {
256
+ // A listener that throws is its own problem; the command goes on for everyone else.
257
+ }
258
+ }
259
+ }
260
+ child.stdout.on('data', chunk => take('stdout', chunk))
261
+ child.stderr.on('data', chunk => take('stderr', chunk))
262
+ let timedOut = false
263
+ const timer = setTimeout(() => {
264
+ timedOut = true
265
+ child.kill('SIGTERM')
266
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref()
267
+ }, options.timeoutMs ?? VENV_TIMEOUT_MS)
268
+ child.on('error', error => {
269
+ clearTimeout(timer)
270
+ resolve({ code: undefined, signal: undefined, timedOut: false, spawnError: error.message })
271
+ })
272
+ child.on('close', (code, signal) => {
273
+ clearTimeout(timer)
274
+ for (const stream of ['stdout', 'stderr']) {
275
+ if (pending[stream] !== '') {
276
+ try {
277
+ options.onLine?.(pending[stream])
278
+ } catch {
279
+ // As above: the command already finished.
280
+ }
281
+ }
282
+ }
283
+ resolve({ code, signal, timedOut, spawnError: undefined })
284
+ })
285
+ })
286
+ }
287
+
288
+ /** The environment `python -m venv` runs in: ours, minus anything that names another Python. */
289
+ function venvEnvironment() {
290
+ const env = { ...process.env, NO_COLOR: '1' }
291
+ delete env.VIRTUAL_ENV
292
+ delete env.PYTHONHOME
293
+ return env
294
+ }
295
+
296
+ /** A failed environment build, in the shape the install reports and the doctor repeat. */
297
+ function failure(code, error, fix) {
298
+ return { ok: false, code, error, fix }
299
+ }
300
+
301
+ /** The last lines a builder printed, short enough to sit inside an error message. */
302
+ function lastWords(tail) {
303
+ const lines = tail.map(line => line.trim()).filter(Boolean).slice(-2)
304
+ if (lines.length === 0) return ''
305
+ const text = lines.join(' | ')
306
+ return `: ${text.length > 240 ? `${text.slice(0, 239)}\u2026` : text}`
307
+ }
308
+
309
+ /**
310
+ * Remove the target environment before building it.
311
+ *
312
+ * A half-built environment from an earlier failed attempt, or a virtualenv built by a Python that
313
+ * has since disappeared, is worse than no environment: `venv` and `uv venv` would both try to
314
+ * repair a tree they did not create. This is this server's own directory, so clearing it is safe.
315
+ */
316
+ function clearEnvironment(environment) {
317
+ try {
318
+ rmSync(environment, { recursive: true, force: true })
319
+ } catch {
320
+ // If it cannot be removed the builder reports that itself; failing to delete is not the error.
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Create the environment with one base Python: `python -m venv`, then probe what it produced.
326
+ * @param {string[]} tail - the builder's output so far; its last lines explain a failure.
327
+ * @returns {Promise<object>} the create result, success or failure.
328
+ */
329
+ async function createWithPython(candidate, probe, environment, say, tail) {
330
+ const label = candidate.source === 'pythonPath' ? 'the configured Python' : 'a Python this machine has'
331
+ say(`creating ${environment} with ${candidate.command} (${label}, Python ${probe.version})`)
332
+ const result = await runCommand(candidate.command, [...candidate.prefix, '-m', 'venv', environment], {
333
+ env: venvEnvironment(),
334
+ onLine: say,
335
+ timeoutMs: VENV_TIMEOUT_MS,
336
+ })
337
+ if (result.spawnError !== undefined) {
338
+ return failure('venv_failed', `${candidate.command} could not be started: ${result.spawnError}`, PYTHON_VENV_FIX)
339
+ }
340
+ if (result.timedOut) {
341
+ return failure('venv_failed',
342
+ `${candidate.command} -m venv did not finish within ${Math.round(VENV_TIMEOUT_MS / 1000)}s`
343
+ + lastWords(tail), PYTHON_VENV_FIX)
344
+ }
345
+ if (result.code !== 0) {
346
+ return failure('venv_failed',
347
+ `${candidate.command} -m venv exited with ${result.code ?? result.signal}${lastWords(tail)}`,
348
+ PYTHON_VENV_FIX)
349
+ }
350
+ const inside = await probePython(environmentPython(environment))
351
+ if (inside.failure !== undefined) {
352
+ return failure('venv_failed',
353
+ `${candidate.command} -m venv created ${environment}, but the interpreter in it did not answer: `
354
+ + inside.failure,
355
+ PYTHON_VENV_FIX)
356
+ }
357
+ if (!supported(inside)) {
358
+ return failure('venv_failed',
359
+ `${candidate.command} -m venv created ${environment} with Python ${inside.version}, older than `
360
+ + `${PYTHON_MINIMUM}`, PYTHON_VENV_FIX)
361
+ }
362
+ return { ok: true, python: environmentPython(environment), strategy: 'python', pythonVersion: inside.version }
363
+ }
364
+
365
+ /**
366
+ * Build the managed virtualenv, with uv when it exists and a probed Python 3.12+ otherwise.
367
+ *
368
+ * The two strategies are not equal: with uv the environment is created by a tool that can also
369
+ * fetch the pinned Python, so a machine with no suitable interpreter still succeeds; without uv the
370
+ * environment has to come from an interpreter already present. Both end in the same place — a
371
+ * virtualenv whose own `python` answered the version probe — and every command's output is passed
372
+ * to `onOutput` as it arrives, because a first install that downloads wheels has to show what it is
373
+ * doing for those seconds.
374
+ *
375
+ * The configured `pythonPath` is the answer or the failure: uv is not used over an explicitly named
376
+ * interpreter, because silently installing somewhere else would ignore the configuration.
377
+ * @param {{config?: object, envDir: string, onOutput?: (line: string) => void}} options
378
+ * @returns {Promise<{ok: true, python: string, strategy: 'uv'|'python', pythonVersion: string}
379
+ * | {ok: false, code: 'no_python'|'no_uv'|'venv_failed', error: string, fix: string}>}
380
+ */
381
+ export async function createEnvironment({ config = {}, envDir, onOutput } = {}) {
382
+ if (typeof envDir !== 'string' || envDir.trim() === '') {
383
+ throw new TypeError('createEnvironment needs an envDir')
384
+ }
385
+ // The builder's output is streamed to the observer and its tail is kept for the failure message:
386
+ // "exit status 1" alone is not a diagnosis, but the line before it often is.
387
+ const tail = []
388
+ const say = line => {
389
+ tail.push(String(line))
390
+ if (tail.length > 20) tail.shift()
391
+ try {
392
+ onOutput?.(line)
393
+ } catch {
394
+ // An observer that throws must not fail the install for everyone else.
395
+ }
396
+ }
397
+ const configured = String(config.pythonPath ?? '').trim()
398
+ if (configured !== '') {
399
+ const probe = await probePython(configured)
400
+ if (probe.failure !== undefined) {
401
+ return failure('no_python',
402
+ `the configured --python (${configured}) cannot be used: ${probe.failure}`,
403
+ 'correct --python, or clear it and the server builds an environment of its own')
404
+ }
405
+ if (!supported(probe)) {
406
+ return failure('no_python',
407
+ `the configured --python is Python ${probe.version}, older than ${PYTHON_MINIMUM}`,
408
+ `${NOTHING_TO_BUILD_WITH}, or point --python at a newer interpreter`)
409
+ }
410
+ clearEnvironment(envDir)
411
+ return createWithPython({ command: configured, prefix: [], source: 'pythonPath' }, probe, envDir, say, tail)
412
+ }
413
+
414
+ const uv = findUv()
415
+ let uvFailure
416
+ if (uv !== undefined) {
417
+ say(`creating ${envDir} with uv (Python ${ENVIRONMENT_PYTHON_VERSION})`)
418
+ clearEnvironment(envDir)
419
+ const result = await runCommand(uv, ['venv', '--python', ENVIRONMENT_PYTHON_VERSION, envDir], {
420
+ env: venvEnvironment(),
421
+ onLine: say,
422
+ timeoutMs: UV_VENV_TIMEOUT_MS,
423
+ })
424
+ if (result.spawnError !== undefined) {
425
+ uvFailure = `${uv} could not be started: ${result.spawnError}`
426
+ } else if (result.timedOut) {
427
+ uvFailure = `uv venv did not finish within ${Math.round(UV_VENV_TIMEOUT_MS / 1000)}s`
428
+ } else if (result.code !== 0) {
429
+ uvFailure = `uv venv exited with ${result.code ?? result.signal}${lastWords(tail)}`
430
+ } else {
431
+ const inside = await probePython(environmentPython(envDir))
432
+ if (inside.failure === undefined && supported(inside)) {
433
+ return { ok: true, python: environmentPython(envDir), strategy: 'uv', pythonVersion: inside.version }
434
+ }
435
+ uvFailure = inside.failure === undefined
436
+ ? `the interpreter uv created is Python ${inside.version}, older than ${PYTHON_MINIMUM}`
437
+ : `the interpreter uv created did not answer: ${inside.failure}`
438
+ }
439
+ say(`uv could not create the environment (${uvFailure})`)
440
+ }
441
+
442
+ const candidates = basePythons(config).filter(item => item.source !== 'uv')
443
+ const attempts = []
444
+ for (const candidate of candidates) {
445
+ const probe = await probePython(candidate.command, candidate.prefix)
446
+ if (probe.failure !== undefined) {
447
+ attempts.push(`${candidate.command} (${candidate.source}): ${probe.failure}`)
448
+ continue
449
+ }
450
+ if (!supported(probe)) {
451
+ attempts.push(`${candidate.command} (${candidate.source}): Python ${probe.version} `
452
+ + `is older than ${PYTHON_MINIMUM}`)
453
+ continue
454
+ }
455
+ clearEnvironment(envDir)
456
+ const created = await createWithPython(candidate, probe, envDir, say, tail)
457
+ if (created.ok === true) return created
458
+ attempts.push(`${candidate.command} (${candidate.source}): ${created.error}`)
459
+ }
460
+
461
+ if (attempts.length === 0 && uvFailure === undefined) {
462
+ return failure('no_python',
463
+ `no Python ${PYTHON_MINIMUM}+ was found on PATH `
464
+ + `(tried ${systemPythonNames().join(', ')}) and uv is not installed`,
465
+ NOTHING_TO_BUILD_WITH)
466
+ }
467
+ const tried = attempts.length === 0 ? 'no usable base Python was found' : attempts.join('; ')
468
+ const because = uvFailure === undefined ? '' : `uv could not build the environment (${uvFailure}) and `
469
+ return failure('venv_failed', `${because}${tried}`, `${NOTHING_TO_BUILD_WITH}, then retry the install`)
470
+ }
package/lib/render.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The model-visible rendering of one observation: `dsh-browser-use`'s action-space table.
3
+ *
4
+ * Screenshots are for people; a model reads this text instead. Every line names one element with the
5
+ * operations it supports, and every target it may send is an index printed here — never a selector,
6
+ * a coordinate, or code.
7
+ *
8
+ * @module browser-use-mcp/render
9
+ */
10
+
11
+ /** Collapse whitespace so a page's own layout does not consume the model's context. */
12
+ export function collapse(text) {
13
+ return String(text ?? '').replace(/\s+/gu, ' ').trim()
14
+ }
15
+
16
+ /**
17
+ * Checkbox, option and disclosure state: without it a model cannot tell whether a click is still
18
+ * needed. A tri-state value reads as `partly checked` rather than as either answer.
19
+ */
20
+ export function elementState(element) {
21
+ const states = [
22
+ ['checked', 'checked', 'not checked'],
23
+ ['selected', 'selected', 'not selected'],
24
+ ['expanded', 'expanded', 'collapsed'],
25
+ ].flatMap(([key, on, off]) => {
26
+ const state = String(element[key] ?? '')
27
+ return state === 'true' ? [on] : state === 'false' ? [off] : state === 'mixed' ? [`partly ${on}`] : []
28
+ })
29
+ return states.length > 0 ? ` (${states.join(', ')})` : ''
30
+ }
31
+
32
+ /**
33
+ * The action space of one page as text: its address, its visible text, and one line per element.
34
+ *
35
+ * @param {object} page - one observation's page object from the sidecar.
36
+ * @param {number} observation - the observation number this table belongs to.
37
+ * @returns {string} the table the model acts from.
38
+ */
39
+ export function pageText(page, observation) {
40
+ const lines = [
41
+ `Page ${page.url}${page.title ? ` — ${page.title}` : ''}`,
42
+ `Observation ${observation}. Targets below are valid only for this observation.`,
43
+ ]
44
+ const text = collapse(page.text)
45
+ if (text) lines.push(`Visible text: ${text.slice(0, 1200)}${text.length > 1200 ? '…' : ''}`)
46
+ for (const element of page.elements ?? []) {
47
+ const value = collapse(element.value)
48
+ const details = [element.role ?? 'element', collapse(element.label)].filter(Boolean).join(' ')
49
+ lines.push(`[${element.index}] ${details}${value ? ` · "${value}"` : ''}${elementState(element)} — ${element.operations.join(', ')}`)
50
+ for (const option of element.options ?? []) lines.push(` ${option.index} → ${collapse(option.label)}`)
51
+ }
52
+ const withoutTarget = Object.keys(page.operations ?? {}).filter(operation => page.operations[operation].length === 0)
53
+ if (withoutTarget.length > 0) lines.push(`Without a target: ${withoutTarget.join(', ')}`)
54
+ if ((page.elements ?? []).length === 0) lines.push('No reachable element was found on this page.')
55
+ return lines.join('\n')
56
+ }
57
+
58
+ /**
59
+ * A refusal that carries the table the next choice must come from.
60
+ *
61
+ * The point of the freshness guard is that a refusal is not an error to retry blindly: it hands back
62
+ * the observation the page is in now, so the agent chooses again from something that is true.
63
+ * @param {string} message - why nothing executed.
64
+ * @param {object} page - the fresh page.
65
+ * @param {number} observation - the fresh observation number.
66
+ */
67
+ export function refusalText(message, page, observation) {
68
+ return `Nothing was executed: ${message}\n\n${pageText(page, observation)}`
69
+ }
70
+
71
+ /** A page's own title and address as one line, for results that do not need the whole table. */
72
+ export function pageLabel(page) {
73
+ if (!page) return 'no page has been read yet'
74
+ return `${page.title ? `${page.title} — ` : ''}${page.url}`
75
+ }