glasswarp 0.1.0__tar.gz
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.
- glasswarp-0.1.0/.gitignore +13 -0
- glasswarp-0.1.0/CHANGELOG.md +10 -0
- glasswarp-0.1.0/LICENSE +17 -0
- glasswarp-0.1.0/PKG-INFO +177 -0
- glasswarp-0.1.0/README.md +141 -0
- glasswarp-0.1.0/glasswarp/__init__.py +48 -0
- glasswarp-0.1.0/glasswarp/client.py +454 -0
- glasswarp-0.1.0/glasswarp/grounding.py +111 -0
- glasswarp-0.1.0/glasswarp/py.typed +0 -0
- glasswarp-0.1.0/glasswarp/types.py +166 -0
- glasswarp-0.1.0/pyproject.toml +72 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-07-22
|
|
4
|
+
|
|
5
|
+
Initial public release on PyPI.
|
|
6
|
+
|
|
7
|
+
- `GlasswarpClient` over Platform API `/v1/*` (`https://signal.glasswarp.com`)
|
|
8
|
+
- Sessions, observe / screenshot, native input, app launch/kill
|
|
9
|
+
- Grounding helpers (`som_annotate`, `targets_from_grid`, `click_target`) via optional `glasswarp[grounding]`
|
|
10
|
+
- Typed package (`py.typed`)
|
glasswarp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Woodrow Stores LLC
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
glasswarp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glasswarp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Eyes and hands for AI agents on real Windows — Python SDK for the Glasswarp Platform API.
|
|
5
|
+
Project-URL: Homepage, https://www.glasswarp.com
|
|
6
|
+
Project-URL: Documentation, https://docs.glasswarp.com
|
|
7
|
+
Project-URL: Repository, https://github.com/saferelay/project-x
|
|
8
|
+
Project-URL: Issues, https://github.com/saferelay/project-x/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/saferelay/project-x/blob/main/sdk/python/CHANGELOG.md
|
|
10
|
+
Author-email: Woodrow Stores LLC <hello@glasswarp.com>
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,automation,computer-use,glasswarp,mcp,screenshot,windows
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: httpx>=0.24
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-httpx>=0.21; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
33
|
+
Provides-Extra: grounding
|
|
34
|
+
Requires-Dist: pillow>=10; extra == 'grounding'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Glasswarp Python SDK
|
|
38
|
+
|
|
39
|
+
See any screen. Control any PC. One API.
|
|
40
|
+
|
|
41
|
+
Glasswarp gives your agent **eyes and hands** on a real Windows machine; you
|
|
42
|
+
supply the **brain** (model, prompts, CV, solvers). Example task logic lives under
|
|
43
|
+
`examples/` and outcome-named starters under `templates/` — not in the platform.
|
|
44
|
+
Public plans: Free / Builder / Growth / Enterprise. Game Mode at `/play` is a frozen
|
|
45
|
+
demo of the same capture pipeline.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install glasswarp
|
|
49
|
+
# optional SoM / image helpers:
|
|
50
|
+
pip install "glasswarp[grounding]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Publish status:** packaging targets PyPI as `glasswarp` (`0.1.0`). Until the
|
|
54
|
+
> first upload lands, install from this repo: `pip install -e sdk/python`.
|
|
55
|
+
> Operator steps: [`docs/PYPI_PUBLISH.md`](../../docs/PYPI_PUBLISH.md).
|
|
56
|
+
|
|
57
|
+
Want Claude / Cursor with **no project code**? Use the remote MCP server instead —
|
|
58
|
+
[glasswarp.com/mcp](https://www.glasswarp.com/mcp) ·
|
|
59
|
+
[docs](https://docs.glasswarp.com/get-started/mcp).
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from glasswarp import GlasswarpClient
|
|
65
|
+
|
|
66
|
+
gw = GlasswarpClient(api_key="gw_live_sk_...")
|
|
67
|
+
|
|
68
|
+
# List your rigs
|
|
69
|
+
rigs = gw.list_rigs()
|
|
70
|
+
rig = rigs[0]
|
|
71
|
+
|
|
72
|
+
# Start a desktop session
|
|
73
|
+
session = gw.create_session(rig_id=rig.id)
|
|
74
|
+
|
|
75
|
+
# Take a screenshot (returns JPEG bytes)
|
|
76
|
+
screenshot = gw.screenshot(session.session_id)
|
|
77
|
+
with open("screen.jpg", "wb") as f:
|
|
78
|
+
f.write(screenshot.jpeg)
|
|
79
|
+
|
|
80
|
+
# Send input
|
|
81
|
+
gw.click(session.session_id, x=500, y=400)
|
|
82
|
+
gw.type_text(session.session_id, "Hello from Python!")
|
|
83
|
+
gw.key_press(session.session_id, "Enter")
|
|
84
|
+
|
|
85
|
+
# End the session
|
|
86
|
+
gw.end_session(session.session_id)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
| Parameter | Env var | Default |
|
|
92
|
+
|-----------|---------|---------|
|
|
93
|
+
| `api_key` | `GLASSWARP_API_KEY` | (required) |
|
|
94
|
+
| `base_url` | `GLASSWARP_BASE_URL` | `https://signal.glasswarp.com` |
|
|
95
|
+
|
|
96
|
+
## API reference
|
|
97
|
+
|
|
98
|
+
### Rigs
|
|
99
|
+
|
|
100
|
+
- `list_rigs()` — all rigs accessible to this key
|
|
101
|
+
- `get_rig(rig_id)` — single rig detail
|
|
102
|
+
|
|
103
|
+
### Sessions
|
|
104
|
+
|
|
105
|
+
- `create_session(rig_id, mode="desktop")` — start a session
|
|
106
|
+
- `get_session(session_id)` — current status
|
|
107
|
+
- `list_sessions()` — all active sessions
|
|
108
|
+
- `end_session(session_id)` — end and trigger safety_restore
|
|
109
|
+
|
|
110
|
+
### Visual
|
|
111
|
+
|
|
112
|
+
- `screenshot(session_id, max_width=None, quality=None, x=y=w=h=None)` — JPEG; optional ROI crop
|
|
113
|
+
- `dirty_rects(session_id)` — accumulated DXGI dirty rects since last poll (VP0)
|
|
114
|
+
- `list_targets(session_id)` — sparse click targets from host UIA (P8); empty if none
|
|
115
|
+
- `observe(session_id, mark=True, targets=None, …)` — one RTT: JPEG + dirty + targets (SoM when marked)
|
|
116
|
+
|
|
117
|
+
### Grounding (SoM)
|
|
118
|
+
|
|
119
|
+
Agents should click **targets**, not guessed pixels:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from glasswarp import targets_from_grid, som_annotate
|
|
123
|
+
|
|
124
|
+
# Host UIA targets (P8) or client CV/grid:
|
|
125
|
+
targets = targets_from_grid(x0=100, y0=200, x1=900, y1=800, rows=8, cols=8)
|
|
126
|
+
obs = gw.observe(session.session_id, max_width=1280, mark=True, targets=targets)
|
|
127
|
+
# obs.jpeg is Set-of-Mark annotated; model returns target id →
|
|
128
|
+
gw.click_target(session.session_id, "12", targets=targets)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `som_annotate(jpeg, targets, native_width=…, native_height=…)` — draw numbered marks (needs `pip install pillow`)
|
|
132
|
+
- `targets_from_grid(…)` — build cell targets for a rectangular board
|
|
133
|
+
- `click_target(session_id, target_or_id, targets=…)` — click bbox center
|
|
134
|
+
|
|
135
|
+
### Apps
|
|
136
|
+
|
|
137
|
+
- `launch_app(session_id, path, args=None)` — spawn a process on the rig (returns `pid`)
|
|
138
|
+
- `kill_app(session_id, pid=None)` — kill session-launched apps (`pid` optional)
|
|
139
|
+
|
|
140
|
+
### Input
|
|
141
|
+
|
|
142
|
+
- `send_input(session_id, events)` — batch input events
|
|
143
|
+
- `click(session_id, x, y, button)` — click at coordinates
|
|
144
|
+
- `type_text(session_id, text)` — type a string
|
|
145
|
+
- `key_press(session_id, key)` — press a key
|
|
146
|
+
- `move_mouse(session_id, x, y)` — move cursor
|
|
147
|
+
- `scroll(session_id, dx, dy)` — scroll
|
|
148
|
+
|
|
149
|
+
### Stream
|
|
150
|
+
|
|
151
|
+
- `stream(session_id)` — WebRTC signaling credentials for a live 60fps feed
|
|
152
|
+
|
|
153
|
+
## Templates
|
|
154
|
+
|
|
155
|
+
Outcome-named starters in `templates/` (observe → think → act → verify):
|
|
156
|
+
|
|
157
|
+
- `templates/observe_think_act/` — minimal agent-loop skeleton
|
|
158
|
+
- `templates/notepad_type/` — Notepad via UIA + type
|
|
159
|
+
- `templates/live_view_hitl/` — hold a session for Console Live View
|
|
160
|
+
|
|
161
|
+
## Examples
|
|
162
|
+
|
|
163
|
+
See `examples/` for complete runnable scripts:
|
|
164
|
+
|
|
165
|
+
- `quickstart.py` — connect, screenshot, click, type, disconnect
|
|
166
|
+
- `vp0_smoke.py` — dirty rects + ROI screenshot smoke
|
|
167
|
+
- `vp0_observe.py` — shared VP0 helper (dirty skip + ROI coalesce) for agent loops
|
|
168
|
+
- `paint_mark_demo.py` — **an agent draws a themed GLASSWARP outline in Paint** (custom colors + native drag). See [Paint quickstart](../../docs/QUICKSTART_PAINT.md).
|
|
169
|
+
- `paint_mona_lisa_demo.py` — **an agent reproduces the Mona Lisa in Paint** (observe→think→act→verify: vision placement, UIA colors, tile fills, Save As→Desktop). VNC-hard differentiator. See [Mona Lisa quickstart](../../docs/QUICKSTART_MONA_LISA.md).
|
|
170
|
+
- `notepad_uia_demo.py` — Notepad via UIA targets + type. See [Notepad quickstart](../../docs/QUICKSTART_NOTEPAD.md).
|
|
171
|
+
- `minesweeper_solver_demo.py` — Minesweeper demo (CV + solver). See [quickstart](../../docs/QUICKSTART_MINESWEEPER.md).
|
|
172
|
+
- `gemini_agent_loop.py` — Minesweeper demo with Gemini + SoM → `click_target` → verify
|
|
173
|
+
- `claude_agent_loop.py` — observe → Claude → act; VP0 on by default (`VP0=0` for full frames)
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
Apache-2.0
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Glasswarp Python SDK
|
|
2
|
+
|
|
3
|
+
See any screen. Control any PC. One API.
|
|
4
|
+
|
|
5
|
+
Glasswarp gives your agent **eyes and hands** on a real Windows machine; you
|
|
6
|
+
supply the **brain** (model, prompts, CV, solvers). Example task logic lives under
|
|
7
|
+
`examples/` and outcome-named starters under `templates/` — not in the platform.
|
|
8
|
+
Public plans: Free / Builder / Growth / Enterprise. Game Mode at `/play` is a frozen
|
|
9
|
+
demo of the same capture pipeline.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install glasswarp
|
|
13
|
+
# optional SoM / image helpers:
|
|
14
|
+
pip install "glasswarp[grounding]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> **Publish status:** packaging targets PyPI as `glasswarp` (`0.1.0`). Until the
|
|
18
|
+
> first upload lands, install from this repo: `pip install -e sdk/python`.
|
|
19
|
+
> Operator steps: [`docs/PYPI_PUBLISH.md`](../../docs/PYPI_PUBLISH.md).
|
|
20
|
+
|
|
21
|
+
Want Claude / Cursor with **no project code**? Use the remote MCP server instead —
|
|
22
|
+
[glasswarp.com/mcp](https://www.glasswarp.com/mcp) ·
|
|
23
|
+
[docs](https://docs.glasswarp.com/get-started/mcp).
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from glasswarp import GlasswarpClient
|
|
29
|
+
|
|
30
|
+
gw = GlasswarpClient(api_key="gw_live_sk_...")
|
|
31
|
+
|
|
32
|
+
# List your rigs
|
|
33
|
+
rigs = gw.list_rigs()
|
|
34
|
+
rig = rigs[0]
|
|
35
|
+
|
|
36
|
+
# Start a desktop session
|
|
37
|
+
session = gw.create_session(rig_id=rig.id)
|
|
38
|
+
|
|
39
|
+
# Take a screenshot (returns JPEG bytes)
|
|
40
|
+
screenshot = gw.screenshot(session.session_id)
|
|
41
|
+
with open("screen.jpg", "wb") as f:
|
|
42
|
+
f.write(screenshot.jpeg)
|
|
43
|
+
|
|
44
|
+
# Send input
|
|
45
|
+
gw.click(session.session_id, x=500, y=400)
|
|
46
|
+
gw.type_text(session.session_id, "Hello from Python!")
|
|
47
|
+
gw.key_press(session.session_id, "Enter")
|
|
48
|
+
|
|
49
|
+
# End the session
|
|
50
|
+
gw.end_session(session.session_id)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
| Parameter | Env var | Default |
|
|
56
|
+
|-----------|---------|---------|
|
|
57
|
+
| `api_key` | `GLASSWARP_API_KEY` | (required) |
|
|
58
|
+
| `base_url` | `GLASSWARP_BASE_URL` | `https://signal.glasswarp.com` |
|
|
59
|
+
|
|
60
|
+
## API reference
|
|
61
|
+
|
|
62
|
+
### Rigs
|
|
63
|
+
|
|
64
|
+
- `list_rigs()` — all rigs accessible to this key
|
|
65
|
+
- `get_rig(rig_id)` — single rig detail
|
|
66
|
+
|
|
67
|
+
### Sessions
|
|
68
|
+
|
|
69
|
+
- `create_session(rig_id, mode="desktop")` — start a session
|
|
70
|
+
- `get_session(session_id)` — current status
|
|
71
|
+
- `list_sessions()` — all active sessions
|
|
72
|
+
- `end_session(session_id)` — end and trigger safety_restore
|
|
73
|
+
|
|
74
|
+
### Visual
|
|
75
|
+
|
|
76
|
+
- `screenshot(session_id, max_width=None, quality=None, x=y=w=h=None)` — JPEG; optional ROI crop
|
|
77
|
+
- `dirty_rects(session_id)` — accumulated DXGI dirty rects since last poll (VP0)
|
|
78
|
+
- `list_targets(session_id)` — sparse click targets from host UIA (P8); empty if none
|
|
79
|
+
- `observe(session_id, mark=True, targets=None, …)` — one RTT: JPEG + dirty + targets (SoM when marked)
|
|
80
|
+
|
|
81
|
+
### Grounding (SoM)
|
|
82
|
+
|
|
83
|
+
Agents should click **targets**, not guessed pixels:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from glasswarp import targets_from_grid, som_annotate
|
|
87
|
+
|
|
88
|
+
# Host UIA targets (P8) or client CV/grid:
|
|
89
|
+
targets = targets_from_grid(x0=100, y0=200, x1=900, y1=800, rows=8, cols=8)
|
|
90
|
+
obs = gw.observe(session.session_id, max_width=1280, mark=True, targets=targets)
|
|
91
|
+
# obs.jpeg is Set-of-Mark annotated; model returns target id →
|
|
92
|
+
gw.click_target(session.session_id, "12", targets=targets)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `som_annotate(jpeg, targets, native_width=…, native_height=…)` — draw numbered marks (needs `pip install pillow`)
|
|
96
|
+
- `targets_from_grid(…)` — build cell targets for a rectangular board
|
|
97
|
+
- `click_target(session_id, target_or_id, targets=…)` — click bbox center
|
|
98
|
+
|
|
99
|
+
### Apps
|
|
100
|
+
|
|
101
|
+
- `launch_app(session_id, path, args=None)` — spawn a process on the rig (returns `pid`)
|
|
102
|
+
- `kill_app(session_id, pid=None)` — kill session-launched apps (`pid` optional)
|
|
103
|
+
|
|
104
|
+
### Input
|
|
105
|
+
|
|
106
|
+
- `send_input(session_id, events)` — batch input events
|
|
107
|
+
- `click(session_id, x, y, button)` — click at coordinates
|
|
108
|
+
- `type_text(session_id, text)` — type a string
|
|
109
|
+
- `key_press(session_id, key)` — press a key
|
|
110
|
+
- `move_mouse(session_id, x, y)` — move cursor
|
|
111
|
+
- `scroll(session_id, dx, dy)` — scroll
|
|
112
|
+
|
|
113
|
+
### Stream
|
|
114
|
+
|
|
115
|
+
- `stream(session_id)` — WebRTC signaling credentials for a live 60fps feed
|
|
116
|
+
|
|
117
|
+
## Templates
|
|
118
|
+
|
|
119
|
+
Outcome-named starters in `templates/` (observe → think → act → verify):
|
|
120
|
+
|
|
121
|
+
- `templates/observe_think_act/` — minimal agent-loop skeleton
|
|
122
|
+
- `templates/notepad_type/` — Notepad via UIA + type
|
|
123
|
+
- `templates/live_view_hitl/` — hold a session for Console Live View
|
|
124
|
+
|
|
125
|
+
## Examples
|
|
126
|
+
|
|
127
|
+
See `examples/` for complete runnable scripts:
|
|
128
|
+
|
|
129
|
+
- `quickstart.py` — connect, screenshot, click, type, disconnect
|
|
130
|
+
- `vp0_smoke.py` — dirty rects + ROI screenshot smoke
|
|
131
|
+
- `vp0_observe.py` — shared VP0 helper (dirty skip + ROI coalesce) for agent loops
|
|
132
|
+
- `paint_mark_demo.py` — **an agent draws a themed GLASSWARP outline in Paint** (custom colors + native drag). See [Paint quickstart](../../docs/QUICKSTART_PAINT.md).
|
|
133
|
+
- `paint_mona_lisa_demo.py` — **an agent reproduces the Mona Lisa in Paint** (observe→think→act→verify: vision placement, UIA colors, tile fills, Save As→Desktop). VNC-hard differentiator. See [Mona Lisa quickstart](../../docs/QUICKSTART_MONA_LISA.md).
|
|
134
|
+
- `notepad_uia_demo.py` — Notepad via UIA targets + type. See [Notepad quickstart](../../docs/QUICKSTART_NOTEPAD.md).
|
|
135
|
+
- `minesweeper_solver_demo.py` — Minesweeper demo (CV + solver). See [quickstart](../../docs/QUICKSTART_MINESWEEPER.md).
|
|
136
|
+
- `gemini_agent_loop.py` — Minesweeper demo with Gemini + SoM → `click_target` → verify
|
|
137
|
+
- `claude_agent_loop.py` — observe → Claude → act; VP0 on by default (`VP0=0` for full frames)
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Apache-2.0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Glasswarp Python SDK — see any screen, control any PC, one API."""
|
|
2
|
+
|
|
3
|
+
from glasswarp.client import GlasswarpClient, GlasswarpError
|
|
4
|
+
from glasswarp.grounding import som_annotate, targets_from_grid
|
|
5
|
+
from glasswarp.types import (
|
|
6
|
+
Rig,
|
|
7
|
+
Session,
|
|
8
|
+
ApiKey,
|
|
9
|
+
Screenshot,
|
|
10
|
+
Target,
|
|
11
|
+
ObserveResult,
|
|
12
|
+
InputEvent,
|
|
13
|
+
MouseMove,
|
|
14
|
+
MouseClick,
|
|
15
|
+
MouseDown,
|
|
16
|
+
MouseUp,
|
|
17
|
+
MouseScroll,
|
|
18
|
+
KeyPress,
|
|
19
|
+
KeyDown,
|
|
20
|
+
KeyUp,
|
|
21
|
+
TypeText,
|
|
22
|
+
StreamConnection,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
"GlasswarpClient",
|
|
28
|
+
"GlasswarpError",
|
|
29
|
+
"Rig",
|
|
30
|
+
"Session",
|
|
31
|
+
"ApiKey",
|
|
32
|
+
"Screenshot",
|
|
33
|
+
"Target",
|
|
34
|
+
"ObserveResult",
|
|
35
|
+
"som_annotate",
|
|
36
|
+
"targets_from_grid",
|
|
37
|
+
"InputEvent",
|
|
38
|
+
"MouseMove",
|
|
39
|
+
"MouseClick",
|
|
40
|
+
"MouseDown",
|
|
41
|
+
"MouseUp",
|
|
42
|
+
"MouseScroll",
|
|
43
|
+
"KeyPress",
|
|
44
|
+
"KeyDown",
|
|
45
|
+
"KeyUp",
|
|
46
|
+
"TypeText",
|
|
47
|
+
"StreamConnection",
|
|
48
|
+
]
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Glasswarp API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Optional, Sequence, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from glasswarp.types import (
|
|
13
|
+
ApiKey,
|
|
14
|
+
InputEvent,
|
|
15
|
+
ObserveResult,
|
|
16
|
+
Rig,
|
|
17
|
+
Screenshot,
|
|
18
|
+
Session,
|
|
19
|
+
StreamConnection,
|
|
20
|
+
Target,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://signal.glasswarp.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GlasswarpError(Exception):
|
|
27
|
+
"""Raised when the API returns a non-2xx status."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, status: int, message: str) -> None:
|
|
30
|
+
self.status = status
|
|
31
|
+
self.message = message
|
|
32
|
+
super().__init__(f"[{status}] {message}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GlasswarpClient:
|
|
36
|
+
"""Synchronous client for the Glasswarp Platform API.
|
|
37
|
+
|
|
38
|
+
Usage::
|
|
39
|
+
|
|
40
|
+
from glasswarp import GlasswarpClient
|
|
41
|
+
|
|
42
|
+
gw = GlasswarpClient(api_key="gw_live_sk_...")
|
|
43
|
+
rigs = gw.list_rigs()
|
|
44
|
+
session = gw.create_session(rig_id=rigs[0].id)
|
|
45
|
+
screenshot = gw.screenshot(session.session_id)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: Optional[str] = None,
|
|
51
|
+
base_url: Optional[str] = None,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.api_key = api_key or os.environ.get("GLASSWARP_API_KEY", "")
|
|
55
|
+
if not self.api_key:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"api_key is required — pass it or set GLASSWARP_API_KEY"
|
|
58
|
+
)
|
|
59
|
+
self.base_url = (base_url or os.environ.get("GLASSWARP_BASE_URL", DEFAULT_BASE_URL)).rstrip("/")
|
|
60
|
+
self._http = httpx.Client(
|
|
61
|
+
base_url=self.base_url,
|
|
62
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._http.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> "GlasswarpClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *_: object) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _json(self, response: httpx.Response) -> dict:
|
|
78
|
+
if response.status_code >= 400:
|
|
79
|
+
try:
|
|
80
|
+
body = response.json()
|
|
81
|
+
msg = body.get("error", response.text)
|
|
82
|
+
except Exception:
|
|
83
|
+
msg = response.text
|
|
84
|
+
raise GlasswarpError(response.status_code, msg)
|
|
85
|
+
return response.json()
|
|
86
|
+
|
|
87
|
+
# ── Rigs ─────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def list_rigs(self) -> list[Rig]:
|
|
90
|
+
"""List all rigs accessible to this API key."""
|
|
91
|
+
data = self._json(self._http.get("/v1/rigs"))
|
|
92
|
+
return [Rig(**r) for r in data.get("rigs", [])]
|
|
93
|
+
|
|
94
|
+
def get_rig(self, rig_id: str) -> Rig:
|
|
95
|
+
"""Get details for a single rig."""
|
|
96
|
+
data = self._json(self._http.get(f"/v1/rigs/{rig_id}"))
|
|
97
|
+
return Rig(**data)
|
|
98
|
+
|
|
99
|
+
# ── Sessions ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def create_session(
|
|
102
|
+
self,
|
|
103
|
+
rig_id: str,
|
|
104
|
+
mode: str = "desktop",
|
|
105
|
+
*,
|
|
106
|
+
idle_timeout_seconds: int | None = None,
|
|
107
|
+
) -> Session:
|
|
108
|
+
"""Start a new API session on a rig.
|
|
109
|
+
|
|
110
|
+
``idle_timeout_seconds`` (60–3600, default 900 / 15m) is how long the
|
|
111
|
+
session may sit with no screenshot/observe/input before the server ends
|
|
112
|
+
it automatically. Longer idle = more wall-clock billed minutes.
|
|
113
|
+
"""
|
|
114
|
+
body: dict = {"rig_id": rig_id, "mode": mode}
|
|
115
|
+
if idle_timeout_seconds is not None:
|
|
116
|
+
body["idle_timeout_seconds"] = idle_timeout_seconds
|
|
117
|
+
data = self._json(self._http.post("/v1/sessions", json=body))
|
|
118
|
+
return Session(**data)
|
|
119
|
+
|
|
120
|
+
def get_session(self, session_id: str) -> Session:
|
|
121
|
+
"""Get the current state of a session."""
|
|
122
|
+
data = self._json(self._http.get(f"/v1/sessions/{session_id}"))
|
|
123
|
+
return Session(**data)
|
|
124
|
+
|
|
125
|
+
def list_sessions(self) -> list[Session]:
|
|
126
|
+
"""List all active sessions for this API key's owner."""
|
|
127
|
+
data = self._json(self._http.get("/v1/sessions"))
|
|
128
|
+
return [Session(**s) for s in data.get("sessions", [])]
|
|
129
|
+
|
|
130
|
+
def end_session(self, session_id: str, *, keep_apps: bool = False) -> dict:
|
|
131
|
+
"""End a session (triggers safety_restore on the host).
|
|
132
|
+
|
|
133
|
+
By default, apps launched via :meth:`launch_app` are killed. Pass
|
|
134
|
+
``keep_apps=True`` to leave them running (useful for demos that should
|
|
135
|
+
leave Paint or Notepad open on the desktop).
|
|
136
|
+
"""
|
|
137
|
+
path = f"/v1/sessions/{session_id}"
|
|
138
|
+
if keep_apps:
|
|
139
|
+
path += "?keep_apps=1"
|
|
140
|
+
return self._json(self._http.request("DELETE", path))
|
|
141
|
+
|
|
142
|
+
# ── Screenshot ───────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def screenshot(
|
|
145
|
+
self,
|
|
146
|
+
session_id: str,
|
|
147
|
+
*,
|
|
148
|
+
max_width: int | None = None,
|
|
149
|
+
quality: int | None = None,
|
|
150
|
+
x: int | None = None,
|
|
151
|
+
y: int | None = None,
|
|
152
|
+
w: int | None = None,
|
|
153
|
+
h: int | None = None,
|
|
154
|
+
) -> Screenshot:
|
|
155
|
+
"""Capture a JPEG of the current screen (from the host last-frame buffer).
|
|
156
|
+
|
|
157
|
+
Capture stays at native desktop resolution (including 4K). Pass
|
|
158
|
+
``max_width`` / ``quality`` to downscale on the host for faster LLM
|
|
159
|
+
ingest. Pass ``x,y,w,h`` for a ROI crop in native capture coords (VP0).
|
|
160
|
+
Click coordinates should use ``native_width`` / ``native_height``.
|
|
161
|
+
"""
|
|
162
|
+
params: dict[str, int] = {}
|
|
163
|
+
if max_width is not None:
|
|
164
|
+
params["max_width"] = max_width
|
|
165
|
+
if quality is not None:
|
|
166
|
+
params["quality"] = quality
|
|
167
|
+
if x is not None and y is not None and w is not None and h is not None:
|
|
168
|
+
params["x"] = x
|
|
169
|
+
params["y"] = y
|
|
170
|
+
params["w"] = w
|
|
171
|
+
params["h"] = h
|
|
172
|
+
|
|
173
|
+
response = self._http.get(
|
|
174
|
+
f"/v1/sessions/{session_id}/screenshot",
|
|
175
|
+
params=params or None,
|
|
176
|
+
)
|
|
177
|
+
if response.status_code >= 400:
|
|
178
|
+
try:
|
|
179
|
+
msg = response.json().get("error", response.text)
|
|
180
|
+
except Exception:
|
|
181
|
+
msg = response.text
|
|
182
|
+
raise GlasswarpError(response.status_code, msg)
|
|
183
|
+
|
|
184
|
+
width = int(response.headers.get("X-Screenshot-Width", "0"))
|
|
185
|
+
height = int(response.headers.get("X-Screenshot-Height", "0"))
|
|
186
|
+
return Screenshot(
|
|
187
|
+
jpeg=response.content,
|
|
188
|
+
width=width,
|
|
189
|
+
height=height,
|
|
190
|
+
timestamp=int(response.headers.get("X-Screenshot-Timestamp", "0")),
|
|
191
|
+
native_width=int(
|
|
192
|
+
response.headers.get("X-Screenshot-Native-Width", str(width))
|
|
193
|
+
),
|
|
194
|
+
native_height=int(
|
|
195
|
+
response.headers.get("X-Screenshot-Native-Height", str(height))
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def dirty_rects(self, session_id: str) -> dict:
|
|
200
|
+
"""Return accumulated DXGI dirty rects since last poll (VP0).
|
|
201
|
+
|
|
202
|
+
Each rect is ``[x, y, w, h]`` in capture-buffer coordinates. Empty
|
|
203
|
+
``rects`` means no visual change since the previous take — safe to
|
|
204
|
+
skip a vision model call.
|
|
205
|
+
"""
|
|
206
|
+
return self._json(self._http.get(f"/v1/sessions/{session_id}/dirty"))
|
|
207
|
+
|
|
208
|
+
def list_targets(self, session_id: str) -> list[Target]:
|
|
209
|
+
"""Return sparse click targets (VP0.5). Empty until host UIA (P8)."""
|
|
210
|
+
data = self._json(self._http.get(f"/v1/sessions/{session_id}/targets"))
|
|
211
|
+
return [_parse_target(t) for t in (data.get("targets") or [])]
|
|
212
|
+
|
|
213
|
+
def observe(
|
|
214
|
+
self,
|
|
215
|
+
session_id: str,
|
|
216
|
+
*,
|
|
217
|
+
max_width: int | None = None,
|
|
218
|
+
quality: int | None = None,
|
|
219
|
+
x: int | None = None,
|
|
220
|
+
y: int | None = None,
|
|
221
|
+
w: int | None = None,
|
|
222
|
+
h: int | None = None,
|
|
223
|
+
mark: bool = True,
|
|
224
|
+
targets: Sequence[Target] | None = None,
|
|
225
|
+
) -> ObserveResult:
|
|
226
|
+
"""One-RTT observe: JPEG + dirty + targets (VP0.5 grounding).
|
|
227
|
+
|
|
228
|
+
If ``targets`` is passed (e.g. client CV/grid), those override the
|
|
229
|
+
host list and are used for optional SoM marking when ``mark=True``.
|
|
230
|
+
"""
|
|
231
|
+
params: dict[str, int] = {}
|
|
232
|
+
if max_width is not None:
|
|
233
|
+
params["max_width"] = max_width
|
|
234
|
+
if quality is not None:
|
|
235
|
+
params["quality"] = quality
|
|
236
|
+
if x is not None and y is not None and w is not None and h is not None:
|
|
237
|
+
params["x"] = x
|
|
238
|
+
params["y"] = y
|
|
239
|
+
params["w"] = w
|
|
240
|
+
params["h"] = h
|
|
241
|
+
if mark:
|
|
242
|
+
params["mark"] = 1
|
|
243
|
+
|
|
244
|
+
data = self._json(
|
|
245
|
+
self._http.get(
|
|
246
|
+
f"/v1/sessions/{session_id}/observe",
|
|
247
|
+
params=params or None,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
jpeg = base64.b64decode(data["jpeg_base64"])
|
|
251
|
+
host_targets = [_parse_target(t) for t in (data.get("targets") or [])]
|
|
252
|
+
resolved = list(targets) if targets is not None else host_targets
|
|
253
|
+
marked = bool(data.get("marked"))
|
|
254
|
+
if mark and resolved and (targets is not None or not marked):
|
|
255
|
+
from glasswarp.grounding import som_annotate
|
|
256
|
+
|
|
257
|
+
jpeg = som_annotate(
|
|
258
|
+
jpeg,
|
|
259
|
+
resolved,
|
|
260
|
+
native_width=int(data.get("native_width") or data.get("width") or 1),
|
|
261
|
+
native_height=int(
|
|
262
|
+
data.get("native_height") or data.get("height") or 1
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
marked = True
|
|
266
|
+
|
|
267
|
+
return ObserveResult(
|
|
268
|
+
jpeg=jpeg,
|
|
269
|
+
width=int(data.get("width") or 0),
|
|
270
|
+
height=int(data.get("height") or 0),
|
|
271
|
+
native_width=int(data.get("native_width") or data.get("width") or 0),
|
|
272
|
+
native_height=int(
|
|
273
|
+
data.get("native_height") or data.get("height") or 0
|
|
274
|
+
),
|
|
275
|
+
timestamp=int(data.get("timestamp") or 0),
|
|
276
|
+
dirty=data.get("dirty") or {},
|
|
277
|
+
targets=resolved,
|
|
278
|
+
marked=marked,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def click_target(
|
|
282
|
+
self,
|
|
283
|
+
session_id: str,
|
|
284
|
+
target: Union[Target, str],
|
|
285
|
+
*,
|
|
286
|
+
targets: Sequence[Target] | None = None,
|
|
287
|
+
button: str = "left",
|
|
288
|
+
) -> dict:
|
|
289
|
+
"""Click the center of a grounding target (by object or id)."""
|
|
290
|
+
if isinstance(target, Target):
|
|
291
|
+
t = target
|
|
292
|
+
else:
|
|
293
|
+
pool = list(targets or [])
|
|
294
|
+
if not pool:
|
|
295
|
+
pool = self.list_targets(session_id)
|
|
296
|
+
match = next((x for x in pool if x.id == target), None)
|
|
297
|
+
if match is None:
|
|
298
|
+
raise GlasswarpError(400, f"Unknown target id: {target}")
|
|
299
|
+
t = match
|
|
300
|
+
cx, cy = t.center
|
|
301
|
+
return self.click(session_id, x=cx, y=cy, button=button)
|
|
302
|
+
|
|
303
|
+
# ── Stream ───────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def stream(self, session_id: str) -> StreamConnection:
|
|
306
|
+
"""Get WebRTC signaling credentials for a live 60fps video stream.
|
|
307
|
+
|
|
308
|
+
Returns connection details including a ready-to-use WebSocket URL.
|
|
309
|
+
Connect to ``connect_url``, send an SDP OFFER, receive an ANSWER,
|
|
310
|
+
and exchange ICE candidates to start receiving H.264 video.
|
|
311
|
+
"""
|
|
312
|
+
data = self._json(self._http.get(f"/v1/sessions/{session_id}/stream"))
|
|
313
|
+
return StreamConnection(**data)
|
|
314
|
+
|
|
315
|
+
# ── Apps ─────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def launch_app(
|
|
318
|
+
self,
|
|
319
|
+
session_id: str,
|
|
320
|
+
path: str,
|
|
321
|
+
args: list[str] | None = None,
|
|
322
|
+
) -> dict:
|
|
323
|
+
"""Spawn an application on the remote rig (e.g. ``notepad.exe``).
|
|
324
|
+
|
|
325
|
+
Returns ``{"ok": True, "pid": <int>}``. Tracked PIDs are killed on
|
|
326
|
+
``end_session`` or via :meth:`kill_app`.
|
|
327
|
+
"""
|
|
328
|
+
body: dict = {"path": path}
|
|
329
|
+
if args is not None:
|
|
330
|
+
body["args"] = args
|
|
331
|
+
return self._json(
|
|
332
|
+
self._http.post(f"/v1/sessions/{session_id}/app/launch", json=body)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def kill_app(self, session_id: str, pid: int | None = None) -> dict:
|
|
336
|
+
"""Kill apps launched via :meth:`launch_app` in this session.
|
|
337
|
+
|
|
338
|
+
Pass ``pid`` to kill one process; omit to kill all session-launched apps.
|
|
339
|
+
"""
|
|
340
|
+
body: dict = {}
|
|
341
|
+
if pid is not None:
|
|
342
|
+
body["pid"] = pid
|
|
343
|
+
return self._json(
|
|
344
|
+
self._http.post(f"/v1/sessions/{session_id}/app/kill", json=body)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# ── Input ────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
def send_input(self, session_id: str, events: list[InputEvent]) -> dict:
|
|
350
|
+
"""Send input events (mouse, keyboard) to the session's rig."""
|
|
351
|
+
payload = [asdict(e) for e in events]
|
|
352
|
+
return self._json(
|
|
353
|
+
self._http.post(
|
|
354
|
+
f"/v1/sessions/{session_id}/input",
|
|
355
|
+
json={"events": payload},
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def click(
|
|
360
|
+
self,
|
|
361
|
+
session_id: str,
|
|
362
|
+
x: Optional[int] = None,
|
|
363
|
+
y: Optional[int] = None,
|
|
364
|
+
button: str = "left",
|
|
365
|
+
) -> dict:
|
|
366
|
+
"""Convenience: click at (x, y)."""
|
|
367
|
+
from glasswarp.types import MouseClick
|
|
368
|
+
return self.send_input(session_id, [MouseClick(button=button, x=x, y=y)]) # type: ignore[arg-type]
|
|
369
|
+
|
|
370
|
+
def type_text(self, session_id: str, text: str) -> dict:
|
|
371
|
+
"""Convenience: type a string."""
|
|
372
|
+
from glasswarp.types import TypeText
|
|
373
|
+
return self.send_input(session_id, [TypeText(text=text)])
|
|
374
|
+
|
|
375
|
+
def key_press(self, session_id: str, key: str) -> dict:
|
|
376
|
+
"""Convenience: press and release a key."""
|
|
377
|
+
from glasswarp.types import KeyPress
|
|
378
|
+
return self.send_input(session_id, [KeyPress(key=key)])
|
|
379
|
+
|
|
380
|
+
def move_mouse(self, session_id: str, x: int, y: int) -> dict:
|
|
381
|
+
"""Convenience: move the mouse cursor."""
|
|
382
|
+
from glasswarp.types import MouseMove
|
|
383
|
+
return self.send_input(session_id, [MouseMove(x=x, y=y)])
|
|
384
|
+
|
|
385
|
+
def mouse_down(
|
|
386
|
+
self,
|
|
387
|
+
session_id: str,
|
|
388
|
+
x: int,
|
|
389
|
+
y: int,
|
|
390
|
+
button: str = "left",
|
|
391
|
+
) -> dict:
|
|
392
|
+
"""Press a mouse button at (x, y) without releasing (start of a drag)."""
|
|
393
|
+
from glasswarp.types import MouseDown
|
|
394
|
+
return self.send_input(
|
|
395
|
+
session_id, [MouseDown(button=button, x=x, y=y)] # type: ignore[arg-type]
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def mouse_up(
|
|
399
|
+
self,
|
|
400
|
+
session_id: str,
|
|
401
|
+
x: int,
|
|
402
|
+
y: int,
|
|
403
|
+
button: str = "left",
|
|
404
|
+
) -> dict:
|
|
405
|
+
"""Release a mouse button at (x, y) (end of a drag)."""
|
|
406
|
+
from glasswarp.types import MouseUp
|
|
407
|
+
return self.send_input(
|
|
408
|
+
session_id, [MouseUp(button=button, x=x, y=y)] # type: ignore[arg-type]
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def drag(
|
|
412
|
+
self,
|
|
413
|
+
session_id: str,
|
|
414
|
+
x0: int,
|
|
415
|
+
y0: int,
|
|
416
|
+
x1: int,
|
|
417
|
+
y1: int,
|
|
418
|
+
*,
|
|
419
|
+
button: str = "left",
|
|
420
|
+
steps: int = 12,
|
|
421
|
+
) -> dict:
|
|
422
|
+
"""Drag from (x0,y0) to (x1,y1) with intermediate moves (native coords)."""
|
|
423
|
+
from glasswarp.types import MouseDown, MouseMove, MouseUp
|
|
424
|
+
|
|
425
|
+
steps = max(2, steps)
|
|
426
|
+
events: list = [MouseDown(button=button, x=x0, y=y0)]
|
|
427
|
+
for i in range(1, steps + 1):
|
|
428
|
+
t = i / steps
|
|
429
|
+
events.append(
|
|
430
|
+
MouseMove(
|
|
431
|
+
x=int(round(x0 + (x1 - x0) * t)),
|
|
432
|
+
y=int(round(y0 + (y1 - y0) * t)),
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
events.append(MouseUp(button=button, x=x1, y=y1))
|
|
436
|
+
return self.send_input(session_id, events) # type: ignore[arg-type]
|
|
437
|
+
|
|
438
|
+
def scroll(self, session_id: str, dx: int = 0, dy: int = 0) -> dict:
|
|
439
|
+
"""Convenience: scroll the mouse wheel."""
|
|
440
|
+
from glasswarp.types import MouseScroll
|
|
441
|
+
return self.send_input(session_id, [MouseScroll(dx=dx, dy=dy)])
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _parse_target(raw: dict) -> Target:
|
|
445
|
+
return Target(
|
|
446
|
+
id=str(raw["id"]),
|
|
447
|
+
x=int(raw["x"]),
|
|
448
|
+
y=int(raw["y"]),
|
|
449
|
+
w=int(raw["w"]),
|
|
450
|
+
h=int(raw["h"]),
|
|
451
|
+
role=raw.get("role"),
|
|
452
|
+
name=raw.get("name"),
|
|
453
|
+
source=raw.get("source"),
|
|
454
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Set-of-Mark (SoM) helpers for agent grounding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from glasswarp.types import Target
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def som_annotate(
|
|
12
|
+
jpeg: bytes,
|
|
13
|
+
targets: Sequence[Target],
|
|
14
|
+
*,
|
|
15
|
+
native_width: int,
|
|
16
|
+
native_height: int,
|
|
17
|
+
style: str = "badge",
|
|
18
|
+
) -> bytes:
|
|
19
|
+
"""Draw numbered marks on a JPEG for VLM grounding.
|
|
20
|
+
|
|
21
|
+
Target bboxes are in *native capture* coords; the JPEG may be downscaled.
|
|
22
|
+
``style``:
|
|
23
|
+
- ``badge`` (default): cyan box + opaque red id badge (good for sparse UIA)
|
|
24
|
+
- ``corner``: thin outline + tiny corner id — keeps cell contents readable
|
|
25
|
+
(needed for dense grids like Minesweeper)
|
|
26
|
+
|
|
27
|
+
Requires Pillow (``pip install pillow``).
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
31
|
+
except ImportError as e:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"som_annotate requires Pillow — pip install pillow"
|
|
34
|
+
) from e
|
|
35
|
+
|
|
36
|
+
img = Image.open(io.BytesIO(jpeg)).convert("RGB")
|
|
37
|
+
draw = ImageDraw.Draw(img)
|
|
38
|
+
jw, jh = img.size
|
|
39
|
+
sx = jw / max(1, native_width)
|
|
40
|
+
sy = jh / max(1, native_height)
|
|
41
|
+
|
|
42
|
+
corner = style == "corner"
|
|
43
|
+
font_size = 9 if corner else 12
|
|
44
|
+
try:
|
|
45
|
+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
|
|
46
|
+
except Exception:
|
|
47
|
+
font = ImageFont.load_default()
|
|
48
|
+
|
|
49
|
+
for t in targets:
|
|
50
|
+
x0 = t.x * sx
|
|
51
|
+
y0 = t.y * sy
|
|
52
|
+
x1 = (t.x + t.w) * sx
|
|
53
|
+
y1 = (t.y + t.h) * sy
|
|
54
|
+
outline = (0, 180, 255) if corner else (0, 200, 255)
|
|
55
|
+
draw.rectangle([x0, y0, x1, y1], outline=outline, width=1 if corner else 2)
|
|
56
|
+
label = t.id
|
|
57
|
+
bx, by = x0 + 1, y0 + 1
|
|
58
|
+
if corner:
|
|
59
|
+
tw = max(8, 5 * len(label))
|
|
60
|
+
th = 10
|
|
61
|
+
# Semi-transparent feel via dark red (small — leaves cell center free)
|
|
62
|
+
draw.rectangle([bx, by, bx + tw, by + th], fill=(200, 30, 30))
|
|
63
|
+
draw.text((bx + 1, by), label, fill=(255, 255, 255), font=font)
|
|
64
|
+
else:
|
|
65
|
+
tw = max(10, 7 * len(label))
|
|
66
|
+
th = 14
|
|
67
|
+
draw.rectangle([bx, by, bx + tw, by + th], fill=(255, 40, 40))
|
|
68
|
+
draw.text((bx + 2, by + 1), label, fill=(255, 255, 255), font=font)
|
|
69
|
+
|
|
70
|
+
out = io.BytesIO()
|
|
71
|
+
img.save(out, format="JPEG", quality=85)
|
|
72
|
+
return out.getvalue()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def targets_from_grid(
|
|
76
|
+
*,
|
|
77
|
+
x0: int,
|
|
78
|
+
y0: int,
|
|
79
|
+
x1: int,
|
|
80
|
+
y1: int,
|
|
81
|
+
rows: int,
|
|
82
|
+
cols: int,
|
|
83
|
+
id_prefix: str = "",
|
|
84
|
+
source: str = "grid",
|
|
85
|
+
) -> list[Target]:
|
|
86
|
+
"""Build one target per cell of a rectangular grid (native coords)."""
|
|
87
|
+
cell_w = (x1 - x0) / max(1, cols)
|
|
88
|
+
cell_h = (y1 - y0) / max(1, rows)
|
|
89
|
+
out: list[Target] = []
|
|
90
|
+
n = 1
|
|
91
|
+
for r in range(rows):
|
|
92
|
+
for c in range(cols):
|
|
93
|
+
tx = int(round(x0 + c * cell_w))
|
|
94
|
+
ty = int(round(y0 + r * cell_h))
|
|
95
|
+
tw = int(round(cell_w))
|
|
96
|
+
th = int(round(cell_h))
|
|
97
|
+
tid = f"{id_prefix}{n}" if id_prefix else str(n)
|
|
98
|
+
out.append(
|
|
99
|
+
Target(
|
|
100
|
+
id=tid,
|
|
101
|
+
x=tx,
|
|
102
|
+
y=ty,
|
|
103
|
+
w=tw,
|
|
104
|
+
h=th,
|
|
105
|
+
role="cell",
|
|
106
|
+
name=f"{r},{c}",
|
|
107
|
+
source=source,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
n += 1
|
|
111
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Data types for the Glasswarp SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Rig:
|
|
11
|
+
id: str
|
|
12
|
+
name: Optional[str]
|
|
13
|
+
online: bool
|
|
14
|
+
gpu: Optional[str]
|
|
15
|
+
os: Optional[str]
|
|
16
|
+
api_access_enabled: bool
|
|
17
|
+
has_active_session: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Session:
|
|
22
|
+
session_id: str
|
|
23
|
+
rig_id: str
|
|
24
|
+
mode: Literal["desktop", "app"]
|
|
25
|
+
status: Literal["active", "ended", "failed"]
|
|
26
|
+
started_at: Optional[str] = None
|
|
27
|
+
action_count: int = 0
|
|
28
|
+
idle_timeout_seconds: Optional[int] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ApiKey:
|
|
33
|
+
id: str
|
|
34
|
+
name: str
|
|
35
|
+
key_prefix: str
|
|
36
|
+
environment: str
|
|
37
|
+
scopes: list[str] = field(default_factory=list)
|
|
38
|
+
allowed_rig_ids: Optional[list[str]] = None
|
|
39
|
+
created_at: Optional[str] = None
|
|
40
|
+
last_used_at: Optional[str] = None
|
|
41
|
+
revoked_at: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Screenshot:
|
|
46
|
+
"""`width`/`height` are the JPEG size; `native_*` is the desktop capture size."""
|
|
47
|
+
|
|
48
|
+
jpeg: bytes
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
timestamp: int
|
|
52
|
+
native_width: int = 0
|
|
53
|
+
native_height: int = 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Target:
|
|
58
|
+
"""Sparse click target in *native capture* coordinates (VP0.5 grounding)."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
x: int
|
|
62
|
+
y: int
|
|
63
|
+
w: int
|
|
64
|
+
h: int
|
|
65
|
+
role: Optional[str] = None
|
|
66
|
+
name: Optional[str] = None
|
|
67
|
+
source: Optional[str] = None # uia | cv | grid | client
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def center(self) -> tuple[int, int]:
|
|
71
|
+
return self.x + self.w // 2, self.y + self.h // 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ObserveResult:
|
|
76
|
+
"""One-RTT observe: JPEG + dirty + targets (VP0.5)."""
|
|
77
|
+
|
|
78
|
+
jpeg: bytes
|
|
79
|
+
width: int
|
|
80
|
+
height: int
|
|
81
|
+
native_width: int
|
|
82
|
+
native_height: int
|
|
83
|
+
timestamp: int
|
|
84
|
+
dirty: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
targets: list[Target] = field(default_factory=list)
|
|
86
|
+
marked: bool = False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class StreamConnection:
|
|
91
|
+
"""WebRTC signaling credentials for a live video stream."""
|
|
92
|
+
signaling_url: str
|
|
93
|
+
viewer_token: str
|
|
94
|
+
rig_id: str
|
|
95
|
+
session_id: str
|
|
96
|
+
connect_url: str
|
|
97
|
+
instructions: dict[str, str] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Input events ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class MouseMove:
|
|
104
|
+
x: int
|
|
105
|
+
y: int
|
|
106
|
+
type: str = "mouse_move"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class MouseClick:
|
|
111
|
+
button: Literal["left", "right", "middle"] = "left"
|
|
112
|
+
x: Optional[int] = None
|
|
113
|
+
y: Optional[int] = None
|
|
114
|
+
type: str = "mouse_click"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class MouseDown:
|
|
119
|
+
button: Literal["left", "right", "middle"] = "left"
|
|
120
|
+
x: Optional[int] = None
|
|
121
|
+
y: Optional[int] = None
|
|
122
|
+
type: str = "mouse_down"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class MouseUp:
|
|
127
|
+
button: Literal["left", "right", "middle"] = "left"
|
|
128
|
+
x: Optional[int] = None
|
|
129
|
+
y: Optional[int] = None
|
|
130
|
+
type: str = "mouse_up"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class MouseScroll:
|
|
135
|
+
dx: int = 0
|
|
136
|
+
dy: int = 0
|
|
137
|
+
type: str = "mouse_scroll"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class KeyPress:
|
|
142
|
+
key: str = ""
|
|
143
|
+
type: str = "key_press"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class KeyDown:
|
|
148
|
+
key: str = ""
|
|
149
|
+
type: str = "key_down"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class KeyUp:
|
|
154
|
+
key: str = ""
|
|
155
|
+
type: str = "key_up"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class TypeText:
|
|
160
|
+
text: str = ""
|
|
161
|
+
type: str = "type_text"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
InputEvent = Union[
|
|
165
|
+
MouseMove, MouseClick, MouseDown, MouseUp, MouseScroll, KeyPress, KeyDown, KeyUp, TypeText
|
|
166
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "glasswarp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Eyes and hands for AI agents on real Windows — Python SDK for the Glasswarp Platform API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Woodrow Stores LLC", email = "hello@glasswarp.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"glasswarp",
|
|
17
|
+
"computer-use",
|
|
18
|
+
"windows",
|
|
19
|
+
"agents",
|
|
20
|
+
"screenshot",
|
|
21
|
+
"automation",
|
|
22
|
+
"mcp",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: Apache Software License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
"Typing :: Typed",
|
|
37
|
+
]
|
|
38
|
+
dependencies = [
|
|
39
|
+
"httpx>=0.24",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
grounding = [
|
|
44
|
+
"pillow>=10",
|
|
45
|
+
]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=7",
|
|
48
|
+
"pytest-httpx>=0.21",
|
|
49
|
+
"build>=1.2",
|
|
50
|
+
"twine>=5",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://www.glasswarp.com"
|
|
55
|
+
Documentation = "https://docs.glasswarp.com"
|
|
56
|
+
Repository = "https://github.com/saferelay/project-x"
|
|
57
|
+
Issues = "https://github.com/saferelay/project-x/issues"
|
|
58
|
+
Changelog = "https://github.com/saferelay/project-x/blob/main/sdk/python/CHANGELOG.md"
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build]
|
|
61
|
+
# Keep the published sdist lean — package + docs only (no demo JPEGs / examples).
|
|
62
|
+
[tool.hatch.build.targets.sdist]
|
|
63
|
+
include = [
|
|
64
|
+
"/glasswarp",
|
|
65
|
+
"/README.md",
|
|
66
|
+
"/LICENSE",
|
|
67
|
+
"/pyproject.toml",
|
|
68
|
+
"/CHANGELOG.md",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.wheel]
|
|
72
|
+
packages = ["glasswarp"]
|