ghost-hands 0.3.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.
- ghost_hands/__init__.py +41 -0
- ghost_hands/actions.py +253 -0
- ghost_hands/bench.py +1919 -0
- ghost_hands/cli.py +189 -0
- ghost_hands/deciders.py +272 -0
- ghost_hands/drivers.py +2353 -0
- ghost_hands/errors.py +10 -0
- ghost_hands/export.py +140 -0
- ghost_hands/eyes.py +477 -0
- ghost_hands/governor.py +245 -0
- ghost_hands/mcp_server.py +295 -0
- ghost_hands/runner.py +322 -0
- ghost_hands/trail.py +95 -0
- ghost_hands-0.3.0.dist-info/METADATA +251 -0
- ghost_hands-0.3.0.dist-info/RECORD +18 -0
- ghost_hands-0.3.0.dist-info/WHEEL +4 -0
- ghost_hands-0.3.0.dist-info/entry_points.txt +2 -0
- ghost_hands-0.3.0.dist-info/licenses/LICENSE +21 -0
ghost_hands/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Ghost Hands — governed agent hands for the web.
|
|
2
|
+
|
|
3
|
+
Playwright drives. Stagehand thinks. Browser Use wanders.
|
|
4
|
+
Ghost Hands answers for every move.
|
|
5
|
+
|
|
6
|
+
Zero-dependency core. Our own stdlib CDP client drives Chromium directly —
|
|
7
|
+
no Playwright, no Selenium, nothing else's automation stack under the hood.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .actions import Action
|
|
11
|
+
from .deciders import OpenAICompatibleDecider, RuleDecider, ScriptedDecider
|
|
12
|
+
from .drivers import ChromiumDriver, FakeDriver
|
|
13
|
+
from .errors import HandsError
|
|
14
|
+
from .export import export_ghost_hands_script
|
|
15
|
+
from .eyes import Element, ElementMap
|
|
16
|
+
from .governor import Governor, Policy, classify
|
|
17
|
+
from .runner import RunReport, Runner
|
|
18
|
+
from .trail import Trail, read_trail
|
|
19
|
+
|
|
20
|
+
__version__ = "0.3.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Action",
|
|
24
|
+
"ChromiumDriver",
|
|
25
|
+
"Element",
|
|
26
|
+
"ElementMap",
|
|
27
|
+
"FakeDriver",
|
|
28
|
+
"Governor",
|
|
29
|
+
"HandsError",
|
|
30
|
+
"OpenAICompatibleDecider",
|
|
31
|
+
"Policy",
|
|
32
|
+
"RuleDecider",
|
|
33
|
+
"RunReport",
|
|
34
|
+
"Runner",
|
|
35
|
+
"ScriptedDecider",
|
|
36
|
+
"Trail",
|
|
37
|
+
"classify",
|
|
38
|
+
"export_ghost_hands_script",
|
|
39
|
+
"read_trail",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
ghost_hands/actions.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Typed, JSON-serializable actions — the only moves Ghost Hands can make.
|
|
2
|
+
|
|
3
|
+
A target is always an integer element number taken from the current
|
|
4
|
+
ElementMap (see eyes.py), never a raw selector. That keeps every decision
|
|
5
|
+
auditable: the trail records exactly which numbered element was acted on.
|
|
6
|
+
|
|
7
|
+
v0.3 vocabulary additions: hover, double_click, right_click, drag,
|
|
8
|
+
click_at (raw coordinates — the documented fallback for canvas pages),
|
|
9
|
+
fill_form (one governed action filling many fields), set_file (uploads),
|
|
10
|
+
download, pdf, set_viewport, plus richer extract modes (list / table /
|
|
11
|
+
network) and conditioned waits (wait for text / element / URL).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
ACTION_KINDS = (
|
|
21
|
+
"navigate",
|
|
22
|
+
"click",
|
|
23
|
+
"type",
|
|
24
|
+
"press",
|
|
25
|
+
"select",
|
|
26
|
+
"scroll",
|
|
27
|
+
"extract",
|
|
28
|
+
"screenshot",
|
|
29
|
+
"wait",
|
|
30
|
+
"done",
|
|
31
|
+
# v0.3
|
|
32
|
+
"hover",
|
|
33
|
+
"double_click",
|
|
34
|
+
"right_click",
|
|
35
|
+
"drag",
|
|
36
|
+
"click_at",
|
|
37
|
+
"fill_form",
|
|
38
|
+
"set_file",
|
|
39
|
+
"download",
|
|
40
|
+
"pdf",
|
|
41
|
+
"set_viewport",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
EXTRACT_MODES = ("text", "list", "table", "network")
|
|
45
|
+
|
|
46
|
+
_FIELDS = (
|
|
47
|
+
"target",
|
|
48
|
+
"url",
|
|
49
|
+
"text",
|
|
50
|
+
"key",
|
|
51
|
+
"value",
|
|
52
|
+
"direction",
|
|
53
|
+
"amount",
|
|
54
|
+
"seconds",
|
|
55
|
+
"summary",
|
|
56
|
+
"path",
|
|
57
|
+
# v0.3
|
|
58
|
+
"mode",
|
|
59
|
+
"fields",
|
|
60
|
+
"submit",
|
|
61
|
+
"to_target",
|
|
62
|
+
"x",
|
|
63
|
+
"y",
|
|
64
|
+
"width",
|
|
65
|
+
"height",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Action:
|
|
71
|
+
"""One atomic move. All fields except ``kind`` are optional."""
|
|
72
|
+
|
|
73
|
+
kind: str
|
|
74
|
+
target: Optional[int] = None
|
|
75
|
+
url: Optional[str] = None
|
|
76
|
+
text: Optional[str] = None
|
|
77
|
+
key: Optional[str] = None
|
|
78
|
+
value: Optional[str] = None
|
|
79
|
+
direction: Optional[str] = None
|
|
80
|
+
amount: Optional[int] = None
|
|
81
|
+
seconds: Optional[float] = None
|
|
82
|
+
summary: Optional[str] = None
|
|
83
|
+
path: Optional[str] = None
|
|
84
|
+
# v0.3 fields
|
|
85
|
+
mode: Optional[str] = None # extract mode: text | list | table | network
|
|
86
|
+
fields: Optional[dict] = None # fill_form: {label-or-name: value}
|
|
87
|
+
submit: bool = False # fill_form: also submit the form (consequential)
|
|
88
|
+
to_target: Optional[int] = None # drag destination element number
|
|
89
|
+
x: Optional[float] = None # click_at coordinates / viewport pieces
|
|
90
|
+
y: Optional[float] = None
|
|
91
|
+
width: Optional[int] = None # set_viewport custom size
|
|
92
|
+
height: Optional[int] = None
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
if self.kind not in ACTION_KINDS:
|
|
96
|
+
raise ValueError(f"unknown action kind: {self.kind!r}")
|
|
97
|
+
if self.kind == "extract" and self.mode is not None and self.mode not in EXTRACT_MODES:
|
|
98
|
+
raise ValueError(f"unknown extract mode: {self.mode!r}")
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict[str, Any]:
|
|
101
|
+
out: dict[str, Any] = {"kind": self.kind}
|
|
102
|
+
for field in _FIELDS:
|
|
103
|
+
val = getattr(self, field)
|
|
104
|
+
if field == "submit":
|
|
105
|
+
if val:
|
|
106
|
+
out[field] = True
|
|
107
|
+
continue
|
|
108
|
+
if val is not None:
|
|
109
|
+
out[field] = val
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, Any]) -> "Action":
|
|
114
|
+
if not isinstance(data, dict) or "kind" not in data:
|
|
115
|
+
raise ValueError(f"action dict must carry a 'kind': {data!r}")
|
|
116
|
+
known = {k: v for k, v in data.items() if k in ("kind",) + _FIELDS}
|
|
117
|
+
return cls(**known)
|
|
118
|
+
|
|
119
|
+
def signature(self) -> tuple:
|
|
120
|
+
"""Stable identity used by the Runner's stuck detection."""
|
|
121
|
+
return (
|
|
122
|
+
self.kind,
|
|
123
|
+
self.target,
|
|
124
|
+
self.url,
|
|
125
|
+
self.text,
|
|
126
|
+
self.key,
|
|
127
|
+
self.value,
|
|
128
|
+
self.mode,
|
|
129
|
+
self.to_target,
|
|
130
|
+
self.x,
|
|
131
|
+
self.y,
|
|
132
|
+
json.dumps(self.fields, sort_keys=True) if self.fields else None,
|
|
133
|
+
self.submit,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -- Constructors (the vocabulary used by deciders, scripts, and the CLI) --
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def navigate(url: str) -> Action:
|
|
141
|
+
return Action(kind="navigate", url=url)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def click(target: int) -> Action:
|
|
145
|
+
return Action(kind="click", target=int(target))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def type(target: int, text: str) -> Action: # noqa: A001 - action vocabulary
|
|
149
|
+
return Action(kind="type", target=int(target), text=text)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def press(key: str) -> Action:
|
|
153
|
+
"""Press a key — or a chord, e.g. press("Control+a")."""
|
|
154
|
+
return Action(kind="press", key=key)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def select(target: int, value: str) -> Action:
|
|
158
|
+
return Action(kind="select", target=int(target), value=value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def scroll(direction: str = "down", amount: int = 500) -> Action:
|
|
162
|
+
return Action(kind="scroll", direction=direction, amount=int(amount))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def extract(target: Optional[int] = None, mode: str = "text") -> Action:
|
|
166
|
+
return Action(kind="extract", target=target, mode=mode)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def screenshot(path: Optional[str] = None) -> Action:
|
|
170
|
+
return Action(kind="screenshot", path=path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def wait(seconds: float) -> Action:
|
|
174
|
+
return Action(kind="wait", seconds=float(seconds))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def wait_for(
|
|
178
|
+
text: Optional[str] = None,
|
|
179
|
+
target: Optional[int] = None,
|
|
180
|
+
url_contains: Optional[str] = None,
|
|
181
|
+
timeout: float = 10.0,
|
|
182
|
+
) -> Action:
|
|
183
|
+
"""Wait until a condition holds (text visible / element present /
|
|
184
|
+
URL contains), up to ``timeout`` seconds. Times out with an honest
|
|
185
|
+
error — never silently."""
|
|
186
|
+
return Action(
|
|
187
|
+
kind="wait",
|
|
188
|
+
seconds=float(timeout),
|
|
189
|
+
text=text,
|
|
190
|
+
target=int(target) if target is not None else None,
|
|
191
|
+
url=url_contains,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def done(summary: str = "") -> Action:
|
|
196
|
+
return Action(kind="done", summary=summary)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# -- v0.3 constructors -------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def hover(target: int) -> Action:
|
|
203
|
+
return Action(kind="hover", target=int(target))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def double_click(target: int) -> Action:
|
|
207
|
+
return Action(kind="double_click", target=int(target))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def right_click(target: int) -> Action:
|
|
211
|
+
return Action(kind="right_click", target=int(target))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def drag(target: int, to_target: int) -> Action:
|
|
215
|
+
return Action(kind="drag", target=int(target), to_target=int(to_target))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def click_at(x: float, y: float) -> Action:
|
|
219
|
+
"""Raw-coordinate click — the fallback for canvas/coordinate pages
|
|
220
|
+
where no element map applies. Governed as a write; the trail marks
|
|
221
|
+
it raw:true so coordinate moves are always visible in the record."""
|
|
222
|
+
return Action(kind="click_at", x=float(x), y=float(y))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def fill_form(fields: dict, submit: bool = False) -> Action:
|
|
226
|
+
"""Fill several fields in one governed action. Keys are field
|
|
227
|
+
descriptors (label text, name, placeholder, aria-label, id); values
|
|
228
|
+
are the strings to enter. ``submit=True`` also triggers the form's
|
|
229
|
+
submit control and makes the action consequential."""
|
|
230
|
+
return Action(kind="fill_form", fields=dict(fields), submit=bool(submit))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def set_file(target: int, path: str) -> Action:
|
|
234
|
+
"""Attach a local file to an <input type=file>. The path must exist
|
|
235
|
+
and be a file; directories are refused."""
|
|
236
|
+
return Action(kind="set_file", target=int(target), path=path)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def download(target: Optional[int] = None, url: Optional[str] = None) -> Action:
|
|
240
|
+
"""Start a download (click the target link, or navigate to the URL)
|
|
241
|
+
and wait for it to complete. The result names the file and its size;
|
|
242
|
+
completion is also a ``download`` trail event."""
|
|
243
|
+
return Action(kind="download", target=target, url=url)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def pdf(path: Optional[str] = None) -> Action:
|
|
247
|
+
return Action(kind="pdf", path=path)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_viewport(preset: Optional[str] = None, width=None, height=None) -> Action:
|
|
251
|
+
"""Emulate a viewport: a named preset ("desktop" 1280x800, "mobile"
|
|
252
|
+
390x844 with touch) or explicit width/height."""
|
|
253
|
+
return Action(kind="set_viewport", value=preset, width=width, height=height)
|