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/format.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CUP format utilities: envelope builder, compact text serializer, and overview.
|
|
3
|
+
|
|
4
|
+
Shared across platform-specific tree capture scripts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import time
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
Detail = Literal["compact", "full"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# CUP envelope
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_envelope(
|
|
22
|
+
tree_data: list[dict],
|
|
23
|
+
*,
|
|
24
|
+
platform: str,
|
|
25
|
+
scope: str | None = None,
|
|
26
|
+
screen_w: int,
|
|
27
|
+
screen_h: int,
|
|
28
|
+
screen_scale: float | None = None,
|
|
29
|
+
app_name: str | None = None,
|
|
30
|
+
app_pid: int | None = None,
|
|
31
|
+
app_bundle_id: str | None = None,
|
|
32
|
+
tools: list[dict] | None = None,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Wrap tree nodes in the CUP envelope with metadata."""
|
|
35
|
+
screen: dict = {"w": screen_w, "h": screen_h}
|
|
36
|
+
if screen_scale is not None and screen_scale != 1.0:
|
|
37
|
+
screen["scale"] = screen_scale
|
|
38
|
+
|
|
39
|
+
envelope: dict = {
|
|
40
|
+
"version": "0.1.0",
|
|
41
|
+
"platform": platform,
|
|
42
|
+
"timestamp": int(time.time() * 1000),
|
|
43
|
+
"screen": screen,
|
|
44
|
+
}
|
|
45
|
+
if scope:
|
|
46
|
+
envelope["scope"] = scope
|
|
47
|
+
if app_name or app_pid is not None or app_bundle_id:
|
|
48
|
+
app_info: dict = {}
|
|
49
|
+
if app_name:
|
|
50
|
+
app_info["name"] = app_name
|
|
51
|
+
if app_pid is not None:
|
|
52
|
+
app_info["pid"] = app_pid
|
|
53
|
+
if app_bundle_id:
|
|
54
|
+
app_info["bundleId"] = app_bundle_id
|
|
55
|
+
envelope["app"] = app_info
|
|
56
|
+
envelope["tree"] = tree_data
|
|
57
|
+
if tools:
|
|
58
|
+
envelope["tools"] = tools
|
|
59
|
+
return envelope
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Overview serializer (window list only, no tree)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def serialize_overview(
|
|
68
|
+
window_list: list[dict],
|
|
69
|
+
*,
|
|
70
|
+
platform: str,
|
|
71
|
+
screen_w: int,
|
|
72
|
+
screen_h: int,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Serialize a window list to compact overview text.
|
|
75
|
+
|
|
76
|
+
No tree walking, no element IDs — just a list of open windows
|
|
77
|
+
for situational awareness.
|
|
78
|
+
"""
|
|
79
|
+
lines = [
|
|
80
|
+
f"# CUP 0.1.0 | {platform} | {screen_w}x{screen_h}",
|
|
81
|
+
f"# overview | {len(window_list)} windows",
|
|
82
|
+
"",
|
|
83
|
+
]
|
|
84
|
+
for win in window_list:
|
|
85
|
+
title = win.get("title", "(untitled)")
|
|
86
|
+
pid = win.get("pid")
|
|
87
|
+
is_fg = win.get("foreground", False)
|
|
88
|
+
bounds = win.get("bounds")
|
|
89
|
+
|
|
90
|
+
prefix = "* " if is_fg else " "
|
|
91
|
+
marker = "[fg] " if is_fg else ""
|
|
92
|
+
|
|
93
|
+
parts = [f"{prefix}{marker}{title}"]
|
|
94
|
+
if pid is not None:
|
|
95
|
+
parts.append(f"(pid:{pid})")
|
|
96
|
+
if bounds:
|
|
97
|
+
parts.append(f"@{bounds['x']},{bounds['y']} {bounds['w']}x{bounds['h']}")
|
|
98
|
+
|
|
99
|
+
url = win.get("url")
|
|
100
|
+
if url:
|
|
101
|
+
truncated_url = url[:80] + ("..." if len(url) > 80 else "")
|
|
102
|
+
parts.append(f"url:{truncated_url}")
|
|
103
|
+
|
|
104
|
+
lines.append(" ".join(parts))
|
|
105
|
+
|
|
106
|
+
return "\n".join(lines) + "\n"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Compact text serializer
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _count_nodes(nodes: list[dict]) -> int:
|
|
115
|
+
"""Count total nodes in a tree."""
|
|
116
|
+
total = 0
|
|
117
|
+
for node in nodes:
|
|
118
|
+
total += 1
|
|
119
|
+
total += _count_nodes(node.get("children", []))
|
|
120
|
+
return total
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_CHROME_ROLES = frozenset({"scrollbar", "separator", "titlebar", "tooltip", "status"})
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Vocabulary short codes — compact aliases for roles, states, and actions.
|
|
127
|
+
# These reduce per-node token cost by ~50% on role/state/action strings.
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
ROLE_CODES: dict[str, str] = {
|
|
131
|
+
"alert": "alrt",
|
|
132
|
+
"alertdialog": "adlg",
|
|
133
|
+
"application": "app",
|
|
134
|
+
"banner": "bnr",
|
|
135
|
+
"button": "btn",
|
|
136
|
+
"cell": "cel",
|
|
137
|
+
"checkbox": "chk",
|
|
138
|
+
"columnheader": "colh",
|
|
139
|
+
"combobox": "cmb",
|
|
140
|
+
"complementary": "cmp",
|
|
141
|
+
"contentinfo": "ci",
|
|
142
|
+
"dialog": "dlg",
|
|
143
|
+
"document": "doc",
|
|
144
|
+
"form": "frm",
|
|
145
|
+
"generic": "gen",
|
|
146
|
+
"grid": "grd",
|
|
147
|
+
"group": "grp",
|
|
148
|
+
"heading": "hdg",
|
|
149
|
+
"img": "img",
|
|
150
|
+
"link": "lnk",
|
|
151
|
+
"list": "lst",
|
|
152
|
+
"listitem": "li",
|
|
153
|
+
"log": "log",
|
|
154
|
+
"main": "main",
|
|
155
|
+
"marquee": "mrq",
|
|
156
|
+
"menu": "mnu",
|
|
157
|
+
"menubar": "mnub",
|
|
158
|
+
"menuitem": "mi",
|
|
159
|
+
"menuitemcheckbox": "mic",
|
|
160
|
+
"menuitemradio": "mir",
|
|
161
|
+
"navigation": "nav",
|
|
162
|
+
"none": "none",
|
|
163
|
+
"option": "opt",
|
|
164
|
+
"progressbar": "pbar",
|
|
165
|
+
"radio": "rad",
|
|
166
|
+
"region": "rgn",
|
|
167
|
+
"row": "row",
|
|
168
|
+
"rowheader": "rowh",
|
|
169
|
+
"scrollbar": "sb",
|
|
170
|
+
"search": "srch",
|
|
171
|
+
"searchbox": "sbx",
|
|
172
|
+
"separator": "sep",
|
|
173
|
+
"slider": "sld",
|
|
174
|
+
"spinbutton": "spn",
|
|
175
|
+
"status": "sts",
|
|
176
|
+
"switch": "sw",
|
|
177
|
+
"tab": "tab",
|
|
178
|
+
"table": "tbl",
|
|
179
|
+
"tablist": "tabs",
|
|
180
|
+
"tabpanel": "tpnl",
|
|
181
|
+
"text": "txt",
|
|
182
|
+
"textbox": "tbx",
|
|
183
|
+
"timer": "tmr",
|
|
184
|
+
"titlebar": "ttlb",
|
|
185
|
+
"toolbar": "tlbr",
|
|
186
|
+
"tooltip": "ttp",
|
|
187
|
+
"tree": "tre",
|
|
188
|
+
"treeitem": "ti",
|
|
189
|
+
"window": "win",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
STATE_CODES: dict[str, str] = {
|
|
193
|
+
"busy": "bsy",
|
|
194
|
+
"checked": "chk",
|
|
195
|
+
"collapsed": "col",
|
|
196
|
+
"disabled": "dis",
|
|
197
|
+
"editable": "edt",
|
|
198
|
+
"expanded": "exp",
|
|
199
|
+
"focused": "foc",
|
|
200
|
+
"hidden": "hid",
|
|
201
|
+
"mixed": "mix",
|
|
202
|
+
"modal": "mod",
|
|
203
|
+
"multiselectable": "msel",
|
|
204
|
+
"offscreen": "off",
|
|
205
|
+
"pressed": "prs",
|
|
206
|
+
"readonly": "ro",
|
|
207
|
+
"required": "req",
|
|
208
|
+
"selected": "sel",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ACTION_CODES: dict[str, str] = {
|
|
212
|
+
"click": "clk",
|
|
213
|
+
"collapse": "col",
|
|
214
|
+
"decrement": "dec",
|
|
215
|
+
"dismiss": "dsm",
|
|
216
|
+
"doubleclick": "dbl",
|
|
217
|
+
"expand": "exp",
|
|
218
|
+
"focus": "foc",
|
|
219
|
+
"increment": "inc",
|
|
220
|
+
"longpress": "lp",
|
|
221
|
+
"rightclick": "rclk",
|
|
222
|
+
"scroll": "scr",
|
|
223
|
+
"select": "sel",
|
|
224
|
+
"setvalue": "sv",
|
|
225
|
+
"toggle": "tog",
|
|
226
|
+
"type": "typ",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _should_skip(node: dict, parent: dict | None, siblings: int) -> bool:
|
|
231
|
+
"""Decide if a node should be pruned (entire subtree is dropped)."""
|
|
232
|
+
role = node["role"]
|
|
233
|
+
name = node.get("name", "")
|
|
234
|
+
states = node.get("states", [])
|
|
235
|
+
|
|
236
|
+
# Skip window chrome / decorative roles (and their entire subtrees).
|
|
237
|
+
# Scrollbar: the parent container already has [scroll] — agents never
|
|
238
|
+
# click scrollbar thumbs/tracks.
|
|
239
|
+
# Separator: pure visual decoration, no semantic content.
|
|
240
|
+
# Titlebar: minimize/maximize/close — agents use press instead.
|
|
241
|
+
# Tooltip: transient flyouts, rarely actionable.
|
|
242
|
+
# Status: read-only info (line numbers, encoding, git branch) — agents
|
|
243
|
+
# can still find these via find on the raw tree if needed.
|
|
244
|
+
if role in _CHROME_ROLES:
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
# Skip zero-size elements — invisible regardless of other properties
|
|
248
|
+
bounds = node.get("bounds")
|
|
249
|
+
if bounds and (bounds.get("w", 1) == 0 or bounds.get("h", 1) == 0):
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
# Skip offscreen nodes that have no meaningful actions — they can't be
|
|
253
|
+
# interacted with until scrolled into view and add no actionable info.
|
|
254
|
+
# Offscreen buttons/links/inputs ARE kept so the LLM knows what's
|
|
255
|
+
# available after scrolling.
|
|
256
|
+
if "offscreen" in states:
|
|
257
|
+
actions = node.get("actions", [])
|
|
258
|
+
meaningful_actions = [a for a in actions if a != "focus"]
|
|
259
|
+
if not meaningful_actions:
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
# Skip unnamed decorative images
|
|
263
|
+
if role == "img" and not name:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
# Skip empty-name text nodes
|
|
267
|
+
if role == "text" and not name:
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
# Skip text that is sole child of a named parent (redundant label)
|
|
271
|
+
if role == "text" and parent and parent.get("name") and siblings == 1:
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _should_hoist(node: dict) -> bool:
|
|
278
|
+
"""Decide if a node's children should be hoisted (node itself skipped)."""
|
|
279
|
+
role = node["role"]
|
|
280
|
+
name = node.get("name", "")
|
|
281
|
+
|
|
282
|
+
# Unnamed generic nodes are structural wrappers -- hoist children
|
|
283
|
+
if role == "generic" and not name:
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
# Unnamed region nodes — very common in Electron/Chromium apps where
|
|
287
|
+
# nested <div> wrappers get exposed as UIA regions. Pure noise.
|
|
288
|
+
if role == "region" and not name:
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# Unnamed group nodes without meaningful actions are structural wrappers.
|
|
292
|
+
# On Windows, these map to Pane->generic and get hoisted above.
|
|
293
|
+
# On macOS, AXGroup is used for both semantic and structural containers,
|
|
294
|
+
# so we hoist only when there's no name and no actions (pure wrapper).
|
|
295
|
+
if role == "group" and not name:
|
|
296
|
+
actions = node.get("actions", [])
|
|
297
|
+
meaningful = [a for a in actions if a != "focus"]
|
|
298
|
+
if not meaningful:
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Viewport clipping helpers
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_outside_viewport(child_bounds: dict, viewport: dict) -> bool:
|
|
310
|
+
"""Return True if child_bounds falls entirely outside the viewport rect."""
|
|
311
|
+
return (
|
|
312
|
+
child_bounds["x"] + child_bounds["w"] <= viewport["x"] # fully left
|
|
313
|
+
or child_bounds["x"] >= viewport["x"] + viewport["w"] # fully right
|
|
314
|
+
or child_bounds["y"] + child_bounds["h"] <= viewport["y"] # fully above
|
|
315
|
+
or child_bounds["y"] >= viewport["y"] + viewport["h"] # fully below
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _clip_direction(child_bounds: dict, viewport: dict) -> str:
|
|
320
|
+
"""Return 'above', 'below', 'left', or 'right' for a clipped child."""
|
|
321
|
+
if child_bounds["y"] + child_bounds["h"] <= viewport["y"]:
|
|
322
|
+
return "above"
|
|
323
|
+
if child_bounds["y"] >= viewport["y"] + viewport["h"]:
|
|
324
|
+
return "below"
|
|
325
|
+
if child_bounds["x"] + child_bounds["w"] <= viewport["x"]:
|
|
326
|
+
return "left"
|
|
327
|
+
return "right"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _is_scrollable(node: dict) -> bool:
|
|
331
|
+
"""Check if a node is a scrollable container."""
|
|
332
|
+
return "scroll" in node.get("actions", [])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _intersect_viewports(bounds: dict, viewport: dict | None) -> dict:
|
|
336
|
+
"""Intersect a scrollable container's bounds with its parent viewport.
|
|
337
|
+
|
|
338
|
+
A scrollable child may report bounds larger than its visible area (e.g. a
|
|
339
|
+
grid reporting 1888px height inside a 398px list). Intersecting ensures
|
|
340
|
+
the effective viewport is never larger than the parent's visible region.
|
|
341
|
+
"""
|
|
342
|
+
if viewport is None:
|
|
343
|
+
return bounds
|
|
344
|
+
x1 = max(bounds["x"], viewport["x"])
|
|
345
|
+
y1 = max(bounds["y"], viewport["y"])
|
|
346
|
+
x2 = min(bounds["x"] + bounds["w"], viewport["x"] + viewport["w"])
|
|
347
|
+
y2 = min(bounds["y"] + bounds["h"], viewport["y"] + viewport["h"])
|
|
348
|
+
return {"x": x1, "y": y1, "w": max(0, x2 - x1), "h": max(0, y2 - y1)}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# JSON tree pruning
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _prune_node(
|
|
357
|
+
node: dict,
|
|
358
|
+
parent: dict | None,
|
|
359
|
+
siblings: int,
|
|
360
|
+
viewport: dict | None = None,
|
|
361
|
+
) -> list[dict]:
|
|
362
|
+
"""Prune a single node, returning 0 or more nodes to replace it.
|
|
363
|
+
|
|
364
|
+
- Hoisted nodes are removed and their (pruned) children returned in place.
|
|
365
|
+
- Skipped nodes are dropped entirely (with descendants).
|
|
366
|
+
- Viewport-clipped nodes are dropped with a count tracked on the
|
|
367
|
+
scrollable ancestor (emitted as a hint in compact output).
|
|
368
|
+
- Normal nodes are kept with their children recursively pruned.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
viewport: Bounds rect of the nearest scrollable ancestor, or None.
|
|
372
|
+
"""
|
|
373
|
+
children = node.get("children", [])
|
|
374
|
+
|
|
375
|
+
if _should_hoist(node):
|
|
376
|
+
result = []
|
|
377
|
+
for child in children:
|
|
378
|
+
result.extend(_prune_node(child, parent, len(children), viewport))
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
if _should_skip(node, parent, siblings):
|
|
382
|
+
return []
|
|
383
|
+
|
|
384
|
+
# Determine the viewport for this node's children: if this node is a
|
|
385
|
+
# scrollable container with bounds, its bounds become the viewport.
|
|
386
|
+
# Intersect with the inherited viewport so a scrollable child can't
|
|
387
|
+
# expand beyond its parent's visible region (e.g. a grid that reports
|
|
388
|
+
# 1888px height inside a 398px-tall list).
|
|
389
|
+
child_viewport = viewport
|
|
390
|
+
if _is_scrollable(node) and node.get("bounds"):
|
|
391
|
+
child_viewport = _intersect_viewports(node["bounds"], viewport)
|
|
392
|
+
|
|
393
|
+
# Keep this node — prune its children recursively, clipping those
|
|
394
|
+
# that fall entirely outside the active viewport.
|
|
395
|
+
pruned_children = []
|
|
396
|
+
clipped = {"above": 0, "below": 0, "left": 0, "right": 0}
|
|
397
|
+
has_clipped = False
|
|
398
|
+
|
|
399
|
+
for child in children:
|
|
400
|
+
child_bounds = child.get("bounds")
|
|
401
|
+
# Clip children outside the viewport of a scrollable container
|
|
402
|
+
if child_viewport and child_bounds and _is_outside_viewport(child_bounds, child_viewport):
|
|
403
|
+
direction = _clip_direction(child_bounds, child_viewport)
|
|
404
|
+
clipped[direction] += _count_nodes([child])
|
|
405
|
+
has_clipped = True
|
|
406
|
+
continue
|
|
407
|
+
pruned_children.extend(_prune_node(child, node, len(children), child_viewport))
|
|
408
|
+
|
|
409
|
+
# Single-child structural collapse: unnamed structural containers that
|
|
410
|
+
# ended up wrapping a single child after pruning (and carry no actions
|
|
411
|
+
# of their own) are pure wrappers — replace them with the child.
|
|
412
|
+
if (
|
|
413
|
+
len(pruned_children) == 1
|
|
414
|
+
and node["role"] in _COLLAPSIBLE_ROLES
|
|
415
|
+
and not node.get("name")
|
|
416
|
+
and not _has_meaningful_actions(node)
|
|
417
|
+
):
|
|
418
|
+
return pruned_children
|
|
419
|
+
|
|
420
|
+
pruned = {k: v for k, v in node.items() if k != "children"}
|
|
421
|
+
if pruned_children:
|
|
422
|
+
pruned["children"] = pruned_children
|
|
423
|
+
if has_clipped:
|
|
424
|
+
pruned["_clipped"] = clipped
|
|
425
|
+
return [pruned]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# Structural container roles eligible for single-child collapse.
|
|
429
|
+
# When an unnamed node with one of these roles ends up with exactly one
|
|
430
|
+
# child after pruning (and has no actions of its own), it's a pure wrapper
|
|
431
|
+
# and the child is hoisted in its place.
|
|
432
|
+
_COLLAPSIBLE_ROLES = frozenset(
|
|
433
|
+
{
|
|
434
|
+
"region",
|
|
435
|
+
"document",
|
|
436
|
+
"main",
|
|
437
|
+
"complementary",
|
|
438
|
+
"navigation",
|
|
439
|
+
"search",
|
|
440
|
+
"banner",
|
|
441
|
+
"contentinfo",
|
|
442
|
+
"form",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _has_meaningful_actions(node: dict) -> bool:
|
|
448
|
+
"""Check if a node has actions beyond just 'focus'."""
|
|
449
|
+
actions = node.get("actions", [])
|
|
450
|
+
return any(a != "focus" for a in actions)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def prune_tree(
|
|
454
|
+
tree: list[dict],
|
|
455
|
+
*,
|
|
456
|
+
detail: Detail = "compact",
|
|
457
|
+
screen: dict | None = None,
|
|
458
|
+
) -> list[dict]:
|
|
459
|
+
"""Apply pruning to a CUP tree, returning a new pruned tree.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
tree: List of root CUP node dicts.
|
|
463
|
+
detail: Pruning level:
|
|
464
|
+
"compact" — Remove unnamed generics, decorative images, empty
|
|
465
|
+
text, offscreen noise, etc. (default)
|
|
466
|
+
"full" — No pruning; return every node from the raw tree.
|
|
467
|
+
screen: Screen dimensions dict with "w" and "h" keys. When provided,
|
|
468
|
+
elements entirely outside the screen bounds are clipped even
|
|
469
|
+
if no scrollable ancestor is present.
|
|
470
|
+
"""
|
|
471
|
+
if detail == "full":
|
|
472
|
+
return copy.deepcopy(tree)
|
|
473
|
+
|
|
474
|
+
# "compact" — use screen as baseline viewport so elements far offscreen
|
|
475
|
+
# (e.g. in web-based apps with virtual scroll) are clipped even when no
|
|
476
|
+
# ancestor exposes the "scroll" action.
|
|
477
|
+
screen_viewport = None
|
|
478
|
+
if screen:
|
|
479
|
+
screen_viewport = {"x": 0, "y": 0, "w": screen["w"], "h": screen["h"]}
|
|
480
|
+
result = []
|
|
481
|
+
for root in tree:
|
|
482
|
+
result.extend(_prune_node(root, None, len(tree), viewport=screen_viewport))
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _format_line(node: dict) -> str:
|
|
487
|
+
"""Format a single CUP node as a compact one-liner."""
|
|
488
|
+
role = node["role"]
|
|
489
|
+
parts = [f"[{node['id']}]", ROLE_CODES.get(role, role)]
|
|
490
|
+
|
|
491
|
+
name = node.get("name", "")
|
|
492
|
+
if name:
|
|
493
|
+
truncated = name[:80] + ("..." if len(name) > 80 else "")
|
|
494
|
+
# Escape quotes and newlines in name
|
|
495
|
+
truncated = truncated.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
|
|
496
|
+
parts.append(f'"{truncated}"')
|
|
497
|
+
|
|
498
|
+
# Actions (drop "focus" -- it's noise)
|
|
499
|
+
actions = [a for a in node.get("actions", []) if a != "focus"]
|
|
500
|
+
|
|
501
|
+
# Only include bounds for interactable nodes (nodes with meaningful actions).
|
|
502
|
+
# Non-interactable nodes are context-only — agents reference them by ID, not
|
|
503
|
+
# by coordinates, so spatial info adds tokens without value.
|
|
504
|
+
bounds = node.get("bounds")
|
|
505
|
+
if bounds and actions:
|
|
506
|
+
parts.append(f"{bounds['x']},{bounds['y']} {bounds['w']}x{bounds['h']}")
|
|
507
|
+
|
|
508
|
+
states = node.get("states", [])
|
|
509
|
+
if states:
|
|
510
|
+
parts.append("{" + ",".join(STATE_CODES.get(s, s) for s in states) + "}")
|
|
511
|
+
|
|
512
|
+
if actions:
|
|
513
|
+
parts.append("[" + ",".join(ACTION_CODES.get(a, a) for a in actions) + "]")
|
|
514
|
+
|
|
515
|
+
# Value for input-type elements
|
|
516
|
+
value = node.get("value", "")
|
|
517
|
+
if value and role in ("textbox", "searchbox", "combobox", "spinbutton", "slider"):
|
|
518
|
+
truncated_val = value[:120] + ("..." if len(value) > 120 else "")
|
|
519
|
+
truncated_val = truncated_val.replace('"', '\\"').replace("\n", " ")
|
|
520
|
+
parts.append(f'val="{truncated_val}"')
|
|
521
|
+
|
|
522
|
+
# Compact attributes (only the most useful ones for LLM context)
|
|
523
|
+
attrs = node.get("attributes", {})
|
|
524
|
+
if attrs:
|
|
525
|
+
attr_parts = []
|
|
526
|
+
if "level" in attrs:
|
|
527
|
+
attr_parts.append(f"L{attrs['level']}")
|
|
528
|
+
if "placeholder" in attrs:
|
|
529
|
+
ph = attrs["placeholder"][:30]
|
|
530
|
+
ph = ph.replace('"', '\\"').replace("\n", " ")
|
|
531
|
+
attr_parts.append(f'ph="{ph}"')
|
|
532
|
+
if "orientation" in attrs:
|
|
533
|
+
attr_parts.append(attrs["orientation"][:1]) # "h" or "v"
|
|
534
|
+
if "valueMin" in attrs or "valueMax" in attrs:
|
|
535
|
+
vmin = attrs.get("valueMin", "")
|
|
536
|
+
vmax = attrs.get("valueMax", "")
|
|
537
|
+
attr_parts.append(f"range={vmin}..{vmax}")
|
|
538
|
+
if attr_parts:
|
|
539
|
+
parts.append("(" + " ".join(attr_parts) + ")")
|
|
540
|
+
|
|
541
|
+
return " ".join(parts)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _emit_compact(node: dict, depth: int, lines: list[str], counter: list[int]) -> None:
|
|
545
|
+
"""Recursively emit compact lines for an already-pruned node."""
|
|
546
|
+
counter[0] += 1
|
|
547
|
+
indent = " " * depth
|
|
548
|
+
lines.append(f"{indent}{_format_line(node)}")
|
|
549
|
+
|
|
550
|
+
for child in node.get("children", []):
|
|
551
|
+
_emit_compact(child, depth + 1, lines, counter)
|
|
552
|
+
|
|
553
|
+
# Emit hint for viewport-clipped children
|
|
554
|
+
clipped = node.get("_clipped")
|
|
555
|
+
if clipped:
|
|
556
|
+
above = clipped.get("above", 0)
|
|
557
|
+
below = clipped.get("below", 0)
|
|
558
|
+
left = clipped.get("left", 0)
|
|
559
|
+
right = clipped.get("right", 0)
|
|
560
|
+
v_total = above + below
|
|
561
|
+
h_total = left + right
|
|
562
|
+
total = v_total + h_total
|
|
563
|
+
if total > 0:
|
|
564
|
+
directions = []
|
|
565
|
+
if above > 0:
|
|
566
|
+
directions.append("up")
|
|
567
|
+
if below > 0:
|
|
568
|
+
directions.append("down")
|
|
569
|
+
if left > 0:
|
|
570
|
+
directions.append("left")
|
|
571
|
+
if right > 0:
|
|
572
|
+
directions.append("right")
|
|
573
|
+
hint_indent = " " * (depth + 1)
|
|
574
|
+
lines.append(
|
|
575
|
+
f"{hint_indent}# {total} more items — scroll {'/'.join(directions)} to see"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# Maximum output size in characters. Prevents token-limit explosions when
|
|
580
|
+
# agents accidentally request very large trees. Kept well under typical
|
|
581
|
+
# MCP host limits (~100K) to leave room for JSON wrapping and other context.
|
|
582
|
+
MAX_OUTPUT_CHARS = 40_000
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def serialize_compact(
|
|
586
|
+
envelope: dict,
|
|
587
|
+
*,
|
|
588
|
+
window_list: list[dict] | None = None,
|
|
589
|
+
detail: Detail = "compact",
|
|
590
|
+
max_chars: int = MAX_OUTPUT_CHARS,
|
|
591
|
+
) -> str:
|
|
592
|
+
"""Serialize a CUP envelope to compact LLM-friendly text.
|
|
593
|
+
|
|
594
|
+
Applies pruning to remove structural noise while preserving all
|
|
595
|
+
semantically meaningful and interactive elements. Node IDs are
|
|
596
|
+
preserved from the full tree so agents can reference them in actions.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
envelope: CUP envelope dict with tree data.
|
|
600
|
+
window_list: Optional list of open windows to include in header
|
|
601
|
+
for situational awareness (used by foreground scope).
|
|
602
|
+
detail: Pruning level ("compact" or "full").
|
|
603
|
+
max_chars: Hard character limit for output. When exceeded, the
|
|
604
|
+
output is truncated with a diagnostic message.
|
|
605
|
+
"""
|
|
606
|
+
total_before = _count_nodes(envelope["tree"])
|
|
607
|
+
pruned = prune_tree(envelope["tree"], detail=detail, screen=envelope.get("screen"))
|
|
608
|
+
|
|
609
|
+
lines: list[str] = []
|
|
610
|
+
counter = [0]
|
|
611
|
+
|
|
612
|
+
for root in pruned:
|
|
613
|
+
_emit_compact(root, 0, lines, counter)
|
|
614
|
+
|
|
615
|
+
# Build header
|
|
616
|
+
header_lines = [
|
|
617
|
+
f"# CUP {envelope['version']} | {envelope['platform']} | {envelope['screen']['w']}x{envelope['screen']['h']}",
|
|
618
|
+
]
|
|
619
|
+
if envelope.get("app"):
|
|
620
|
+
header_lines.append(f"# app: {envelope['app'].get('name', '')}")
|
|
621
|
+
header_lines.append(f"# {counter[0]} nodes ({total_before} before pruning)")
|
|
622
|
+
if envelope.get("tools"):
|
|
623
|
+
n = len(envelope["tools"])
|
|
624
|
+
header_lines.append(f"# {n} WebMCP tool{'s' if n != 1 else ''} available")
|
|
625
|
+
|
|
626
|
+
# Window list in header (for foreground scope awareness)
|
|
627
|
+
if window_list:
|
|
628
|
+
header_lines.append(f"# --- {len(window_list)} open windows ---")
|
|
629
|
+
for win in window_list:
|
|
630
|
+
title = win.get("title", "(untitled)")[:50]
|
|
631
|
+
is_fg = win.get("foreground", False)
|
|
632
|
+
marker = " [fg]" if is_fg else ""
|
|
633
|
+
header_lines.append(f"# {title}{marker}")
|
|
634
|
+
|
|
635
|
+
header_lines.append("")
|
|
636
|
+
|
|
637
|
+
output = "\n".join(header_lines + lines) + "\n"
|
|
638
|
+
|
|
639
|
+
# Hard truncation safety net
|
|
640
|
+
if max_chars > 0 and len(output) > max_chars:
|
|
641
|
+
truncated = output[:max_chars]
|
|
642
|
+
# Cut at last newline to avoid partial lines
|
|
643
|
+
last_nl = truncated.rfind("\n")
|
|
644
|
+
if last_nl > 0:
|
|
645
|
+
truncated = truncated[:last_nl]
|
|
646
|
+
truncated += (
|
|
647
|
+
"\n\n# OUTPUT TRUNCATED — exceeded character limit.\n"
|
|
648
|
+
"# Use find(name=...) to locate specific elements instead.\n"
|
|
649
|
+
"# Or use snapshot_app(app='<title>') to target a specific window.\n"
|
|
650
|
+
)
|
|
651
|
+
return truncated
|
|
652
|
+
|
|
653
|
+
return output
|
cup/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CUP MCP Server — expose UI accessibility trees and actions to AI agents."""
|