computeruseprotocol 0.1.0__py3-none-any.whl
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.
- computeruseprotocol-0.1.0.dist-info/METADATA +225 -0
- computeruseprotocol-0.1.0.dist-info/RECORD +27 -0
- computeruseprotocol-0.1.0.dist-info/WHEEL +4 -0
- computeruseprotocol-0.1.0.dist-info/entry_points.txt +3 -0
- computeruseprotocol-0.1.0.dist-info/licenses/LICENSE +21 -0
- cup/__init__.py +548 -0
- cup/__main__.py +222 -0
- cup/_base.py +123 -0
- cup/_router.py +63 -0
- cup/actions/__init__.py +9 -0
- cup/actions/_handler.py +62 -0
- cup/actions/_keys.py +56 -0
- cup/actions/_linux.py +1008 -0
- cup/actions/_macos.py +1090 -0
- cup/actions/_web.py +555 -0
- cup/actions/_windows.py +984 -0
- cup/actions/executor.py +162 -0
- cup/format.py +653 -0
- cup/mcp/__init__.py +1 -0
- cup/mcp/__main__.py +11 -0
- cup/mcp/server.py +418 -0
- cup/platforms/__init__.py +0 -0
- cup/platforms/linux.py +1060 -0
- cup/platforms/macos.py +1005 -0
- cup/platforms/web.py +1009 -0
- cup/platforms/windows.py +935 -0
- cup/search.py +583 -0
cup/platforms/web.py
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web platform adapter for CUP via Chrome DevTools Protocol (CDP).
|
|
3
|
+
|
|
4
|
+
Connects to a Chromium browser running with --remote-debugging-port,
|
|
5
|
+
captures the accessibility tree via Accessibility.getFullAXTree(),
|
|
6
|
+
and optionally discovers WebMCP tools from the page context.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# Launch Chrome with debugging enabled:
|
|
10
|
+
chrome --remote-debugging-port=9222
|
|
11
|
+
|
|
12
|
+
# Capture via CLI:
|
|
13
|
+
python -m cup --platform web --compact
|
|
14
|
+
|
|
15
|
+
# Or via API:
|
|
16
|
+
import cup
|
|
17
|
+
text = cup.snapshot("full")
|
|
18
|
+
|
|
19
|
+
Dependencies:
|
|
20
|
+
pip install websocket-client
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import http.client
|
|
26
|
+
import itertools
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import websocket # websocket-client
|
|
33
|
+
|
|
34
|
+
from cup._base import PlatformAdapter
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# CDP Transport
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_msg_id_lock = threading.Lock()
|
|
41
|
+
_msg_id_counter = itertools.count(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cdp_get_targets(host: str, port: int) -> list[dict]:
|
|
45
|
+
"""Fetch the list of CDP targets (browser tabs) via HTTP."""
|
|
46
|
+
conn = http.client.HTTPConnection(host, port, timeout=5)
|
|
47
|
+
try:
|
|
48
|
+
conn.request("GET", "/json")
|
|
49
|
+
resp = conn.getresponse()
|
|
50
|
+
data = resp.read().decode("utf-8")
|
|
51
|
+
return json.loads(data)
|
|
52
|
+
finally:
|
|
53
|
+
conn.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cdp_connect(ws_url: str, host: str | None = None) -> websocket.WebSocket:
|
|
57
|
+
"""Open a synchronous websocket connection to a CDP target.
|
|
58
|
+
|
|
59
|
+
If *host* is given, the hostname in *ws_url* is replaced so that
|
|
60
|
+
we always connect via the same address used for target discovery
|
|
61
|
+
(avoids slow ``localhost`` DNS lookups on some systems).
|
|
62
|
+
"""
|
|
63
|
+
if host:
|
|
64
|
+
# ws://localhost:9222/devtools/... → ws://127.0.0.1:9222/devtools/...
|
|
65
|
+
from urllib.parse import urlparse, urlunparse
|
|
66
|
+
|
|
67
|
+
parts = urlparse(ws_url)
|
|
68
|
+
ws_url = urlunparse(parts._replace(netloc=f"{host}:{parts.port}"))
|
|
69
|
+
ws = websocket.WebSocket()
|
|
70
|
+
ws.settimeout(30)
|
|
71
|
+
ws.connect(ws_url)
|
|
72
|
+
return ws
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cdp_send(
|
|
76
|
+
ws: websocket.WebSocket,
|
|
77
|
+
method: str,
|
|
78
|
+
params: dict | None = None,
|
|
79
|
+
timeout: float = 30.0,
|
|
80
|
+
) -> dict:
|
|
81
|
+
"""Send a CDP command and wait for the matching response.
|
|
82
|
+
|
|
83
|
+
Discards interleaved CDP event messages while waiting.
|
|
84
|
+
"""
|
|
85
|
+
with _msg_id_lock:
|
|
86
|
+
msg_id = next(_msg_id_counter)
|
|
87
|
+
|
|
88
|
+
message: dict[str, Any] = {"id": msg_id, "method": method}
|
|
89
|
+
if params:
|
|
90
|
+
message["params"] = params
|
|
91
|
+
|
|
92
|
+
old_timeout = ws.gettimeout()
|
|
93
|
+
ws.settimeout(timeout)
|
|
94
|
+
try:
|
|
95
|
+
ws.send(json.dumps(message))
|
|
96
|
+
while True:
|
|
97
|
+
raw = ws.recv()
|
|
98
|
+
resp = json.loads(raw)
|
|
99
|
+
if resp.get("id") == msg_id:
|
|
100
|
+
if "error" in resp:
|
|
101
|
+
err = resp["error"]
|
|
102
|
+
raise RuntimeError(f"CDP error {err.get('code')}: {err.get('message')}")
|
|
103
|
+
return resp
|
|
104
|
+
# else: event notification — discard and keep waiting
|
|
105
|
+
finally:
|
|
106
|
+
ws.settimeout(old_timeout)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cdp_close(ws: websocket.WebSocket) -> None:
|
|
110
|
+
"""Close a CDP websocket connection."""
|
|
111
|
+
try:
|
|
112
|
+
ws.close()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# CDP AX Role → CUP Role mapping
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Roles that should be skipped entirely (internal browser nodes)
|
|
122
|
+
_SKIP_ROLES = frozenset(
|
|
123
|
+
{
|
|
124
|
+
"InlineTextBox",
|
|
125
|
+
"LineBreak",
|
|
126
|
+
"IframePresentational",
|
|
127
|
+
"none",
|
|
128
|
+
"Ignored",
|
|
129
|
+
"IgnoredRole",
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Explicit mapping for CDP roles that don't match CUP names directly.
|
|
134
|
+
# CDP roles not listed here fall through to the lowercase identity check.
|
|
135
|
+
CDP_ROLE_MAP: dict[str, str] = {
|
|
136
|
+
# Document roots
|
|
137
|
+
"RootWebArea": "document",
|
|
138
|
+
"WebArea": "document",
|
|
139
|
+
# Structural / generic
|
|
140
|
+
"GenericContainer": "generic",
|
|
141
|
+
"Iframe": "generic",
|
|
142
|
+
"Div": "generic",
|
|
143
|
+
"Span": "generic",
|
|
144
|
+
"Paragraph": "generic",
|
|
145
|
+
"Pre": "generic",
|
|
146
|
+
"Mark": "generic",
|
|
147
|
+
"Abbr": "generic",
|
|
148
|
+
"Ruby": "generic",
|
|
149
|
+
"Time": "generic",
|
|
150
|
+
"Subscript": "generic",
|
|
151
|
+
"Superscript": "generic",
|
|
152
|
+
"LabelText": "generic",
|
|
153
|
+
"Legend": "generic",
|
|
154
|
+
# Text
|
|
155
|
+
"StaticText": "text",
|
|
156
|
+
# Groups
|
|
157
|
+
"Blockquote": "group",
|
|
158
|
+
"Figcaption": "group",
|
|
159
|
+
"DescriptionListDetail": "group",
|
|
160
|
+
"Details": "group",
|
|
161
|
+
# Lists
|
|
162
|
+
"DescriptionList": "list",
|
|
163
|
+
"DescriptionListTerm": "listitem",
|
|
164
|
+
# CamelCase → lowercase ARIA
|
|
165
|
+
"progressIndicator": "progressbar",
|
|
166
|
+
"spinButton": "spinbutton",
|
|
167
|
+
"tabList": "tablist",
|
|
168
|
+
"tabPanel": "tabpanel",
|
|
169
|
+
"menuItem": "menuitem",
|
|
170
|
+
"menuItemCheckBox": "menuitemcheckbox",
|
|
171
|
+
"menuItemRadio": "menuitemradio",
|
|
172
|
+
"menuBar": "menubar",
|
|
173
|
+
"listItem": "listitem",
|
|
174
|
+
"treeItem": "treeitem",
|
|
175
|
+
"columnHeader": "columnheader",
|
|
176
|
+
"rowHeader": "rowheader",
|
|
177
|
+
"comboBoxGrouping": "combobox",
|
|
178
|
+
"comboBoxMenuButton": "combobox",
|
|
179
|
+
"comboBoxSelect": "combobox",
|
|
180
|
+
"alertDialog": "alertdialog",
|
|
181
|
+
"contentInfo": "contentinfo",
|
|
182
|
+
"radioButton": "radio",
|
|
183
|
+
"scrollBar": "scrollbar",
|
|
184
|
+
# Semantic overrides
|
|
185
|
+
"Summary": "button",
|
|
186
|
+
"Meter": "progressbar",
|
|
187
|
+
"Output": "status",
|
|
188
|
+
"Figure": "figure",
|
|
189
|
+
"Canvas": "img",
|
|
190
|
+
"Video": "generic",
|
|
191
|
+
"Audio": "generic",
|
|
192
|
+
"Section": "generic", # refined to "region" if named
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Valid CUP roles (for the identity-check fallback)
|
|
196
|
+
_CUP_ROLES = frozenset(
|
|
197
|
+
{
|
|
198
|
+
"alert",
|
|
199
|
+
"alertdialog",
|
|
200
|
+
"application",
|
|
201
|
+
"article",
|
|
202
|
+
"banner",
|
|
203
|
+
"button",
|
|
204
|
+
"cell",
|
|
205
|
+
"checkbox",
|
|
206
|
+
"columnheader",
|
|
207
|
+
"combobox",
|
|
208
|
+
"complementary",
|
|
209
|
+
"contentinfo",
|
|
210
|
+
"definition",
|
|
211
|
+
"dialog",
|
|
212
|
+
"directory",
|
|
213
|
+
"document",
|
|
214
|
+
"feed",
|
|
215
|
+
"figure",
|
|
216
|
+
"form",
|
|
217
|
+
"generic",
|
|
218
|
+
"grid",
|
|
219
|
+
"gridcell",
|
|
220
|
+
"group",
|
|
221
|
+
"heading",
|
|
222
|
+
"img",
|
|
223
|
+
"link",
|
|
224
|
+
"list",
|
|
225
|
+
"listbox",
|
|
226
|
+
"listitem",
|
|
227
|
+
"log",
|
|
228
|
+
"main",
|
|
229
|
+
"marquee",
|
|
230
|
+
"math",
|
|
231
|
+
"menu",
|
|
232
|
+
"menubar",
|
|
233
|
+
"menuitem",
|
|
234
|
+
"menuitemcheckbox",
|
|
235
|
+
"menuitemradio",
|
|
236
|
+
"meter",
|
|
237
|
+
"navigation",
|
|
238
|
+
"none",
|
|
239
|
+
"note",
|
|
240
|
+
"option",
|
|
241
|
+
"pane",
|
|
242
|
+
"presentation",
|
|
243
|
+
"progressbar",
|
|
244
|
+
"radio",
|
|
245
|
+
"radiogroup",
|
|
246
|
+
"region",
|
|
247
|
+
"row",
|
|
248
|
+
"rowgroup",
|
|
249
|
+
"rowheader",
|
|
250
|
+
"scrollbar",
|
|
251
|
+
"search",
|
|
252
|
+
"searchbox",
|
|
253
|
+
"separator",
|
|
254
|
+
"slider",
|
|
255
|
+
"spinbutton",
|
|
256
|
+
"status",
|
|
257
|
+
"switch",
|
|
258
|
+
"tab",
|
|
259
|
+
"table",
|
|
260
|
+
"tablist",
|
|
261
|
+
"tabpanel",
|
|
262
|
+
"term",
|
|
263
|
+
"text",
|
|
264
|
+
"textbox",
|
|
265
|
+
"timer",
|
|
266
|
+
"toolbar",
|
|
267
|
+
"tooltip",
|
|
268
|
+
"tree",
|
|
269
|
+
"treegrid",
|
|
270
|
+
"treeitem",
|
|
271
|
+
"window",
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Roles where text input is expected
|
|
276
|
+
_TEXT_INPUT_ROLES = frozenset(
|
|
277
|
+
{
|
|
278
|
+
"textbox",
|
|
279
|
+
"searchbox",
|
|
280
|
+
"combobox",
|
|
281
|
+
"spinbutton",
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Roles that are inherently clickable
|
|
286
|
+
_CLICKABLE_ROLES = frozenset(
|
|
287
|
+
{
|
|
288
|
+
"button",
|
|
289
|
+
"link",
|
|
290
|
+
"menuitem",
|
|
291
|
+
"menuitemcheckbox",
|
|
292
|
+
"menuitemradio",
|
|
293
|
+
"option",
|
|
294
|
+
"tab",
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Roles that support selection
|
|
299
|
+
_SELECTABLE_ROLES = frozenset(
|
|
300
|
+
{
|
|
301
|
+
"option",
|
|
302
|
+
"tab",
|
|
303
|
+
"treeitem",
|
|
304
|
+
"listitem",
|
|
305
|
+
"row",
|
|
306
|
+
"cell",
|
|
307
|
+
"gridcell",
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Roles that are toggle-like
|
|
312
|
+
_TOGGLE_ROLES = frozenset(
|
|
313
|
+
{
|
|
314
|
+
"checkbox",
|
|
315
|
+
"switch",
|
|
316
|
+
"menuitemcheckbox",
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Roles that are range widgets
|
|
321
|
+
_RANGE_ROLES = frozenset(
|
|
322
|
+
{
|
|
323
|
+
"slider",
|
|
324
|
+
"spinbutton",
|
|
325
|
+
"progressbar",
|
|
326
|
+
"scrollbar",
|
|
327
|
+
"meter",
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _map_cdp_role(cdp_role: str, name: str) -> str | None:
|
|
333
|
+
"""Map a CDP AX role string to a CUP role, or None to skip."""
|
|
334
|
+
if cdp_role in _SKIP_ROLES:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
# Explicit mapping
|
|
338
|
+
cup_role = CDP_ROLE_MAP.get(cdp_role)
|
|
339
|
+
if cup_role is not None:
|
|
340
|
+
# Section with a name becomes "region"
|
|
341
|
+
if cdp_role == "Section" and name:
|
|
342
|
+
return "region"
|
|
343
|
+
return cup_role
|
|
344
|
+
|
|
345
|
+
# Identity check: CDP role lowercased might already be a valid CUP role
|
|
346
|
+
lower = cdp_role.lower()
|
|
347
|
+
if lower in _CUP_ROLES:
|
|
348
|
+
return lower
|
|
349
|
+
|
|
350
|
+
return "generic"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# State extraction
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _extract_states(
|
|
359
|
+
props: dict[str, Any],
|
|
360
|
+
role: str,
|
|
361
|
+
bounds: dict | None,
|
|
362
|
+
viewport_w: int,
|
|
363
|
+
viewport_h: int,
|
|
364
|
+
) -> list[str]:
|
|
365
|
+
"""Derive CUP states from CDP AX properties."""
|
|
366
|
+
states: list[str] = []
|
|
367
|
+
|
|
368
|
+
if props.get("disabled"):
|
|
369
|
+
states.append("disabled")
|
|
370
|
+
if props.get("focused"):
|
|
371
|
+
states.append("focused")
|
|
372
|
+
|
|
373
|
+
# Expanded / collapsed
|
|
374
|
+
expanded = props.get("expanded")
|
|
375
|
+
if expanded is True:
|
|
376
|
+
states.append("expanded")
|
|
377
|
+
elif expanded is False:
|
|
378
|
+
states.append("collapsed")
|
|
379
|
+
|
|
380
|
+
if props.get("selected"):
|
|
381
|
+
states.append("selected")
|
|
382
|
+
|
|
383
|
+
# Checked (can be boolean or string "true"/"mixed")
|
|
384
|
+
checked = props.get("checked")
|
|
385
|
+
if checked is True or checked == "true":
|
|
386
|
+
states.append("checked")
|
|
387
|
+
elif checked == "mixed":
|
|
388
|
+
states.append("mixed")
|
|
389
|
+
|
|
390
|
+
# Pressed (toggle buttons)
|
|
391
|
+
pressed = props.get("pressed")
|
|
392
|
+
if pressed is True or pressed == "true":
|
|
393
|
+
states.append("pressed")
|
|
394
|
+
elif pressed == "mixed":
|
|
395
|
+
states.append("mixed")
|
|
396
|
+
|
|
397
|
+
if props.get("busy"):
|
|
398
|
+
states.append("busy")
|
|
399
|
+
if props.get("modal"):
|
|
400
|
+
states.append("modal")
|
|
401
|
+
if props.get("required"):
|
|
402
|
+
states.append("required")
|
|
403
|
+
|
|
404
|
+
readonly = props.get("readonly")
|
|
405
|
+
if readonly:
|
|
406
|
+
states.append("readonly")
|
|
407
|
+
|
|
408
|
+
# Editable: text-input role that is not readonly
|
|
409
|
+
if role in _TEXT_INPUT_ROLES and not readonly:
|
|
410
|
+
states.append("editable")
|
|
411
|
+
|
|
412
|
+
# Offscreen detection from bounds vs viewport
|
|
413
|
+
if bounds:
|
|
414
|
+
bx, by = bounds["x"], bounds["y"]
|
|
415
|
+
bw, bh = bounds["w"], bounds["h"]
|
|
416
|
+
if (
|
|
417
|
+
bw <= 0
|
|
418
|
+
or bh <= 0
|
|
419
|
+
or bx + bw <= 0
|
|
420
|
+
or by + bh <= 0
|
|
421
|
+
or bx >= viewport_w
|
|
422
|
+
or by >= viewport_h
|
|
423
|
+
):
|
|
424
|
+
states.append("offscreen")
|
|
425
|
+
|
|
426
|
+
return states
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
# Action derivation
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _derive_actions(
|
|
435
|
+
role: str,
|
|
436
|
+
props: dict[str, Any],
|
|
437
|
+
states: list[str],
|
|
438
|
+
) -> list[str]:
|
|
439
|
+
"""Derive CUP actions from node role and properties."""
|
|
440
|
+
actions: list[str] = []
|
|
441
|
+
|
|
442
|
+
if "disabled" in states:
|
|
443
|
+
return actions
|
|
444
|
+
|
|
445
|
+
if role in _CLICKABLE_ROLES:
|
|
446
|
+
actions.append("click")
|
|
447
|
+
actions.append("rightclick")
|
|
448
|
+
actions.append("doubleclick")
|
|
449
|
+
|
|
450
|
+
if role in _TOGGLE_ROLES:
|
|
451
|
+
actions.append("toggle")
|
|
452
|
+
|
|
453
|
+
if role in _SELECTABLE_ROLES and "select" not in actions:
|
|
454
|
+
actions.append("select")
|
|
455
|
+
|
|
456
|
+
if "expanded" in states or "collapsed" in states:
|
|
457
|
+
if "expand" not in actions:
|
|
458
|
+
actions.append("expand")
|
|
459
|
+
actions.append("collapse")
|
|
460
|
+
|
|
461
|
+
if role in _TEXT_INPUT_ROLES and "readonly" not in states:
|
|
462
|
+
actions.append("type")
|
|
463
|
+
actions.append("setvalue")
|
|
464
|
+
|
|
465
|
+
if role in ("slider", "spinbutton"):
|
|
466
|
+
actions.append("increment")
|
|
467
|
+
actions.append("decrement")
|
|
468
|
+
|
|
469
|
+
if role == "scrollbar":
|
|
470
|
+
actions.append("scroll")
|
|
471
|
+
|
|
472
|
+
# Focusable fallback
|
|
473
|
+
if not actions and props.get("focusable"):
|
|
474
|
+
actions.append("focus")
|
|
475
|
+
|
|
476
|
+
return actions
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# Attribute extraction
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _extract_attributes(
|
|
485
|
+
props: dict[str, Any],
|
|
486
|
+
role: str,
|
|
487
|
+
ax_node: dict,
|
|
488
|
+
) -> dict[str, Any]:
|
|
489
|
+
"""Extract optional CUP attributes from CDP AX properties."""
|
|
490
|
+
attrs: dict[str, Any] = {}
|
|
491
|
+
|
|
492
|
+
level = props.get("level")
|
|
493
|
+
if level is not None:
|
|
494
|
+
attrs["level"] = int(level)
|
|
495
|
+
|
|
496
|
+
placeholder = props.get("placeholder")
|
|
497
|
+
if placeholder:
|
|
498
|
+
attrs["placeholder"] = str(placeholder)[:200]
|
|
499
|
+
|
|
500
|
+
orientation = props.get("orientation")
|
|
501
|
+
if orientation:
|
|
502
|
+
attrs["orientation"] = str(orientation)
|
|
503
|
+
|
|
504
|
+
# Range values
|
|
505
|
+
if role in _RANGE_ROLES:
|
|
506
|
+
vmin = props.get("valuemin")
|
|
507
|
+
if vmin is not None:
|
|
508
|
+
attrs["valueMin"] = float(vmin)
|
|
509
|
+
vmax = props.get("valuemax")
|
|
510
|
+
if vmax is not None:
|
|
511
|
+
attrs["valueMax"] = float(vmax)
|
|
512
|
+
vnow = props.get("valuetext") or props.get("valuenow")
|
|
513
|
+
if vnow is not None:
|
|
514
|
+
try:
|
|
515
|
+
attrs["valueNow"] = float(vnow)
|
|
516
|
+
except (ValueError, TypeError):
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
# URL for links
|
|
520
|
+
if role == "link":
|
|
521
|
+
url = props.get("url")
|
|
522
|
+
if url:
|
|
523
|
+
attrs["url"] = str(url)[:500]
|
|
524
|
+
|
|
525
|
+
# Autocomplete
|
|
526
|
+
autocomplete = props.get("autocomplete")
|
|
527
|
+
if autocomplete and autocomplete != "none":
|
|
528
|
+
attrs["autocomplete"] = str(autocomplete)
|
|
529
|
+
|
|
530
|
+
return attrs
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# CUP node builder
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _ax_value(field: Any) -> Any:
|
|
539
|
+
"""Unpack a CDP AXValue object to its plain value."""
|
|
540
|
+
if isinstance(field, dict):
|
|
541
|
+
return field.get("value")
|
|
542
|
+
return field
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _build_cup_node(
|
|
546
|
+
ax_node: dict,
|
|
547
|
+
id_gen: itertools.count,
|
|
548
|
+
stats: dict,
|
|
549
|
+
viewport_w: int,
|
|
550
|
+
viewport_h: int,
|
|
551
|
+
) -> dict | None:
|
|
552
|
+
"""Convert a single CDP AX node to a CUP node dict."""
|
|
553
|
+
# Role
|
|
554
|
+
cdp_role = _ax_value(ax_node.get("role")) or "generic"
|
|
555
|
+
name = _ax_value(ax_node.get("name")) or ""
|
|
556
|
+
role = _map_cdp_role(cdp_role, name)
|
|
557
|
+
if role is None:
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
stats["nodes"] += 1
|
|
561
|
+
stats["roles"][cdp_role] = stats["roles"].get(cdp_role, 0) + 1
|
|
562
|
+
|
|
563
|
+
# Name and description
|
|
564
|
+
name = str(name)[:200] if name else ""
|
|
565
|
+
description = str(_ax_value(ax_node.get("description")) or "")[:200]
|
|
566
|
+
|
|
567
|
+
# Value
|
|
568
|
+
raw_value = _ax_value(ax_node.get("value"))
|
|
569
|
+
value_str = str(raw_value)[:200] if raw_value is not None else ""
|
|
570
|
+
|
|
571
|
+
# Properties into a flat dict for easier lookup
|
|
572
|
+
props: dict[str, Any] = {}
|
|
573
|
+
for prop in ax_node.get("properties", []):
|
|
574
|
+
prop_name = prop.get("name", "")
|
|
575
|
+
props[prop_name] = _ax_value(prop.get("value"))
|
|
576
|
+
|
|
577
|
+
# Bounds (from CDP "boundingBox" field if present)
|
|
578
|
+
bounds = None
|
|
579
|
+
bb = ax_node.get("boundingBox")
|
|
580
|
+
if bb:
|
|
581
|
+
bounds = {
|
|
582
|
+
"x": int(bb.get("x", 0)),
|
|
583
|
+
"y": int(bb.get("y", 0)),
|
|
584
|
+
"w": int(bb.get("width", 0)),
|
|
585
|
+
"h": int(bb.get("height", 0)),
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
# States
|
|
589
|
+
states = _extract_states(props, role, bounds, viewport_w, viewport_h)
|
|
590
|
+
|
|
591
|
+
# Actions
|
|
592
|
+
actions = _derive_actions(role, props, states)
|
|
593
|
+
|
|
594
|
+
# Attributes
|
|
595
|
+
attrs = _extract_attributes(props, role, ax_node)
|
|
596
|
+
|
|
597
|
+
# Assemble CUP node
|
|
598
|
+
node: dict[str, Any] = {
|
|
599
|
+
"id": f"e{next(id_gen)}",
|
|
600
|
+
"role": role,
|
|
601
|
+
"name": name,
|
|
602
|
+
}
|
|
603
|
+
if description:
|
|
604
|
+
node["description"] = description
|
|
605
|
+
if value_str and role in (
|
|
606
|
+
"textbox",
|
|
607
|
+
"searchbox",
|
|
608
|
+
"combobox",
|
|
609
|
+
"spinbutton",
|
|
610
|
+
"slider",
|
|
611
|
+
"progressbar",
|
|
612
|
+
"meter",
|
|
613
|
+
"document",
|
|
614
|
+
):
|
|
615
|
+
node["value"] = value_str
|
|
616
|
+
if bounds:
|
|
617
|
+
node["bounds"] = bounds
|
|
618
|
+
if states:
|
|
619
|
+
node["states"] = states
|
|
620
|
+
if actions:
|
|
621
|
+
node["actions"] = actions
|
|
622
|
+
if attrs:
|
|
623
|
+
node["attributes"] = attrs
|
|
624
|
+
|
|
625
|
+
# Platform extension
|
|
626
|
+
platform_ext: dict[str, Any] = {"cdpRole": cdp_role}
|
|
627
|
+
backend_id = ax_node.get("backendDOMNodeId")
|
|
628
|
+
if backend_id is not None:
|
|
629
|
+
platform_ext["backendDOMNodeId"] = backend_id
|
|
630
|
+
node_id = ax_node.get("nodeId")
|
|
631
|
+
if node_id:
|
|
632
|
+
platform_ext["cdpNodeId"] = node_id
|
|
633
|
+
node["platform"] = {"web": platform_ext}
|
|
634
|
+
|
|
635
|
+
return node
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# Tree reconstruction from flat CDP AX node list
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _build_tree_from_flat(
|
|
644
|
+
ax_nodes: list[dict],
|
|
645
|
+
id_gen: itertools.count,
|
|
646
|
+
stats: dict,
|
|
647
|
+
max_depth: int,
|
|
648
|
+
viewport_w: int,
|
|
649
|
+
viewport_h: int,
|
|
650
|
+
refs: dict,
|
|
651
|
+
ws_url: str | None = None,
|
|
652
|
+
) -> list[dict]:
|
|
653
|
+
"""Convert the flat CDP AX node list into a nested CUP tree.
|
|
654
|
+
|
|
655
|
+
CDP returns nodes with nodeId + childIds references. We build a
|
|
656
|
+
lookup table, then walk from the root to construct the nested structure.
|
|
657
|
+
"""
|
|
658
|
+
if not ax_nodes:
|
|
659
|
+
return []
|
|
660
|
+
|
|
661
|
+
# Build nodeId → ax_node lookup
|
|
662
|
+
node_map: dict[str, dict] = {}
|
|
663
|
+
for ax_node in ax_nodes:
|
|
664
|
+
nid = ax_node.get("nodeId", "")
|
|
665
|
+
if nid:
|
|
666
|
+
node_map[nid] = ax_node
|
|
667
|
+
|
|
668
|
+
cup_cache: dict[str, dict | None] = {}
|
|
669
|
+
|
|
670
|
+
def _convert(node_id: str, depth: int) -> dict | None:
|
|
671
|
+
if depth > max_depth:
|
|
672
|
+
return None
|
|
673
|
+
if node_id in cup_cache:
|
|
674
|
+
return cup_cache[node_id]
|
|
675
|
+
|
|
676
|
+
ax_node = node_map.get(node_id)
|
|
677
|
+
if ax_node is None:
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
# Check if this node should be skipped before building
|
|
681
|
+
cdp_role = _ax_value(ax_node.get("role")) or "generic"
|
|
682
|
+
if cdp_role in _SKIP_ROLES:
|
|
683
|
+
cup_cache[node_id] = None
|
|
684
|
+
# But still convert children — they may promote up
|
|
685
|
+
child_ids = ax_node.get("childIds", [])
|
|
686
|
+
promoted: list[dict] = []
|
|
687
|
+
if child_ids and depth < max_depth:
|
|
688
|
+
for cid in child_ids:
|
|
689
|
+
child = _convert(str(cid), depth)
|
|
690
|
+
if child is None:
|
|
691
|
+
continue
|
|
692
|
+
if "_promoted" in child:
|
|
693
|
+
promoted.extend(child["_promoted"])
|
|
694
|
+
else:
|
|
695
|
+
promoted.append(child)
|
|
696
|
+
# Return promoted children via a sentinel (handled below)
|
|
697
|
+
if promoted:
|
|
698
|
+
cup_cache[node_id] = {"_promoted": promoted}
|
|
699
|
+
return cup_cache[node_id]
|
|
700
|
+
|
|
701
|
+
cup_node = _build_cup_node(ax_node, id_gen, stats, viewport_w, viewport_h)
|
|
702
|
+
if cup_node is None:
|
|
703
|
+
cup_cache[node_id] = None
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
if ws_url is not None:
|
|
707
|
+
backend_id = ax_node.get("backendDOMNodeId")
|
|
708
|
+
if backend_id is not None:
|
|
709
|
+
refs[cup_node["id"]] = (ws_url, backend_id)
|
|
710
|
+
|
|
711
|
+
stats["max_depth"] = max(stats["max_depth"], depth)
|
|
712
|
+
|
|
713
|
+
# Recurse into children
|
|
714
|
+
child_ids = ax_node.get("childIds", [])
|
|
715
|
+
if child_ids and depth < max_depth:
|
|
716
|
+
children: list[dict] = []
|
|
717
|
+
for cid in child_ids:
|
|
718
|
+
child_result = _convert(str(cid), depth + 1)
|
|
719
|
+
if child_result is None:
|
|
720
|
+
continue
|
|
721
|
+
# Handle promoted children from skipped nodes
|
|
722
|
+
if "_promoted" in child_result:
|
|
723
|
+
children.extend(child_result["_promoted"])
|
|
724
|
+
else:
|
|
725
|
+
children.append(child_result)
|
|
726
|
+
if children:
|
|
727
|
+
cup_node["children"] = children
|
|
728
|
+
|
|
729
|
+
cup_cache[node_id] = cup_node
|
|
730
|
+
return cup_node
|
|
731
|
+
|
|
732
|
+
# Root is the first node (typically RootWebArea)
|
|
733
|
+
root_id = ax_nodes[0].get("nodeId", "")
|
|
734
|
+
root = _convert(root_id, 0)
|
|
735
|
+
if root is None:
|
|
736
|
+
return []
|
|
737
|
+
if "_promoted" in root:
|
|
738
|
+
return root["_promoted"]
|
|
739
|
+
return [root]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
# WebMCP tool discovery
|
|
744
|
+
# ---------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
_WEBMCP_JS = """\
|
|
747
|
+
(() => {
|
|
748
|
+
try {
|
|
749
|
+
const mc = navigator.modelContext;
|
|
750
|
+
if (!mc) return JSON.stringify([]);
|
|
751
|
+
let tools = [];
|
|
752
|
+
if (typeof mc.getTools === 'function') {
|
|
753
|
+
tools = mc.getTools();
|
|
754
|
+
} else if (mc.tools) {
|
|
755
|
+
tools = Array.from(mc.tools);
|
|
756
|
+
} else if (mc._tools) {
|
|
757
|
+
tools = Array.from(mc._tools);
|
|
758
|
+
}
|
|
759
|
+
return JSON.stringify(
|
|
760
|
+
tools.map(t => ({
|
|
761
|
+
name: t.name || '',
|
|
762
|
+
description: t.description || '',
|
|
763
|
+
inputSchema: t.inputSchema || null,
|
|
764
|
+
annotations: t.annotations || null,
|
|
765
|
+
})).filter(t => t.name)
|
|
766
|
+
);
|
|
767
|
+
} catch (e) {
|
|
768
|
+
return JSON.stringify([]);
|
|
769
|
+
}
|
|
770
|
+
})()
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _extract_webmcp_tools(ws: websocket.WebSocket) -> list[dict]:
|
|
775
|
+
"""Extract WebMCP tools from the page via Runtime.evaluate.
|
|
776
|
+
|
|
777
|
+
Returns a list of tool descriptors, or [] if WebMCP is not available.
|
|
778
|
+
Never raises.
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
resp = _cdp_send(
|
|
782
|
+
ws,
|
|
783
|
+
"Runtime.evaluate",
|
|
784
|
+
{
|
|
785
|
+
"expression": _WEBMCP_JS,
|
|
786
|
+
"returnByValue": True,
|
|
787
|
+
"awaitPromise": False,
|
|
788
|
+
},
|
|
789
|
+
timeout=5.0,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
remote_obj = resp.get("result", {}).get("result", {})
|
|
793
|
+
raw = remote_obj.get("value", "[]")
|
|
794
|
+
tools = json.loads(raw) if isinstance(raw, str) else []
|
|
795
|
+
# Validate structure
|
|
796
|
+
return [t for t in tools if isinstance(t, dict) and t.get("name")]
|
|
797
|
+
except Exception:
|
|
798
|
+
return []
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
# Viewport info
|
|
803
|
+
# ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _get_viewport_info(ws: websocket.WebSocket) -> tuple[int, int, float]:
|
|
807
|
+
"""Get viewport width, height, and device pixel ratio."""
|
|
808
|
+
try:
|
|
809
|
+
resp = _cdp_send(
|
|
810
|
+
ws,
|
|
811
|
+
"Runtime.evaluate",
|
|
812
|
+
{
|
|
813
|
+
"expression": (
|
|
814
|
+
"JSON.stringify({"
|
|
815
|
+
"w:window.innerWidth,"
|
|
816
|
+
"h:window.innerHeight,"
|
|
817
|
+
"s:window.devicePixelRatio})"
|
|
818
|
+
),
|
|
819
|
+
"returnByValue": True,
|
|
820
|
+
},
|
|
821
|
+
timeout=5.0,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
raw = resp.get("result", {}).get("result", {}).get("value", "{}")
|
|
825
|
+
info = json.loads(raw)
|
|
826
|
+
return (
|
|
827
|
+
int(info.get("w", 1920)),
|
|
828
|
+
int(info.get("h", 1080)),
|
|
829
|
+
float(info.get("s", 1.0)),
|
|
830
|
+
)
|
|
831
|
+
except Exception:
|
|
832
|
+
return (1920, 1080, 1.0)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# WebAdapter
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class WebAdapter(PlatformAdapter):
|
|
841
|
+
"""CUP adapter for web pages via Chrome DevTools Protocol (CDP).
|
|
842
|
+
|
|
843
|
+
Connects to a Chromium-based browser running with
|
|
844
|
+
``--remote-debugging-port``. Browser tabs map to CUP's
|
|
845
|
+
"window" concept.
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
def __init__(
|
|
849
|
+
self,
|
|
850
|
+
cdp_host: str | None = None,
|
|
851
|
+
cdp_port: int | None = None,
|
|
852
|
+
) -> None:
|
|
853
|
+
self._host = cdp_host or os.environ.get("CUP_CDP_HOST", "127.0.0.1")
|
|
854
|
+
self._port = int(cdp_port or os.environ.get("CUP_CDP_PORT", "9222"))
|
|
855
|
+
self._initialized = False
|
|
856
|
+
self._last_tools: list[dict] = []
|
|
857
|
+
|
|
858
|
+
# -- identity ----------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
@property
|
|
861
|
+
def platform_name(self) -> str:
|
|
862
|
+
return "web"
|
|
863
|
+
|
|
864
|
+
# -- lifecycle ---------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
def initialize(self) -> None:
|
|
867
|
+
if self._initialized:
|
|
868
|
+
return
|
|
869
|
+
try:
|
|
870
|
+
targets = _cdp_get_targets(self._host, self._port)
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
raise RuntimeError(
|
|
873
|
+
f"Cannot connect to CDP at {self._host}:{self._port}. "
|
|
874
|
+
f"Launch Chrome with: chrome --remote-debugging-port={self._port}\n"
|
|
875
|
+
f" Error: {exc}"
|
|
876
|
+
) from exc
|
|
877
|
+
page_targets = [t for t in targets if t.get("type") == "page"]
|
|
878
|
+
if not page_targets:
|
|
879
|
+
raise RuntimeError(
|
|
880
|
+
f"CDP endpoint at {self._host}:{self._port} has no page targets. "
|
|
881
|
+
f"Open at least one tab in the browser."
|
|
882
|
+
)
|
|
883
|
+
self._initialized = True
|
|
884
|
+
|
|
885
|
+
# -- screen ------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
def get_screen_info(self) -> tuple[int, int, float]:
|
|
888
|
+
"""Return viewport dimensions from the active tab."""
|
|
889
|
+
targets = _cdp_get_targets(self._host, self._port)
|
|
890
|
+
page_targets = [t for t in targets if t.get("type") == "page"]
|
|
891
|
+
if not page_targets:
|
|
892
|
+
return (1920, 1080, 1.0)
|
|
893
|
+
|
|
894
|
+
ws = _cdp_connect(page_targets[0]["webSocketDebuggerUrl"], self._host)
|
|
895
|
+
try:
|
|
896
|
+
return _get_viewport_info(ws)
|
|
897
|
+
finally:
|
|
898
|
+
_cdp_close(ws)
|
|
899
|
+
|
|
900
|
+
# -- window enumeration ------------------------------------------------
|
|
901
|
+
|
|
902
|
+
def _page_targets(self) -> list[dict]:
|
|
903
|
+
targets = _cdp_get_targets(self._host, self._port)
|
|
904
|
+
return [t for t in targets if t.get("type") == "page"]
|
|
905
|
+
|
|
906
|
+
def get_foreground_window(self) -> dict[str, Any]:
|
|
907
|
+
page_targets = self._page_targets()
|
|
908
|
+
if not page_targets:
|
|
909
|
+
raise RuntimeError("No browser tabs found")
|
|
910
|
+
t = page_targets[0]
|
|
911
|
+
return {
|
|
912
|
+
"handle": t["webSocketDebuggerUrl"],
|
|
913
|
+
"title": t.get("title", ""),
|
|
914
|
+
"pid": None,
|
|
915
|
+
"bundle_id": None,
|
|
916
|
+
"url": t.get("url", ""),
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
def get_all_windows(self) -> list[dict[str, Any]]:
|
|
920
|
+
return [
|
|
921
|
+
{
|
|
922
|
+
"handle": t["webSocketDebuggerUrl"],
|
|
923
|
+
"title": t.get("title", ""),
|
|
924
|
+
"pid": None,
|
|
925
|
+
"bundle_id": None,
|
|
926
|
+
"url": t.get("url", ""),
|
|
927
|
+
}
|
|
928
|
+
for t in self._page_targets()
|
|
929
|
+
]
|
|
930
|
+
|
|
931
|
+
# -- window overview ---------------------------------------------------
|
|
932
|
+
|
|
933
|
+
def get_window_list(self) -> list[dict[str, Any]]:
|
|
934
|
+
targets = self._page_targets()
|
|
935
|
+
results = []
|
|
936
|
+
for i, t in enumerate(targets):
|
|
937
|
+
results.append(
|
|
938
|
+
{
|
|
939
|
+
"title": t.get("title", ""),
|
|
940
|
+
"pid": None,
|
|
941
|
+
"bundle_id": None,
|
|
942
|
+
"foreground": i == 0,
|
|
943
|
+
"bounds": None,
|
|
944
|
+
"url": t.get("url", ""),
|
|
945
|
+
}
|
|
946
|
+
)
|
|
947
|
+
return results
|
|
948
|
+
|
|
949
|
+
def get_desktop_window(self) -> dict[str, Any] | None:
|
|
950
|
+
return None # web platform has no desktop concept
|
|
951
|
+
|
|
952
|
+
# -- tree capture ------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
def capture_tree(
|
|
955
|
+
self,
|
|
956
|
+
windows: list[dict[str, Any]],
|
|
957
|
+
*,
|
|
958
|
+
max_depth: int = 999,
|
|
959
|
+
) -> tuple[list[dict], dict, dict[str, Any]]:
|
|
960
|
+
self.initialize()
|
|
961
|
+
id_gen = itertools.count()
|
|
962
|
+
stats: dict[str, Any] = {"nodes": 0, "max_depth": 0, "roles": {}}
|
|
963
|
+
refs: dict[str, Any] = {}
|
|
964
|
+
tree: list[dict] = []
|
|
965
|
+
all_tools: list[dict] = []
|
|
966
|
+
|
|
967
|
+
for win in windows:
|
|
968
|
+
ws_url = win["handle"]
|
|
969
|
+
ws = _cdp_connect(ws_url, self._host)
|
|
970
|
+
try:
|
|
971
|
+
# Enable required CDP domains
|
|
972
|
+
_cdp_send(ws, "Accessibility.enable")
|
|
973
|
+
_cdp_send(ws, "Runtime.enable")
|
|
974
|
+
|
|
975
|
+
# Get viewport for offscreen detection
|
|
976
|
+
vw, vh, _ = _get_viewport_info(ws)
|
|
977
|
+
|
|
978
|
+
# Get the full AX tree
|
|
979
|
+
result = _cdp_send(ws, "Accessibility.getFullAXTree")
|
|
980
|
+
ax_nodes = result.get("result", {}).get("nodes", [])
|
|
981
|
+
|
|
982
|
+
roots = _build_tree_from_flat(
|
|
983
|
+
ax_nodes,
|
|
984
|
+
id_gen,
|
|
985
|
+
stats,
|
|
986
|
+
max_depth,
|
|
987
|
+
vw,
|
|
988
|
+
vh,
|
|
989
|
+
refs,
|
|
990
|
+
ws_url,
|
|
991
|
+
)
|
|
992
|
+
tree.extend(roots)
|
|
993
|
+
|
|
994
|
+
# Discover WebMCP tools
|
|
995
|
+
tools = _extract_webmcp_tools(ws)
|
|
996
|
+
all_tools.extend(tools)
|
|
997
|
+
except Exception:
|
|
998
|
+
continue
|
|
999
|
+
finally:
|
|
1000
|
+
_cdp_close(ws)
|
|
1001
|
+
|
|
1002
|
+
self._last_tools = all_tools
|
|
1003
|
+
return tree, stats, refs
|
|
1004
|
+
|
|
1005
|
+
# -- WebMCP tools ------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
def get_last_tools(self) -> list[dict]:
|
|
1008
|
+
"""Return WebMCP tools discovered during the last capture_tree() call."""
|
|
1009
|
+
return self._last_tools
|