macos-computer-use-kit 0.2.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.
- macos_computer_use/__init__.py +9 -0
- macos_computer_use/__main__.py +4 -0
- macos_computer_use/ax.py +363 -0
- macos_computer_use/cli.py +204 -0
- macos_computer_use/darwin.py +261 -0
- macos_computer_use/input_events.py +172 -0
- macos_computer_use/jev.py +223 -0
- macos_computer_use/overlay.py +148 -0
- macos_computer_use/paste.py +111 -0
- macos_computer_use/shot.py +87 -0
- macos_computer_use_kit-0.2.0.dist-info/METADATA +308 -0
- macos_computer_use_kit-0.2.0.dist-info/RECORD +15 -0
- macos_computer_use_kit-0.2.0.dist-info/WHEEL +4 -0
- macos_computer_use_kit-0.2.0.dist-info/entry_points.txt +2 -0
- macos_computer_use_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""macOS platform layer: guards, permissions, app/window resolution, displays.
|
|
2
|
+
|
|
3
|
+
Everything macOS-specific that more than one command needs lives here, so the
|
|
4
|
+
other modules stay portable and testable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require_macos() -> None:
|
|
18
|
+
"""Exit with an actionable message when not running on macOS."""
|
|
19
|
+
if sys.platform != "darwin":
|
|
20
|
+
print(
|
|
21
|
+
json.dumps(
|
|
22
|
+
{
|
|
23
|
+
"error": "unsupported_platform",
|
|
24
|
+
"platform": sys.platform,
|
|
25
|
+
"detail": "this toolkit drives macOS Accessibility and CoreGraphics APIs",
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
raise SystemExit(2)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pyobjc():
|
|
34
|
+
try:
|
|
35
|
+
import ApplicationServices as AS # noqa: N813
|
|
36
|
+
from AppKit import NSWorkspace
|
|
37
|
+
import Quartz
|
|
38
|
+
except ImportError as exc: # pragma: no cover - depends on host setup
|
|
39
|
+
print(
|
|
40
|
+
json.dumps(
|
|
41
|
+
{
|
|
42
|
+
"error": "missing_dependency",
|
|
43
|
+
"detail": f"{exc}. Install with: pip install 'macos-computer-use-kit' "
|
|
44
|
+
"(or: pip install pyobjc-framework-Quartz pyobjc-framework-Cocoa "
|
|
45
|
+
"pyobjc-framework-ApplicationServices)",
|
|
46
|
+
}
|
|
47
|
+
),
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
50
|
+
raise SystemExit(2) from exc
|
|
51
|
+
return AS, NSWorkspace, Quartz
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def permissions() -> dict[str, bool]:
|
|
55
|
+
"""Report the two TCC permissions this toolkit needs."""
|
|
56
|
+
AS, _NSWorkspace, Quartz = _pyobjc() # noqa: N806
|
|
57
|
+
accessibility = bool(AS.AXIsProcessTrusted())
|
|
58
|
+
try:
|
|
59
|
+
screen_recording = bool(Quartz.CGPreflightScreenCaptureAccess())
|
|
60
|
+
except AttributeError: # pragma: no cover - older SDKs
|
|
61
|
+
screen_recording = bool(Quartz.CGWindowListCopyWindowInfo(
|
|
62
|
+
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID
|
|
63
|
+
))
|
|
64
|
+
return {"accessibility": accessibility, "screen_recording": screen_recording}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def permission_hint(kind: str) -> str:
|
|
68
|
+
app = _frontmost_owner_name()
|
|
69
|
+
return (
|
|
70
|
+
f"{kind} permission is not granted. Open System Settings -> Privacy & Security -> "
|
|
71
|
+
f"{'Accessibility' if kind == 'accessibility' else 'Screen Recording'} and enable "
|
|
72
|
+
f"the process running this command{app}. Then rerun `macos-cu doctor`."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _frontmost_owner_name() -> str:
|
|
77
|
+
"""Best-effort hint at which app should be granted permission."""
|
|
78
|
+
for candidate in (
|
|
79
|
+
os.environ.get("TERM_PROGRAM"),
|
|
80
|
+
os.environ.get("__CFBundleIdentifier"),
|
|
81
|
+
None,
|
|
82
|
+
):
|
|
83
|
+
if candidate:
|
|
84
|
+
return f" ({candidate})"
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def displays() -> list[dict[str, Any]]:
|
|
89
|
+
"""Screen geometry in points, as AX reports it.
|
|
90
|
+
|
|
91
|
+
Note: AX/CoreGraphics use a top-left origin space whose (0,0) is the primary
|
|
92
|
+
display. Secondary displays placed left of or above the primary produce
|
|
93
|
+
negative coordinates, which is normal.
|
|
94
|
+
"""
|
|
95
|
+
from AppKit import NSScreen
|
|
96
|
+
|
|
97
|
+
out = []
|
|
98
|
+
for i, screen in enumerate(NSScreen.screens()):
|
|
99
|
+
frame = screen.frame()
|
|
100
|
+
out.append(
|
|
101
|
+
{
|
|
102
|
+
"index": i,
|
|
103
|
+
"primary": i == 0,
|
|
104
|
+
"name": str(screen.localizedName()),
|
|
105
|
+
"points": [int(frame.size.width), int(frame.size.height)],
|
|
106
|
+
"backing_scale": int(screen.backingScaleFactor()),
|
|
107
|
+
"origin": [int(frame.origin.x), int(frame.origin.y)],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def primary_screen_point_size() -> tuple[int, int]:
|
|
114
|
+
"""Point size of the primary display (the AX coordinate space origin)."""
|
|
115
|
+
from AppKit import NSScreen
|
|
116
|
+
|
|
117
|
+
frame = NSScreen.screens()[0].frame()
|
|
118
|
+
return int(frame.size.width), int(frame.size.height)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_app(name: str | None):
|
|
122
|
+
"""Resolve an NSRunningApplication by localized name or bundle id.
|
|
123
|
+
|
|
124
|
+
Match order: exact bundle id, exact localized name, case-insensitive exact,
|
|
125
|
+
then substring. First match in each tier wins, preferring frontmost apps.
|
|
126
|
+
"""
|
|
127
|
+
_AS, NSWorkspace, _Quartz = _pyobjc() # noqa: N806
|
|
128
|
+
workspace = NSWorkspace.sharedWorkspace()
|
|
129
|
+
if not name:
|
|
130
|
+
return workspace.frontmostApplication()
|
|
131
|
+
|
|
132
|
+
apps = list(workspace.runningApplications())
|
|
133
|
+
frontmost = workspace.frontmostApplication()
|
|
134
|
+
if frontmost is not None:
|
|
135
|
+
apps.sort(key=lambda a: 0 if a.processIdentifier() == frontmost.processIdentifier() else 1)
|
|
136
|
+
|
|
137
|
+
needle = name.lower()
|
|
138
|
+
|
|
139
|
+
def field(app, getter):
|
|
140
|
+
try:
|
|
141
|
+
return (getter(app) or "")
|
|
142
|
+
except Exception: # pragma: no cover - defensive
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
for tier in (
|
|
146
|
+
lambda a: field(a, lambda x: x.bundleIdentifier()) == name,
|
|
147
|
+
lambda a: field(a, lambda x: x.localizedName()) == name,
|
|
148
|
+
lambda a: field(a, lambda x: x.bundleIdentifier()).lower() == needle,
|
|
149
|
+
lambda a: field(a, lambda x: x.localizedName()).lower() == needle,
|
|
150
|
+
lambda a: needle in field(a, lambda x: x.bundleIdentifier()).lower(),
|
|
151
|
+
lambda a: needle in field(a, lambda x: x.localizedName()).lower(),
|
|
152
|
+
):
|
|
153
|
+
for app in apps:
|
|
154
|
+
if tier(app):
|
|
155
|
+
return app
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_pid(app_name: str | None = None, pid: int | None = None) -> int | None:
|
|
160
|
+
if pid:
|
|
161
|
+
return int(pid)
|
|
162
|
+
app = find_app(app_name)
|
|
163
|
+
return int(app.processIdentifier()) if app is not None else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def all_windows(owner_filter: str | None = None) -> list[dict[str, Any]]:
|
|
167
|
+
"""On-screen windows, main windows first."""
|
|
168
|
+
_AS, _NSWorkspace, Quartz = _pyobjc() # noqa: N806
|
|
169
|
+
out = []
|
|
170
|
+
for w in Quartz.CGWindowListCopyWindowInfo(
|
|
171
|
+
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID
|
|
172
|
+
):
|
|
173
|
+
owner = str(w.get("kCGWindowOwnerName") or "")
|
|
174
|
+
if owner_filter and owner_filter.lower() not in owner.lower():
|
|
175
|
+
continue
|
|
176
|
+
b = w.get("kCGWindowBounds") or {}
|
|
177
|
+
width, height = int(b.get("Width", 0)), int(b.get("Height", 0))
|
|
178
|
+
if width < 50 or height < 50:
|
|
179
|
+
continue
|
|
180
|
+
out.append(
|
|
181
|
+
{
|
|
182
|
+
"id": int(w.get("kCGWindowNumber", 0)),
|
|
183
|
+
"pid": int(w.get("kCGWindowOwnerPID", 0)),
|
|
184
|
+
"owner": owner,
|
|
185
|
+
"title": str(w.get("kCGWindowName") or ""),
|
|
186
|
+
"bounds": [int(b.get("X", 0)), int(b.get("Y", 0)), width, height],
|
|
187
|
+
"layer": int(w.get("kCGWindowLayer", 0)),
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
out.sort(key=lambda x: (x["layer"] != 0, -(x["bounds"][2] * x["bounds"][3])))
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def window_info(window_id: int) -> dict[str, Any] | None:
|
|
195
|
+
for w in all_windows():
|
|
196
|
+
if w["id"] == int(window_id):
|
|
197
|
+
return w
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def target_sig(win: dict[str, Any]) -> str:
|
|
202
|
+
"""Stable window identity signature: pid:wid:x:y:w:h."""
|
|
203
|
+
x, y, w, h = win["bounds"]
|
|
204
|
+
return f'{win["pid"]}:{win["id"]}:{x}:{y}:{w}:{h}'
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def overlay_argv(x: float, y: float, label: str = "", duration: float = 1.2, color: str = "cyan") -> list[str]:
|
|
208
|
+
"""Command to draw an overlay ring, for the current install method."""
|
|
209
|
+
return [
|
|
210
|
+
sys.executable,
|
|
211
|
+
"-m",
|
|
212
|
+
"macos_computer_use.cli",
|
|
213
|
+
"overlay",
|
|
214
|
+
"show",
|
|
215
|
+
"--x",
|
|
216
|
+
str(int(x)),
|
|
217
|
+
"--y",
|
|
218
|
+
str(int(y)),
|
|
219
|
+
"--label",
|
|
220
|
+
label or "",
|
|
221
|
+
"--duration",
|
|
222
|
+
str(duration),
|
|
223
|
+
"--color",
|
|
224
|
+
color,
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def show_overlay(x: float, y: float, label: str = "", color: str = "cyan", duration: float = 1.2) -> bool:
|
|
229
|
+
"""Fire-and-forget visual ring at an AX/global screen point."""
|
|
230
|
+
try:
|
|
231
|
+
subprocess.Popen(
|
|
232
|
+
overlay_argv(x, y, label, duration, color),
|
|
233
|
+
stdout=subprocess.DEVNULL,
|
|
234
|
+
stderr=subprocess.DEVNULL,
|
|
235
|
+
start_new_session=True,
|
|
236
|
+
)
|
|
237
|
+
return True
|
|
238
|
+
except Exception: # pragma: no cover - best-effort affordance
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def macos_version() -> str:
|
|
243
|
+
try:
|
|
244
|
+
return platform.mac_ver()[0] or "unknown"
|
|
245
|
+
except Exception: # pragma: no cover - defensive
|
|
246
|
+
return "unknown"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def jev_key() -> str | None:
|
|
250
|
+
"""TypeSafe/Jev API key from the environment or the conventional files."""
|
|
251
|
+
key = os.environ.get("TYPESAFE_API_KEY")
|
|
252
|
+
if key:
|
|
253
|
+
return key.strip()
|
|
254
|
+
for path in ("~/.config/typesafe/api_key", "~/.typesafe/api_key"):
|
|
255
|
+
p = os.path.expanduser(path)
|
|
256
|
+
if os.path.exists(p):
|
|
257
|
+
with open(p) as fh:
|
|
258
|
+
value = fh.read().strip()
|
|
259
|
+
if value:
|
|
260
|
+
return value
|
|
261
|
+
return None
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Process- and window-scoped input for macOS.
|
|
2
|
+
|
|
3
|
+
Delivers mouse/keyboard events straight to a target process's event queue with
|
|
4
|
+
``CGEventPostToPid``, so the system cursor never moves and the user's physical
|
|
5
|
+
mouse is untouched. Window-scoped actions take window-relative coordinates and
|
|
6
|
+
can validate a target signature before acting, which turns "the window moved
|
|
7
|
+
while I was aiming" into an explicit ``target_changed`` refusal instead of a
|
|
8
|
+
misclick.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import darwin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _q():
|
|
22
|
+
_AS, _NSWorkspace, Quartz = darwin._pyobjc() # noqa: N806
|
|
23
|
+
return Quartz
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BUTTONS = {
|
|
27
|
+
"left": ("kCGEventLeftMouseDown", "kCGEventLeftMouseUp", "kCGMouseButtonLeft"),
|
|
28
|
+
"right": ("kCGEventRightMouseDown", "kCGEventRightMouseUp", "kCGMouseButtonRight"),
|
|
29
|
+
"middle": ("kCGEventOtherMouseDown", "kCGEventOtherMouseUp", "kCGMouseButtonCenter"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
KEYS = {
|
|
33
|
+
"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51, "escape": 53, "esc": 53,
|
|
34
|
+
"left": 123, "right": 124, "down": 125, "up": 126, "home": 115, "end": 119,
|
|
35
|
+
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
|
|
36
|
+
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
|
|
37
|
+
"3": 20, "4": 21, "6": 22, "5": 23, "9": 25, "7": 26, "8": 28, "0": 29,
|
|
38
|
+
"o": 31, "u": 32, "i": 34, "p": 35, "l": 37, "j": 38, "k": 40, "n": 45, "m": 46,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
FLAGS = {
|
|
42
|
+
"cmd": "kCGEventFlagMaskCommand",
|
|
43
|
+
"command": "kCGEventFlagMaskCommand",
|
|
44
|
+
"shift": "kCGEventFlagMaskShift",
|
|
45
|
+
"ctrl": "kCGEventFlagMaskControl",
|
|
46
|
+
"control": "kCGEventFlagMaskControl",
|
|
47
|
+
"alt": "kCGEventFlagMaskAlternate",
|
|
48
|
+
"option": "kCGEventFlagMaskAlternate",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def do_click(pid, x, y, button, count):
|
|
53
|
+
Quartz = _q() # noqa: N806
|
|
54
|
+
down, up, btn = (getattr(Quartz, name) for name in BUTTONS[button])
|
|
55
|
+
for i in range(count):
|
|
56
|
+
for kind in (down, up):
|
|
57
|
+
ev = Quartz.CGEventCreateMouseEvent(None, kind, (x, y), btn)
|
|
58
|
+
Quartz.CGEventSetIntegerValueField(ev, Quartz.kCGMouseEventClickState, i + 1)
|
|
59
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
60
|
+
time.sleep(0.06)
|
|
61
|
+
return f"posted {count}x {button} click at ({x},{y}) to pid {pid}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def do_scroll(pid, x, y, amount):
|
|
65
|
+
Quartz = _q() # noqa: N806
|
|
66
|
+
ev = Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, amount)
|
|
67
|
+
Quartz.CGEventSetLocation(ev, (x, y))
|
|
68
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
69
|
+
return f"posted scroll {amount} at ({x},{y}) to pid {pid}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def do_move(pid, x, y):
|
|
73
|
+
Quartz = _q() # noqa: N806
|
|
74
|
+
ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved, (x, y), Quartz.kCGMouseButtonLeft)
|
|
75
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
76
|
+
return f"posted move to ({x},{y}) to pid {pid}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def do_key(pid, key, flags):
|
|
80
|
+
Quartz = _q() # noqa: N806
|
|
81
|
+
code = KEYS.get(key, None)
|
|
82
|
+
if code is None:
|
|
83
|
+
code = int(key)
|
|
84
|
+
flagmask = 0
|
|
85
|
+
for f in (flags.split("+") if flags else []):
|
|
86
|
+
flagmask |= getattr(Quartz, FLAGS[f])
|
|
87
|
+
for is_down in (True, False):
|
|
88
|
+
ev = Quartz.CGEventCreateKeyboardEvent(None, code, is_down)
|
|
89
|
+
if flagmask:
|
|
90
|
+
Quartz.CGEventSetFlags(ev, flagmask)
|
|
91
|
+
Quartz.CGEventPostToPid(pid, ev)
|
|
92
|
+
time.sleep(0.02)
|
|
93
|
+
return f"posted key {key} (code {code}) flags={flagmask} to pid {pid}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cursor_position() -> tuple[int, int]:
|
|
97
|
+
Quartz = _q() # noqa: N806
|
|
98
|
+
loc = Quartz.CGEventGetLocation(Quartz.CGEventCreate(None))
|
|
99
|
+
return int(loc.x), int(loc.y)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run(args) -> int:
|
|
103
|
+
if args.cmd == "cursor":
|
|
104
|
+
x, y = cursor_position()
|
|
105
|
+
print(json.dumps({"cursor": [x, y]}))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
if not darwin.permissions()["accessibility"]:
|
|
109
|
+
print(json.dumps({"error": "accessibility_not_granted", "hint": darwin.permission_hint("accessibility")}), file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
if args.cmd == "windows":
|
|
113
|
+
windows = darwin.all_windows(args.app)
|
|
114
|
+
if args.pid:
|
|
115
|
+
windows = [w for w in windows if w["pid"] == int(args.pid)]
|
|
116
|
+
print(json.dumps({"windows": windows}, ensure_ascii=False))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
win = None
|
|
120
|
+
if args.window_id:
|
|
121
|
+
win = darwin.window_info(args.window_id)
|
|
122
|
+
if win is None:
|
|
123
|
+
print(json.dumps({"ok": False, "reason": "target_changed", "detail": "window not found"}, ensure_ascii=False))
|
|
124
|
+
return 5
|
|
125
|
+
if args.expect and args.expect != darwin.target_sig(win):
|
|
126
|
+
print(json.dumps(
|
|
127
|
+
{"ok": False, "reason": "target_changed", "expected": args.expect, "actual": darwin.target_sig(win)},
|
|
128
|
+
ensure_ascii=False,
|
|
129
|
+
))
|
|
130
|
+
return 5
|
|
131
|
+
pid = win["pid"]
|
|
132
|
+
if args.x is not None and args.y is not None:
|
|
133
|
+
args.x = win["bounds"][0] + args.x
|
|
134
|
+
args.y = win["bounds"][1] + args.y
|
|
135
|
+
else:
|
|
136
|
+
pid = darwin.resolve_pid(args.app, args.pid)
|
|
137
|
+
if pid is None:
|
|
138
|
+
print(json.dumps({"ok": False, "reason": "app_not_found", "app": args.app}, ensure_ascii=False))
|
|
139
|
+
return 2
|
|
140
|
+
|
|
141
|
+
if args.cmd == "pid":
|
|
142
|
+
print(json.dumps({"pid": pid}))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
if args.cmd in ("click", "move", "scroll") and (args.x is None or args.y is None):
|
|
146
|
+
print(json.dumps({"error": "--x/--y required for this command"}), file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
if args.cmd == "key" and not args.key:
|
|
149
|
+
print(json.dumps({"error": "--key required"}), file=sys.stderr)
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
sig = darwin.target_sig(win) if win else None
|
|
153
|
+
if args.show and args.x is not None:
|
|
154
|
+
darwin.show_overlay(args.x, args.y, args.key or args.cmd)
|
|
155
|
+
|
|
156
|
+
result: dict[str, Any] = {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"pid": pid,
|
|
159
|
+
"target": sig,
|
|
160
|
+
"global": [args.x, args.y] if args.x is not None else None,
|
|
161
|
+
"action_sent": True,
|
|
162
|
+
}
|
|
163
|
+
if args.cmd == "click":
|
|
164
|
+
result["detail"] = do_click(pid, args.x, args.y, args.button, args.count)
|
|
165
|
+
elif args.cmd == "move":
|
|
166
|
+
result["detail"] = do_move(pid, args.x, args.y)
|
|
167
|
+
elif args.cmd == "scroll":
|
|
168
|
+
result["detail"] = do_scroll(pid, args.x, args.y, args.amount)
|
|
169
|
+
elif args.cmd == "key":
|
|
170
|
+
result["detail"] = do_key(pid, args.key, args.flags)
|
|
171
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
172
|
+
return 0
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Jev (TypeSafe System One) integration: semantic guards for computer use.
|
|
2
|
+
|
|
3
|
+
Two entry points, both reading JSON on stdin and printing JSON on stdout:
|
|
4
|
+
|
|
5
|
+
- ``guard`` : one request fans out several independent yes/no and choice
|
|
6
|
+
judgments (right target / input correctness / blocker / next action) and the
|
|
7
|
+
*code* applies thresholds. This is the highest-ROI place for a small model in
|
|
8
|
+
a computer-use loop: immediately before an irreversible action.
|
|
9
|
+
- ``select`` : pick one element out of AX candidates, with a ``none`` escape
|
|
10
|
+
hatch and a confidence gate.
|
|
11
|
+
|
|
12
|
+
Jev returns typed judgments and calibrated probabilities, not text. Policy and
|
|
13
|
+
side effects stay in code. API key: ``TYPESAFE_API_KEY`` or
|
|
14
|
+
``~/.config/typesafe/api_key``. See https://docs.typesafe.ai
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import darwin
|
|
28
|
+
|
|
29
|
+
API = "https://api.typesafe.ai/v1/systemone"
|
|
30
|
+
DEFAULT_MODEL = "jev-latest"
|
|
31
|
+
# Act only when the semantic checks clear the bar (tune on your own data).
|
|
32
|
+
T_TARGET = float(os.environ.get("MACOS_CU_JEV_T_TARGET", "0.85"))
|
|
33
|
+
T_INPUT = float(os.environ.get("MACOS_CU_JEV_T_INPUT", "0.85"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def model() -> str:
|
|
37
|
+
return os.environ.get("TYPESAFE_MODEL", DEFAULT_MODEL)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _post(body: dict[str, Any], key: str, timeout: float = 30.0) -> tuple[dict[str, Any] | None, int]:
|
|
41
|
+
req = urllib.request.Request(
|
|
42
|
+
API,
|
|
43
|
+
data=json.dumps(body).encode(),
|
|
44
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
45
|
+
method="POST",
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
49
|
+
return json.load(resp), 0
|
|
50
|
+
except urllib.error.HTTPError as e:
|
|
51
|
+
print(json.dumps({"error": f"HTTP {e.code}", "body": e.read().decode()[:300]}), file=sys.stderr)
|
|
52
|
+
return None, 3
|
|
53
|
+
except urllib.error.URLError as e:
|
|
54
|
+
print(json.dumps({"error": "network_error", "detail": str(e.reason)[:200]}), file=sys.stderr)
|
|
55
|
+
return None, 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _api_key_or_exit() -> str:
|
|
59
|
+
key = darwin.jev_key()
|
|
60
|
+
if not key:
|
|
61
|
+
print(
|
|
62
|
+
json.dumps(
|
|
63
|
+
{
|
|
64
|
+
"error": "TYPESAFE_API_KEY not set",
|
|
65
|
+
"hint": "export TYPESAFE_API_KEY=... or write it to ~/.config/typesafe/api_key "
|
|
66
|
+
"(keys: https://console.typesafe.ai/keys)",
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
raise SystemExit(2)
|
|
72
|
+
return key
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decide(right: float, inp: float, blocker: str, nxt: str) -> str:
|
|
76
|
+
"""Apply the guard policy. Pure function so it can be unit-tested.
|
|
77
|
+
|
|
78
|
+
Code owns the decision: proceed only when nothing blocks and both semantic
|
|
79
|
+
checks clear their thresholds. The model may only contribute the two safe
|
|
80
|
+
recoveries (they cannot send or submit anything); everything else asks the
|
|
81
|
+
user.
|
|
82
|
+
"""
|
|
83
|
+
if blocker == "none" and right >= T_TARGET and inp >= T_INPUT:
|
|
84
|
+
return "proceed"
|
|
85
|
+
if nxt in ("switch_target", "retype_input"):
|
|
86
|
+
return nxt
|
|
87
|
+
return "ask_user"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def guard(payload: dict[str, Any], key: str) -> int:
|
|
91
|
+
state = {
|
|
92
|
+
"task": payload.get("task", ""),
|
|
93
|
+
"expected": payload.get("expected", {}),
|
|
94
|
+
"observed": payload.get("observed", {}),
|
|
95
|
+
}
|
|
96
|
+
body = {
|
|
97
|
+
"state": state,
|
|
98
|
+
"model": model(),
|
|
99
|
+
"questions": {
|
|
100
|
+
"right_target": {
|
|
101
|
+
"type": "noul",
|
|
102
|
+
"instructions": (
|
|
103
|
+
"Is the observed target (chat_title / window_title) the same logical target "
|
|
104
|
+
"the task intends (expected)? Ignore cosmetic differences; answer about identity."
|
|
105
|
+
),
|
|
106
|
+
"criteria": {
|
|
107
|
+
"true": "observed target is the intended recipient/window",
|
|
108
|
+
"false": "observed target is a different recipient/window",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
"input_ok": {
|
|
112
|
+
"type": "noul",
|
|
113
|
+
"instructions": (
|
|
114
|
+
"Does `observed.input_text` match `expected.message` (the message the task intends "
|
|
115
|
+
"to send)? Allow minor whitespace differences, but answer false for empty, unrelated, "
|
|
116
|
+
"garbled, or someone else's clipboard content."
|
|
117
|
+
),
|
|
118
|
+
"criteria": {
|
|
119
|
+
"true": "observed input is the intended message",
|
|
120
|
+
"false": "observed input differs from expected.message, or is empty/unrelated/garbled",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
"blocker": {
|
|
124
|
+
"type": "choice",
|
|
125
|
+
"instructions": "What, if anything, blocks proceeding with the action?",
|
|
126
|
+
"criteria": {
|
|
127
|
+
"none": "Nothing blocks; safe to proceed",
|
|
128
|
+
"wrong_target": "The active chat/window is not the intended target",
|
|
129
|
+
"bad_input": "The input content is wrong, empty, or not the intended text",
|
|
130
|
+
"login_required": "A login or permission screen is showing",
|
|
131
|
+
"error_dialog": "An error or unexpected dialog is in the way",
|
|
132
|
+
"unknown": "Not enough evidence to tell",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
"next_action": {
|
|
136
|
+
"type": "choice",
|
|
137
|
+
"instructions": "What should the agent do next?",
|
|
138
|
+
"criteria": {
|
|
139
|
+
"proceed": "Target and input are correct; perform the action",
|
|
140
|
+
"switch_target": "Switch to the intended target first, then re-check",
|
|
141
|
+
"retype_input": "Clear the input and re-enter the intended text",
|
|
142
|
+
"ask_user": "Stop and ask the user",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
data, code = _post(body, key)
|
|
148
|
+
if data is None:
|
|
149
|
+
return code
|
|
150
|
+
|
|
151
|
+
a = data.get("answers", {})
|
|
152
|
+
right = a.get("right_target", {}).get("noul", 0.0)
|
|
153
|
+
inp = a.get("input_ok", {}).get("noul", 0.0)
|
|
154
|
+
blocker = a.get("blocker", {}).get("choice", "unknown")
|
|
155
|
+
nxt = a.get("next_action", {}).get("choice", "ask_user")
|
|
156
|
+
|
|
157
|
+
# Policy: code owns the decision. See `decide`.
|
|
158
|
+
decision = decide(right, inp, blocker, nxt)
|
|
159
|
+
|
|
160
|
+
print(json.dumps({
|
|
161
|
+
"answers": {"right_target": right, "input_ok": inp, "blocker": blocker, "next_action": nxt},
|
|
162
|
+
"thresholds": {"right_target": T_TARGET, "input_ok": T_INPUT},
|
|
163
|
+
"decision": decision,
|
|
164
|
+
"model_suggested": nxt,
|
|
165
|
+
"usage": data.get("usage"),
|
|
166
|
+
}, ensure_ascii=False))
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def select(payload: dict[str, Any], key: str) -> int:
|
|
171
|
+
goal = payload.get("goal", "")
|
|
172
|
+
candidates = payload.get("candidates", [])
|
|
173
|
+
if not candidates:
|
|
174
|
+
print(json.dumps({"error": "no candidates supplied"}), file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
index = {str(c.get("id")): (c.get("text") or "") for c in candidates}
|
|
178
|
+
choices = dict(index)
|
|
179
|
+
choices["none"] = "No candidate matches the goal"
|
|
180
|
+
body = {
|
|
181
|
+
"state": {"goal": goal, "candidates": [{"id": str(c.get("id")), "text": c.get("text") or ""} for c in candidates]},
|
|
182
|
+
"model": model(),
|
|
183
|
+
"questions": {
|
|
184
|
+
"pick": {
|
|
185
|
+
"type": "choice",
|
|
186
|
+
"instructions": (
|
|
187
|
+
"Which candidate is the element described by `goal`? Answer `none` when no "
|
|
188
|
+
"candidate matches. Judge by role, size, and the text/label it carries."
|
|
189
|
+
),
|
|
190
|
+
"criteria": choices,
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
data, code = _post(body, key)
|
|
195
|
+
if data is None:
|
|
196
|
+
return code
|
|
197
|
+
|
|
198
|
+
picked = data.get("answers", {}).get("pick", {})
|
|
199
|
+
choice = str(picked.get("choice", "none"))
|
|
200
|
+
probs = picked.get("probabilities", {}) or {}
|
|
201
|
+
confidence = float(probs.get(choice, 0.0))
|
|
202
|
+
|
|
203
|
+
if choice == "none" or choice not in index:
|
|
204
|
+
gate = "no_match"
|
|
205
|
+
elif confidence >= float(os.environ.get("MACOS_CU_JEV_SELECT_GATE", "0.7")):
|
|
206
|
+
gate = "auto"
|
|
207
|
+
else:
|
|
208
|
+
gate = "low_confidence_review"
|
|
209
|
+
|
|
210
|
+
print(json.dumps({
|
|
211
|
+
"id": choice,
|
|
212
|
+
"confidence": round(confidence, 4),
|
|
213
|
+
"probabilities": {k: round(float(v), 4) for k, v in probs.items()},
|
|
214
|
+
"gate": gate,
|
|
215
|
+
"usage": data.get("usage"),
|
|
216
|
+
}, ensure_ascii=False))
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run(args: argparse.Namespace) -> int:
|
|
221
|
+
key = _api_key_or_exit()
|
|
222
|
+
payload = json.load(sys.stdin)
|
|
223
|
+
return guard(payload, key) if args.cmd == "guard" else select(payload, key)
|