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