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.
@@ -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)