uacc 0.1.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.
uacc/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ UACC — Universal AI Computer Control
3
+ Let any LLM control a computer with pixel-precise UI interactions.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from uacc.config import config # noqa: F401
@@ -0,0 +1 @@
1
+ """UACC Actions — action schema, execution, and human mimicry."""
@@ -0,0 +1,390 @@
1
+ """
2
+ Artistic Painter — turn UACC into an AI painter that can paint images or preset designs
3
+ directly on screen in Microsoft Paint using precise drag-and-drop stroke trajectories.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import math
10
+ import time
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from PIL import Image, ImageFilter, ImageOps
14
+ import pyautogui
15
+
16
+ from uacc.actions.schema import DragAction, MouseButton, ClickAction, HotkeyAction
17
+ from uacc.actions.executor import ActionExecutor
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ArtisticPainter:
23
+ """Converts images or geometric presets into vector stroke paths and paints
24
+ them in Microsoft Paint using the UACC ActionExecutor."""
25
+
26
+ def __init__(self, executor: Optional[ActionExecutor] = None):
27
+ self.executor = executor or ActionExecutor(human_mimicry=False, action_delay_ms=5)
28
+
29
+ def draw_preset(self, preset_name: str, canvas_center: Tuple[int, int]) -> Dict[str, Any]:
30
+ """Paint a built-in masterpiece design by name.
31
+
32
+ Presets: "rose", "galaxy", "peacock", "mountains".
33
+ """
34
+ preset_name = preset_name.lower().strip()
35
+ cx, cy = canvas_center
36
+
37
+ logger.info("Painting preset art: '%s' around (%d, %d)", preset_name, cx, cy)
38
+
39
+ if preset_name == "rose":
40
+ strokes = self._generate_rose(cx, cy)
41
+ elif preset_name == "galaxy":
42
+ strokes = self._generate_galaxy(cx, cy)
43
+ elif preset_name == "mountains":
44
+ strokes = self._generate_mountains(cx, cy)
45
+ elif preset_name == "peacock":
46
+ return self._draw_peacock_direct(cx, cy)
47
+ else:
48
+ return {"success": False, "message": f"Unknown preset: '{preset_name}'"}
49
+
50
+ return self._execute_strokes(strokes)
51
+
52
+ def draw_image(
53
+ self,
54
+ image_path: str,
55
+ canvas_bounds: Tuple[int, int, int, int], # (left, top, right, bottom)
56
+ max_strokes: int = 150,
57
+ edge_threshold: int = 100,
58
+ ) -> Dict[str, Any]:
59
+ """Load an image, extract its outline contours, and paint it on screen.
60
+
61
+ Args:
62
+ image_path: Path to the image file to paint.
63
+ canvas_bounds: Screen coordinates of Paint's drawing canvas (left, top, right, bottom).
64
+ max_strokes: Cap on number of strokes to avoid infinite execution.
65
+ edge_threshold: Threshold to detect edges (higher = fewer lines, faster).
66
+ """
67
+ try:
68
+ img = Image.open(image_path)
69
+ except Exception as exc:
70
+ return {"success": False, "message": f"Failed to load image: {exc}"}
71
+
72
+ # Step 1: Image Processing (Resize to fit canvas and extract edges)
73
+ canvas_w = canvas_bounds[2] - canvas_bounds[0]
74
+ canvas_h = canvas_bounds[3] - canvas_bounds[1]
75
+
76
+ # Preserve aspect ratio
77
+ img.thumbnail((canvas_w - 40, canvas_h - 40))
78
+ img_w, img_h = img.size
79
+
80
+ # Offset to center within the canvas
81
+ offset_x = canvas_bounds[0] + (canvas_w - img_w) // 2
82
+ offset_y = canvas_bounds[1] + (canvas_h - img_h) // 2
83
+
84
+ # Grayscale + find edges
85
+ gray = ImageOps.grayscale(img)
86
+ edges = gray.filter(ImageFilter.FIND_EDGES)
87
+
88
+ # Binary thresholding
89
+ binary_edges = edges.point(lambda p: 255 if p > edge_threshold else 0)
90
+ width, height = binary_edges.size
91
+ pixels = binary_edges.load()
92
+
93
+ # Step 2: Path Tracing (Contiguous DFS tracking of edge pixels)
94
+ visited = set()
95
+ strokes = []
96
+
97
+ def get_neighbors(x, y):
98
+ neighbors = []
99
+ for dx in [-1, 0, 1]:
100
+ for dy in [-1, 0, 1]:
101
+ if dx == 0 and dy == 0:
102
+ continue
103
+ nx, ny = x + dx, y + dy
104
+ if 0 <= nx < width and 0 <= ny < height:
105
+ neighbors.append((nx, ny))
106
+ return neighbors
107
+
108
+ # Traverse and find contiguous lines
109
+ for y in range(0, height, 2): # Step 2 to downsample slightly
110
+ for x in range(0, width, 2):
111
+ if pixels[x, y] == 255 and (x, y) not in visited:
112
+ # Start a new stroke path
113
+ path = []
114
+ curr_x, curr_y = x, y
115
+ path.append((curr_x, curr_y))
116
+ visited.add((curr_x, curr_y))
117
+
118
+ # Follow the line
119
+ while True:
120
+ next_pixel = None
121
+ for nx, ny in get_neighbors(curr_x, curr_y):
122
+ if pixels[nx, ny] == 255 and (nx, ny) not in visited:
123
+ next_pixel = (nx, ny)
124
+ break
125
+ if next_pixel:
126
+ curr_x, curr_y = next_pixel
127
+ path.append((curr_x, curr_y))
128
+ visited.add((curr_x, curr_y))
129
+ if len(path) > 30: # Cap single path length to avoid huge vectors
130
+ break
131
+ else:
132
+ break
133
+
134
+ if len(path) > 3: # Filter out short noise dots
135
+ strokes.append(path)
136
+
137
+ # Cap the total strokes
138
+ strokes = strokes[:max_strokes]
139
+ logger.info("Generated %d outline paths from image", len(strokes))
140
+
141
+ # Step 3: Convert paths to DragActions (downsampled for 6x speed-up)
142
+ drag_actions = []
143
+ for path in strokes:
144
+ simplified_path = []
145
+ step = 6 # Take every 6th point along the path
146
+ for idx in range(0, len(path), step):
147
+ simplified_path.append(path[idx])
148
+ if path[-1] not in simplified_path:
149
+ simplified_path.append(path[-1])
150
+
151
+ for i in range(len(simplified_path) - 1):
152
+ x1, y1 = simplified_path[i]
153
+ x2, y2 = simplified_path[i + 1]
154
+ drag_actions.append(
155
+ DragAction(
156
+ start_x=int(x1 + offset_x),
157
+ start_y=int(y1 + offset_y),
158
+ end_x=int(x2 + offset_x),
159
+ end_y=int(y2 + offset_y),
160
+ button=MouseButton.LEFT,
161
+ duration_ms=40, # Fast drawing strokes
162
+ reasoning="Tracing edge outline",
163
+ )
164
+ )
165
+
166
+ return self._execute_strokes(drag_actions)
167
+
168
+ # ── Masterpiece Preset Generators ─────────────────────────
169
+
170
+ def _generate_rose(self, cx: int, cy: int) -> List[DragAction]:
171
+ """Generate a mathematical rose curve (Rhodonea curve)."""
172
+ actions = []
173
+ n, d = 5, 1 # 5-lobed rose
174
+ k = n / d
175
+ a = 150 # Radius/size
176
+ steps = 180
177
+
178
+ points = []
179
+ for i in range(steps + 1):
180
+ theta = (2 * math.pi * i) / steps
181
+ r = a * math.cos(k * theta)
182
+ x = cx + r * math.cos(theta)
183
+ y = cy + r * math.sin(theta)
184
+ points.append((int(x), int(y)))
185
+
186
+ for i in range(len(points) - 1):
187
+ actions.append(
188
+ DragAction(
189
+ start_x=points[i][0],
190
+ start_y=points[i][1],
191
+ end_x=points[i+1][0],
192
+ end_y=points[i+1][1],
193
+ button=MouseButton.LEFT,
194
+ duration_ms=50,
195
+ reasoning="Drawing rose petal curve",
196
+ )
197
+ )
198
+ return actions
199
+
200
+ def _generate_galaxy(self, cx: int, cy: int) -> List[DragAction]:
201
+ """Generate a double spiral galaxy pattern."""
202
+ actions = []
203
+ arms = 2
204
+ rotations = 3.5
205
+ max_r = 180
206
+ steps = 150
207
+
208
+ # Arm 1 & Arm 2
209
+ for arm in range(arms):
210
+ points = []
211
+ angle_offset = arm * math.pi
212
+ for i in range(steps):
213
+ t = i / steps
214
+ r = t * max_r
215
+ theta = t * rotations * 2 * math.pi + angle_offset
216
+ x = cx + r * math.cos(theta)
217
+ y = cy + r * math.sin(theta)
218
+ points.append((int(x), int(y)))
219
+
220
+ for i in range(len(points) - 1):
221
+ actions.append(
222
+ DragAction(
223
+ start_x=points[i][0],
224
+ start_y=points[i][1],
225
+ end_x=points[i+1][0],
226
+ end_y=points[i+1][1],
227
+ button=MouseButton.LEFT,
228
+ duration_ms=40,
229
+ reasoning="Drawing galaxy arm spiral",
230
+ )
231
+ )
232
+ return actions
233
+
234
+ def _generate_mountains(self, cx: int, cy: int) -> List[DragAction]:
235
+ """Generate a mountain range silhouette with a rising sun."""
236
+ actions = []
237
+
238
+ # 1. Draw Sun (ellipse)
239
+ sun_r = 50
240
+ sun_cx = cx
241
+ sun_cy = cy - 60
242
+ sun_pts = []
243
+ for i in range(21):
244
+ theta = math.pi * i / 20 # Half circle top
245
+ x = sun_cx + sun_r * math.cos(theta)
246
+ y = sun_cy - sun_r * math.sin(theta)
247
+ sun_pts.append((int(x), int(y)))
248
+
249
+ for i in range(len(sun_pts) - 1):
250
+ actions.append(
251
+ DragAction(
252
+ start_x=sun_pts[i][0],
253
+ start_y=sun_pts[i][1],
254
+ end_x=sun_pts[i+1][0],
255
+ end_y=sun_pts[i+1][1],
256
+ button=MouseButton.LEFT,
257
+ duration_ms=60,
258
+ reasoning="Drawing sun",
259
+ )
260
+ )
261
+
262
+ # 2. Draw Mountain ridges
263
+ peaks = [
264
+ (cx - 200, cy + 80),
265
+ (cx - 100, cy - 20),
266
+ (cx, cy + 40),
267
+ (cx + 120, cy - 50),
268
+ (cx + 220, cy + 80)
269
+ ]
270
+
271
+ for i in range(len(peaks) - 1):
272
+ actions.append(
273
+ DragAction(
274
+ start_x=peaks[i][0],
275
+ start_y=peaks[i][1],
276
+ end_x=peaks[i+1][0],
277
+ end_y=peaks[i+1][1],
278
+ button=MouseButton.LEFT,
279
+ duration_ms=100,
280
+ reasoning="Drawing mountain ridge line",
281
+ )
282
+ )
283
+
284
+ # Draw second background ridge
285
+ peaks2 = [
286
+ (cx - 150, cy + 80),
287
+ (cx - 40, cy + 10),
288
+ (cx + 60, cy - 10),
289
+ (cx + 170, cy + 80)
290
+ ]
291
+ for i in range(len(peaks2) - 1):
292
+ actions.append(
293
+ DragAction(
294
+ start_x=peaks2[i][0],
295
+ start_y=peaks2[i][1],
296
+ end_x=peaks2[i+1][0],
297
+ end_y=peaks2[i+1][1],
298
+ button=MouseButton.LEFT,
299
+ duration_ms=100,
300
+ reasoning="Drawing background mountain ridge",
301
+ )
302
+ )
303
+
304
+ return actions
305
+
306
+ def _draw_peacock_direct(self, cx: int, cy: int) -> Dict[str, Any]:
307
+ """Robust direct drawing of the famous peacock preset."""
308
+ # Standard utility helpers
309
+ def curve_points(x0, y0, x1, y1, cx, cy, n=8):
310
+ pts = []
311
+ for i in range(n + 1):
312
+ t = i / n
313
+ x = (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t**2 * x1
314
+ y = (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t**2 * y1
315
+ pts.append((int(x), int(y)))
316
+ return pts
317
+
318
+ def connect_points(pts):
319
+ acts = []
320
+ for i in range(len(pts) - 1):
321
+ acts.append(
322
+ DragAction(
323
+ start_x=pts[i][0], start_y=pts[i][1],
324
+ end_x=pts[i+1][0], end_y=pts[i+1][1],
325
+ button=MouseButton.LEFT, duration_ms=100,
326
+ reasoning="Peacock drawing path"
327
+ )
328
+ )
329
+ return acts
330
+
331
+ def ellipse(ecx, ecy, rx, ry, steps=16):
332
+ pts = []
333
+ for i in range(steps + 1):
334
+ theta = 2 * math.pi * i / steps
335
+ pts.append((int(ecx + rx * math.cos(theta)), int(ecy + ry * math.sin(theta))))
336
+ return connect_points(pts)
337
+
338
+ all_actions = []
339
+ tail_origin_x, tail_origin_y = cx - 40, cy + 20
340
+
341
+ # Draw tail feathers
342
+ feather_angles = [(-45, 200), (-25, 170), (25, 170), (45, 200)]
343
+ for angle_deg, length in feather_angles:
344
+ angle = math.radians(angle_deg)
345
+ tx = tail_origin_x + length * math.cos(angle)
346
+ ty = tail_origin_y + length * math.sin(angle)
347
+
348
+ shaft = curve_points(
349
+ tail_origin_x, tail_origin_y, tx, ty,
350
+ tail_origin_x + 30 * math.cos(angle + 0.1),
351
+ tail_origin_y + 30 * math.sin(angle + 0.1),
352
+ n=8
353
+ )
354
+ all_actions.extend(connect_points(shaft))
355
+ all_actions.extend(ellipse(tx, ty, 15, 8, steps=12))
356
+
357
+ # Body
358
+ all_actions.extend(ellipse(cx + 10, cy + 20, 30, 50, steps=20))
359
+ # Neck
360
+ neck = curve_points(cx + 10, cy - 20, cx + 50, cy - 100, cx + 40, cy - 60, n=8)
361
+ all_actions.extend(connect_points(neck))
362
+ # Head
363
+ all_actions.extend(ellipse(cx + 50, cy - 100, 10, 12, steps=12))
364
+
365
+ return self._execute_strokes(all_actions)
366
+
367
+ def _execute_strokes(self, strokes: List[DragAction]) -> Dict[str, Any]:
368
+ """Execute a list of DragActions sequentially."""
369
+ total = len(strokes)
370
+ if total == 0:
371
+ return {"success": False, "message": "No paths or lines generated."}
372
+
373
+ logger.info("Executing %d drawing strokes...", total)
374
+ success_count = 0
375
+
376
+ # Hold mouse click down safety checks in pyautogui
377
+ for idx, action in enumerate(strokes, 1):
378
+ res = self.executor.execute(action)
379
+ if res.get("success"):
380
+ success_count += 1
381
+ # Add small pause so user can abort if necessary
382
+ time.sleep(0.01)
383
+
384
+ pct = (success_count / total) * 100
385
+ return {
386
+ "success": success_count > 0,
387
+ "message": f"Successfully completed {success_count}/{total} strokes ({pct:.1f}%)",
388
+ "total_strokes": total,
389
+ "success_strokes": success_count,
390
+ }
@@ -0,0 +1,292 @@
1
+ """
2
+ Action Executor — translate typed Action objects into real mouse / keyboard events.
3
+
4
+ Uses pyautogui for cross-platform input simulation, with optional
5
+ human-like movement from the mimicry module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from typing import Optional
13
+
14
+ import pyautogui
15
+
16
+ from uacc.actions.human_mimicry import move_mouse_human, type_human
17
+ from uacc.actions.schema import (
18
+ Action,
19
+ ClickAction,
20
+ DoneAction,
21
+ DragAction,
22
+ HotkeyAction,
23
+ HoverAction,
24
+ ScrollAction,
25
+ ScreenshotAction,
26
+ TypeAction,
27
+ WaitAction,
28
+ is_potentially_destructive,
29
+ LaunchAction,
30
+ ClipboardAction,
31
+ FocusWindowAction,
32
+ )
33
+ from uacc.config import config
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Safety: disable pyautogui's fail-safe (move mouse to corner to abort)
38
+ pyautogui.FAILSAFE = config.uacc.pyautogui_failsafe
39
+ pyautogui.PAUSE = 0.05 # Small pause between pyautogui calls
40
+
41
+
42
+ class ActionExecutor:
43
+ """Dispatches Action objects to real input events."""
44
+
45
+ def __init__(
46
+ self,
47
+ human_mimicry: Optional[bool] = None,
48
+ action_delay_ms: Optional[int] = None,
49
+ safe_mode: Optional[bool] = None,
50
+ ):
51
+ self.human_mimicry = (
52
+ human_mimicry if human_mimicry is not None else config.uacc.human_mimicry
53
+ )
54
+ self.action_delay_ms = (
55
+ action_delay_ms if action_delay_ms is not None else config.uacc.action_delay_ms
56
+ )
57
+ self.safe_mode = safe_mode if safe_mode is not None else config.uacc.safe_mode
58
+ self._last_action_time = 0.0
59
+
60
+ def execute(self, action: Action) -> dict:
61
+ """Execute a single action and return a result dict.
62
+
63
+ Returns:
64
+ {"success": bool, "message": str, "action": str}
65
+ """
66
+ # Safety check
67
+ if self.safe_mode and is_potentially_destructive(action):
68
+ logger.warning("Destructive action detected: %s", action)
69
+ return {
70
+ "success": False,
71
+ "message": "Action blocked by safe mode (potentially destructive)",
72
+ "action": getattr(action, "action", "unknown"),
73
+ }
74
+
75
+ # Inter-action delay
76
+ elapsed = (time.time() - self._last_action_time) * 1000
77
+ if elapsed < self.action_delay_ms:
78
+ time.sleep((self.action_delay_ms - elapsed) / 1000)
79
+
80
+ try:
81
+ result = self._dispatch(action)
82
+ self._last_action_time = time.time()
83
+ return result
84
+ except Exception as exc:
85
+ logger.error("Action execution failed: %s", exc)
86
+ return {
87
+ "success": False,
88
+ "message": f"Execution error: {exc}",
89
+ "action": getattr(action, "action", "unknown"),
90
+ }
91
+
92
+ def _dispatch(self, action: Action) -> dict:
93
+ """Route to the appropriate handler."""
94
+ if isinstance(action, ClickAction):
95
+ return self._click(action)
96
+ elif isinstance(action, DragAction):
97
+ return self._drag(action)
98
+ elif isinstance(action, TypeAction):
99
+ return self._type(action)
100
+ elif isinstance(action, HotkeyAction):
101
+ return self._hotkey(action)
102
+ elif isinstance(action, ScrollAction):
103
+ return self._scroll(action)
104
+ elif isinstance(action, HoverAction):
105
+ return self._hover(action)
106
+ elif isinstance(action, WaitAction):
107
+ return self._wait(action)
108
+ elif isinstance(action, ScreenshotAction):
109
+ return self._screenshot(action)
110
+ elif isinstance(action, DoneAction):
111
+ return self._done(action)
112
+ elif isinstance(action, LaunchAction):
113
+ return self._launch(action)
114
+ elif isinstance(action, ClipboardAction):
115
+ return self._clipboard(action)
116
+ elif isinstance(action, FocusWindowAction):
117
+ return self._focus_window(action)
118
+ else:
119
+ return {"success": False, "message": f"Unknown action: {action}", "action": "unknown"}
120
+
121
+ # ── Handlers ─────────────────────────────────────────────
122
+
123
+ def _click(self, action: ClickAction) -> dict:
124
+ """Execute a click action."""
125
+ # Move to position
126
+ if self.human_mimicry:
127
+ current = pyautogui.position()
128
+ move_mouse_human(current, (action.x, action.y))
129
+ else:
130
+ pyautogui.moveTo(action.x, action.y, duration=0.1)
131
+
132
+ # Apply modifiers
133
+ for mod in action.modifiers:
134
+ pyautogui.keyDown(mod)
135
+
136
+ # Click
137
+ button = action.button.value
138
+ if action.count == 2:
139
+ pyautogui.doubleClick(action.x, action.y, button=button)
140
+ else:
141
+ for _ in range(action.count):
142
+ pyautogui.click(action.x, action.y, button=button)
143
+
144
+ # Release modifiers
145
+ for mod in reversed(action.modifiers):
146
+ pyautogui.keyUp(mod)
147
+
148
+ logger.info(
149
+ "Click: (%d, %d) button=%s count=%d", action.x, action.y, button, action.count
150
+ )
151
+ return {
152
+ "success": True,
153
+ "message": f"Clicked at ({action.x}, {action.y})",
154
+ "action": "click",
155
+ }
156
+
157
+ def _drag(self, action: DragAction) -> dict:
158
+ """Execute a drag action with smooth movement."""
159
+ if self.human_mimicry:
160
+ current = pyautogui.position()
161
+ move_mouse_human(current, (action.start_x, action.start_y))
162
+
163
+ duration = action.duration_ms / 1000
164
+ pyautogui.moveTo(action.start_x, action.start_y, duration=0.1)
165
+ pyautogui.mouseDown(button=action.button.value)
166
+
167
+ if self.human_mimicry:
168
+ move_mouse_human(
169
+ (action.start_x, action.start_y),
170
+ (action.end_x, action.end_y),
171
+ duration_ms=action.duration_ms,
172
+ )
173
+ else:
174
+ pyautogui.moveTo(action.end_x, action.end_y, duration=duration)
175
+
176
+ pyautogui.mouseUp(button=action.button.value)
177
+
178
+ logger.info(
179
+ "Drag: (%d,%d) → (%d,%d)",
180
+ action.start_x, action.start_y, action.end_x, action.end_y,
181
+ )
182
+ return {
183
+ "success": True,
184
+ "message": f"Dragged ({action.start_x},{action.start_y}) → ({action.end_x},{action.end_y})",
185
+ "action": "drag",
186
+ }
187
+
188
+ def _type(self, action: TypeAction) -> dict:
189
+ """Type text."""
190
+ if self.human_mimicry and action.delay_ms == 0:
191
+ type_human(action.text)
192
+ elif action.delay_ms > 0:
193
+ pyautogui.typewrite(action.text, interval=action.delay_ms / 1000)
194
+ else:
195
+ # Use write() for fast typing — handles special characters
196
+ pyautogui.write(action.text)
197
+
198
+ logger.info("Typed: %s", action.text[:50])
199
+ return {
200
+ "success": True,
201
+ "message": f"Typed {len(action.text)} characters",
202
+ "action": "type",
203
+ }
204
+
205
+ def _hotkey(self, action: HotkeyAction) -> dict:
206
+ """Press a hotkey combination."""
207
+ pyautogui.hotkey(*action.keys)
208
+ combo = "+".join(action.keys)
209
+ logger.info("Hotkey: %s", combo)
210
+ return {"success": True, "message": f"Pressed {combo}", "action": "hotkey"}
211
+
212
+ def _scroll(self, action: ScrollAction) -> dict:
213
+ """Scroll at a position."""
214
+ pyautogui.moveTo(action.x, action.y, duration=0.05)
215
+ if action.direction.value in ("up", "down"):
216
+ clicks = action.amount if action.direction.value == "up" else -action.amount
217
+ pyautogui.scroll(clicks, action.x, action.y)
218
+ else:
219
+ clicks = action.amount if action.direction.value == "right" else -action.amount
220
+ pyautogui.hscroll(clicks, action.x, action.y)
221
+
222
+ logger.info("Scroll: %s ×%d at (%d,%d)", action.direction.value, action.amount, action.x, action.y)
223
+ return {
224
+ "success": True,
225
+ "message": f"Scrolled {action.direction.value} ×{action.amount}",
226
+ "action": "scroll",
227
+ }
228
+
229
+ def _hover(self, action: HoverAction) -> dict:
230
+ """Hover at a position."""
231
+ if self.human_mimicry:
232
+ current = pyautogui.position()
233
+ move_mouse_human(current, (action.x, action.y))
234
+ else:
235
+ pyautogui.moveTo(action.x, action.y, duration=0.15)
236
+
237
+ time.sleep(action.duration_ms / 1000)
238
+ logger.info("Hover: (%d,%d) for %dms", action.x, action.y, action.duration_ms)
239
+ return {
240
+ "success": True,
241
+ "message": f"Hovered at ({action.x}, {action.y}) for {action.duration_ms}ms",
242
+ "action": "hover",
243
+ }
244
+
245
+ def _wait(self, action: WaitAction) -> dict:
246
+ """Wait for a fixed duration."""
247
+ time.sleep(action.duration_ms / 1000)
248
+ logger.info("Waited: %dms", action.duration_ms)
249
+ return {
250
+ "success": True,
251
+ "message": f"Waited {action.duration_ms}ms",
252
+ "action": "wait",
253
+ }
254
+
255
+ def _screenshot(self, action: ScreenshotAction) -> dict:
256
+ """Signal that a screenshot should be taken (handled by controller)."""
257
+ logger.info("Screenshot requested")
258
+ return {
259
+ "success": True,
260
+ "message": "Screenshot requested — will be captured by controller",
261
+ "action": "screenshot",
262
+ }
263
+
264
+ def _done(self, action: DoneAction) -> dict:
265
+ """Signal task completion."""
266
+ logger.info("Task done: success=%s result=%s", action.success, action.result)
267
+ return {
268
+ "success": action.success,
269
+ "message": action.result or "Task completed",
270
+ "action": "done",
271
+ }
272
+
273
+ def _launch(self, action: LaunchAction) -> dict:
274
+ """Launch an application."""
275
+ from uacc.core.window_manager import launch_application
276
+ res = launch_application(action.name_or_path, action.arguments)
277
+ return {**res, "action": "launch"}
278
+
279
+ def _clipboard(self, action: ClipboardAction) -> dict:
280
+ """Read from or write to the clipboard."""
281
+ from uacc.core.clipboard import read_clipboard, write_clipboard
282
+ if action.mode == "write":
283
+ res = write_clipboard(action.text)
284
+ else:
285
+ res = read_clipboard()
286
+ return {**res, "action": "clipboard"}
287
+
288
+ def _focus_window(self, action: FocusWindowAction) -> dict:
289
+ """Focus a window."""
290
+ from uacc.core.window_manager import focus_window
291
+ res = focus_window(action.title)
292
+ return {**res, "action": "focus_window"}