seedcode-cli 6.1.5__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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,324 @@
1
+ """ComputerController: the façade the desktop tools drive.
2
+
3
+ Owns the driver modules (injectable for tests), validates every coordinate
4
+ against the live virtual-desktop geometry (never a blind click), paces
5
+ actions, and verifies each mutating action by reporting post-action state —
6
+ the active window and the element under the pointer — so the agent loop can
7
+ confirm outcomes instead of assuming them. Focus operations retry once
8
+ before surfacing a failure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from typing import Any
15
+
16
+ from ..utils.logger import get_logger
17
+ from . import browser as browser_driver
18
+ from . import keyboard as keyboard_driver
19
+ from . import mouse as mouse_driver
20
+ from . import registry as registry_driver
21
+ from . import screen as screen_driver
22
+ from . import vision as vision_driver
23
+ from . import windows as windows_driver
24
+
25
+ _log = get_logger("computer")
26
+
27
+ # One agent turn may not wait forever; desktop_wait is clamped to this.
28
+ MAX_WAIT_S = 30.0
29
+ # Settle time after mutating actions before state is read for verification.
30
+ _VERIFY_DELAY_S = 0.3
31
+
32
+
33
+ class ComputerError(Exception):
34
+ """A desktop action failed; the message is fed back to the model."""
35
+
36
+
37
+ class ComputerController:
38
+ """High-level desktop actions with validation and built-in verification."""
39
+
40
+ def __init__(
41
+ self,
42
+ mouse: Any = mouse_driver,
43
+ keyboard: Any = keyboard_driver,
44
+ screen: Any = screen_driver,
45
+ windows: Any = windows_driver,
46
+ vision: Any = vision_driver,
47
+ registry: Any = registry_driver,
48
+ browser: Any = browser_driver,
49
+ ) -> None:
50
+ self.mouse = mouse
51
+ self.keyboard = keyboard
52
+ self.screen = screen
53
+ self.windows = windows
54
+ self.vision = vision
55
+ self.registry = registry
56
+ self.browser = browser
57
+ # Declare per-monitor-DPI-awareness v2 once, before any coordinate is
58
+ # read or acted on, so UIA geometry, screenshots, and pointer clicks
59
+ # all share one physical-pixel space (see :mod:`.dpi`). Best-effort and
60
+ # idempotent; a no-op off win32.
61
+ from . import dpi as _dpi
62
+
63
+ self.dpi = _dpi.ensure_dpi_awareness()
64
+
65
+ # --- coordinates ---------------------------------------------------------
66
+ def validate_point(self, x: Any, y: Any) -> tuple[int, int]:
67
+ """Coerce and bounds-check a coordinate pair against the desktop."""
68
+ try:
69
+ px, py = int(x), int(y)
70
+ except (TypeError, ValueError):
71
+ raise ComputerError(f"Coordinates must be integers, got ({x!r}, {y!r}).")
72
+ geo = self.screen.geometry()
73
+ if not geo.contains(px, py):
74
+ raise ComputerError(
75
+ f"({px}, {py}) is outside the desktop "
76
+ f"({geo.left}..{geo.left + geo.width - 1}, "
77
+ f"{geo.top}..{geo.top + geo.height - 1}). "
78
+ "Take a fresh desktop_see snapshot for current coordinates."
79
+ )
80
+ return px, py
81
+
82
+ # --- verification helpers ------------------------------------------------
83
+ def _state_after(self, x: int | None = None, y: int | None = None) -> str:
84
+ """Post-action state line appended to every mutating result."""
85
+ time.sleep(_VERIFY_DELAY_S)
86
+ active = self.windows.active_window()
87
+ parts = [f"active window: {active.describe() if active else '(none)'}"]
88
+ if x is not None and y is not None:
89
+ parts.append(f"element at ({x}, {y}): {self.vision.element_at(x, y)}")
90
+ return "[verify] " + "; ".join(parts)
91
+
92
+ # --- mouse ---------------------------------------------------------------
93
+ def mouse_move(self, x: Any, y: Any) -> str:
94
+ px, py = self.validate_point(x, y)
95
+ self.mouse.move(px, py)
96
+ return f"Moved pointer to ({px}, {py}).\n" + self._state_after(px, py)
97
+
98
+ def mouse_click(self, x: Any, y: Any, button: str = "left", double: bool = False) -> str:
99
+ px, py = self.validate_point(x, y)
100
+ if button not in ("left", "right"):
101
+ raise ComputerError("button must be 'left' or 'right'.")
102
+ self.mouse.click(px, py, button=button, double=double)
103
+ kind = "Double-clicked" if double else f"{button.capitalize()}-clicked"
104
+ return f"{kind} at ({px}, {py}).\n" + self._state_after(px, py)
105
+
106
+ def mouse_drag(self, x1: Any, y1: Any, x2: Any, y2: Any) -> str:
107
+ sx, sy = self.validate_point(x1, y1)
108
+ ex, ey = self.validate_point(x2, y2)
109
+ self.mouse.drag(sx, sy, ex, ey)
110
+ return f"Dragged ({sx}, {sy}) -> ({ex}, {ey}).\n" + self._state_after(ex, ey)
111
+
112
+ def mouse_scroll(self, amount: Any, x: Any = None, y: Any = None) -> str:
113
+ try:
114
+ notches = int(amount)
115
+ except (TypeError, ValueError):
116
+ raise ComputerError(f"Scroll amount must be an integer, got {amount!r}.")
117
+ notches = max(-50, min(50, notches))
118
+ point = None
119
+ if x is not None and y is not None:
120
+ point = self.validate_point(x, y)
121
+ self.mouse.scroll(notches, *(point or (None, None)))
122
+ where = f" at {point}" if point else ""
123
+ return f"Scrolled {notches} notches{where}.\n" + self._state_after(*(point or (None, None)))
124
+
125
+ # --- keyboard ------------------------------------------------------------
126
+ def type_text(self, text: str) -> str:
127
+ try:
128
+ self.keyboard.type_text(str(text))
129
+ except ValueError as exc:
130
+ raise ComputerError(str(exc))
131
+ shown = str(text) if len(str(text)) <= 60 else str(text)[:60] + "..."
132
+ return f"Typed {len(str(text))} chars ({shown!r}).\n" + self._state_after()
133
+
134
+ def hotkey(self, keys: list[str]) -> str:
135
+ try:
136
+ self.keyboard.hotkey([str(k) for k in keys])
137
+ except ValueError as exc:
138
+ raise ComputerError(str(exc))
139
+ return f"Pressed {'+'.join(str(k) for k in keys)}.\n" + self._state_after()
140
+
141
+ # --- windows -------------------------------------------------------------
142
+ def list_windows(self) -> str:
143
+ windows = self.windows.list_windows()
144
+ if not windows:
145
+ return "No open windows found."
146
+ return "Open windows:\n" + "\n".join(f" - {w.describe()}" for w in windows)
147
+
148
+ def focus_window(self, title: str) -> str:
149
+ """Focus with one retry — Windows foreground rules are flaky."""
150
+ last_error: Exception | None = None
151
+ for attempt in (1, 2):
152
+ try:
153
+ self.windows.focus_window(title)
154
+ time.sleep(_VERIFY_DELAY_S)
155
+ active = self.windows.active_window()
156
+ if active and title.strip().lower() in active.title.lower():
157
+ return f"Focused window.\n[verify] active window: {active.describe()}"
158
+ last_error = None
159
+ except Exception as exc:
160
+ last_error = exc
161
+ time.sleep(0.4) # let the window manager settle, then retry
162
+ if last_error is not None:
163
+ raise ComputerError(f"Could not focus '{title}': {last_error}")
164
+ active = self.windows.active_window()
165
+ raise ComputerError(
166
+ f"Focus did not stick on '{title}'. Active window is "
167
+ f"{active.describe() if active else '(none)'} — check desktop_windows."
168
+ )
169
+
170
+ def open_app(self, target: str) -> str:
171
+ try:
172
+ message = self.windows.open_app(target)
173
+ except Exception as exc:
174
+ raise ComputerError(str(exc))
175
+ time.sleep(1.0) # give the app a moment to show a window
176
+ return message + "\n" + self._state_after()
177
+
178
+ def close_app(self, title: str, force: bool = False) -> str:
179
+ try:
180
+ message = self.windows.close_window(title, force=force)
181
+ except Exception as exc:
182
+ raise ComputerError(str(exc))
183
+ return message + "\n" + self._state_after()
184
+
185
+ # --- screen & vision -----------------------------------------------------
186
+ def screenshot(
187
+ self,
188
+ region: tuple[int, int, int, int] | None = None,
189
+ monitor: int | None = None,
190
+ save_to=None,
191
+ ) -> str:
192
+ try:
193
+ path = self.screen.capture(region=region, monitor=monitor, save_to=save_to)
194
+ except Exception as exc:
195
+ raise ComputerError(f"Screenshot failed: {exc}")
196
+ return str(path)
197
+
198
+ def screen_info(self) -> str:
199
+ from . import dpi as _dpi
200
+
201
+ geo = self.screen.geometry()
202
+ lines = [
203
+ f"Virtual desktop: {geo.width}x{geo.height} at ({geo.left}, {geo.top})",
204
+ f"Monitors: {len(geo.monitors)}",
205
+ ]
206
+ for m in geo.monitors:
207
+ primary = " (primary)" if m.primary else ""
208
+ # Report each monitor's scale so the model understands why a
209
+ # 1920-wide panel may only be 1280 logical px, etc.
210
+ scale = _dpi.scale_for_point(m.left + m.width // 2, m.top + m.height // 2)
211
+ scale_txt = f" @ {int(scale * 100)}%" if scale != 1.0 else ""
212
+ lines.append(
213
+ f" - Monitor {m.index}: {m.width}x{m.height} at "
214
+ f"({m.left}, {m.top}){scale_txt}{primary}"
215
+ )
216
+ lines.append(getattr(self, "dpi", _dpi.ensure_dpi_awareness()).describe())
217
+ return "\n".join(lines)
218
+
219
+ def see(self, window_title: str | None = None) -> str:
220
+ """UI-tree snapshot: what is on screen, with clickable coordinates."""
221
+ try:
222
+ title, elements = self.vision.snapshot(window_title)
223
+ except ValueError as exc:
224
+ raise ComputerError(str(exc))
225
+ except Exception as exc:
226
+ raise ComputerError(f"Could not read the UI tree: {exc}")
227
+ return self.vision.describe_snapshot(title, elements)
228
+
229
+ def wait(self, seconds: Any) -> str:
230
+ try:
231
+ duration = float(seconds)
232
+ except (TypeError, ValueError):
233
+ raise ComputerError(f"Wait time must be a number, got {seconds!r}.")
234
+ duration = max(0.0, min(duration, MAX_WAIT_S))
235
+ time.sleep(duration)
236
+ return f"Waited {duration:g}s.\n" + self._state_after()
237
+
238
+ # --- registry ------------------------------------------------------------
239
+ def registry_read(self, path: str, name: str = "") -> str:
240
+ try:
241
+ return self.registry.read_value(path, name)
242
+ except FileNotFoundError:
243
+ raise ComputerError(f"Registry key or value not found: {path} : {name}")
244
+ except (ValueError, OSError, RuntimeError) as exc:
245
+ raise ComputerError(f"Registry read failed: {exc}")
246
+
247
+ def registry_list(self, path: str) -> str:
248
+ try:
249
+ return self.registry.list_key(path)
250
+ except FileNotFoundError:
251
+ raise ComputerError(f"Registry key not found: {path}")
252
+ except (ValueError, OSError, RuntimeError) as exc:
253
+ raise ComputerError(f"Registry list failed: {exc}")
254
+
255
+ def registry_write(self, path: str, name: str, value: str, value_type: str) -> str:
256
+ try:
257
+ return self.registry.write_value(path, name, value, value_type)
258
+ except (ValueError, OSError, RuntimeError) as exc:
259
+ raise ComputerError(f"Registry write failed: {exc}")
260
+
261
+ def registry_delete(self, path: str, name: str) -> str:
262
+ try:
263
+ return self.registry.delete_value(path, name)
264
+ except FileNotFoundError:
265
+ raise ComputerError(f"Registry key or value not found: {path} : {name}")
266
+ except (ValueError, OSError, RuntimeError) as exc:
267
+ raise ComputerError(f"Registry delete failed: {exc}")
268
+
269
+ # --- browser -------------------------------------------------------------
270
+ def browser_navigate(self, url: str) -> str:
271
+ """Open a URL in the user's default browser."""
272
+ try:
273
+ return self.browser.navigate(url)
274
+ except Exception as exc:
275
+ raise ComputerError(f"Browser navigation failed: {exc}")
276
+
277
+ def browser_search(self, query: str, engine: str = "google") -> str:
278
+ """Run a web search in the user's default browser."""
279
+ try:
280
+ return self.browser.search(query, engine=engine)
281
+ except Exception as exc:
282
+ raise ComputerError(f"Browser search failed: {exc}")
283
+
284
+ def browser_default(self) -> str:
285
+ """Report which browser is the system default."""
286
+ try:
287
+ return self.browser.default_browser().describe()
288
+ except Exception as exc:
289
+ raise ComputerError(f"Could not determine the default browser: {exc}")
290
+
291
+ def browser_click(self, selector: str, selector_type: str = "css") -> str:
292
+ """Click element in browser."""
293
+ try:
294
+ return self.browser.click_element(selector, selector_type)
295
+ except Exception as exc:
296
+ raise ComputerError(f"Browser click failed: {exc}")
297
+
298
+ def browser_type(self, selector: str, text: str, selector_type: str = "css") -> str:
299
+ """Type into browser element."""
300
+ try:
301
+ return self.browser.type_in_element(selector, text, selector_type)
302
+ except Exception as exc:
303
+ raise ComputerError(f"Browser typing failed: {exc}")
304
+
305
+ def browser_info(self) -> str:
306
+ """Get current browser page info."""
307
+ try:
308
+ return self.browser.get_page_info()
309
+ except Exception as exc:
310
+ raise ComputerError(f"Browser info failed: {exc}")
311
+
312
+ def browser_find(self, selector: str, selector_type: str = "css") -> str:
313
+ """Find elements in browser."""
314
+ try:
315
+ return self.browser.find_elements(selector, selector_type)
316
+ except Exception as exc:
317
+ raise ComputerError(f"Browser find failed: {exc}")
318
+
319
+ def browser_close(self) -> str:
320
+ """Close browser."""
321
+ try:
322
+ return self.browser.close_browser()
323
+ except Exception as exc:
324
+ raise ComputerError(f"Browser close failed: {exc}")
@@ -0,0 +1,272 @@
1
+ """Skill dispatcher: the coordinator between AI decisions and deterministic work.
2
+
3
+ The dispatcher is the single choke point through which every AI-selected action
4
+ flows. It:
5
+
6
+ 1. looks up the skill (or builds an ad-hoc action for a semantic UI verb),
7
+ 2. executes it deterministically via the :class:`SkillContext`,
8
+ 3. verifies the declared outcome with the :class:`VerificationEngine`,
9
+ 4. on failure, runs the :class:`RecoveryEngine`'s local strategy ladder,
10
+ 5. records every stage in an :class:`ExecutionLog`,
11
+ 6. returns a :class:`DispatchResult` — verified success, or a failure carrying
12
+ a replan hint the AI planner uses (and only then) to think again.
13
+
14
+ The AI never sees the inner steps or any coordinates. It selects a skill by
15
+ name or issues a semantic UI verb with element *descriptions*; the dispatcher
16
+ and the engines below it do the rest.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from .logbook import ExecutionLog
25
+ from .recovery import RecoveryEngine
26
+ from .skills import Outcome, Skill, SkillContext, SkillError, SkillRegistry
27
+ from .verifier import VerificationEngine
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class DispatchResult:
32
+ ok: bool
33
+ detail: str
34
+ log: ExecutionLog
35
+ replan_hint: str | None = None
36
+
37
+ def for_model(self) -> str:
38
+ """Concise text the AI reads back — verified evidence, never raw steps."""
39
+ head = self.detail
40
+ if not self.ok and self.replan_hint:
41
+ return f"{head}\nReplan hint: {self.replan_hint}"
42
+ return head
43
+
44
+
45
+ # Semantic UI verbs the AI may issue directly (no named skill). Each maps to a
46
+ # deterministic body over the resolver + controller; every target is a
47
+ # *description*, resolved to coordinates internally.
48
+ class _SemanticActions:
49
+ """Ad-hoc, description-driven UI actions built on the resolver."""
50
+
51
+ @staticmethod
52
+ def click(ctx: SkillContext, params: dict[str, Any], *, button="left", double=False) -> Outcome:
53
+ target = str(params.get("target", "")).strip()
54
+ if not target:
55
+ raise SkillError("ui action requires a 'target' description")
56
+ detail = ctx.click_described(target, button=button, double=double)
57
+ return Outcome(detail, {"element": target})
58
+
59
+ @staticmethod
60
+ def type(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
61
+ target = str(params.get("target", "")).strip()
62
+ text = str(params.get("text", ""))
63
+ secret = bool(params.get("secret", False))
64
+ if not target:
65
+ raise SkillError("ui_type requires a 'target' description")
66
+ detail = ctx.type_into(target, text, secret=secret)
67
+ return Outcome(detail)
68
+
69
+ @staticmethod
70
+ def wait_for(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
71
+ target = str(params.get("target") or params.get("description", "")).strip()
72
+ if not target:
73
+ raise SkillError("ui_wait_for requires a 'target' description")
74
+ return Outcome(f"waiting for {target}", {"element": target})
75
+
76
+ @staticmethod
77
+ def assert_(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
78
+ target = str(params.get("target") or params.get("description", "")).strip()
79
+ if not target:
80
+ raise SkillError("ui_assert requires a 'target' description")
81
+ return Outcome(f"asserting {target}", {"element": target})
82
+
83
+
84
+ _SEMANTIC = {
85
+ "ui_click": lambda c, p: _SemanticActions.click(c, p),
86
+ "ui_double_click": lambda c, p: _SemanticActions.click(c, p, double=True),
87
+ "ui_right_click": lambda c, p: _SemanticActions.click(c, p, button="right"),
88
+ "ui_type": lambda c, p: _SemanticActions.type(c, p),
89
+ "ui_wait_for": lambda c, p: _SemanticActions.wait_for(c, p),
90
+ "ui_assert": lambda c, p: _SemanticActions.assert_(c, p),
91
+ }
92
+
93
+ # Semantic verbs that actually manipulate the UI. Observation verbs
94
+ # (ui_wait_for, ui_assert) are harmless on a browser and stay allowed.
95
+ _MUTATING_SEMANTIC = ("ui_click", "ui_double_click", "ui_right_click", "ui_type")
96
+
97
+ # Window-title fragments that identify a browser. Titles end with the browser
98
+ # name on every mainstream platform ("YouTube — Google Chrome").
99
+ _BROWSER_MARKERS = (
100
+ "google chrome", "chrome", "microsoft edge", "edge", "mozilla firefox",
101
+ "firefox", "brave", "opera", "vivaldi", "chromium", "safari",
102
+ )
103
+
104
+
105
+ def _is_browser_window(title: str | None) -> bool:
106
+ """Whether a window title belongs to a web browser."""
107
+ low = (title or "").strip().lower()
108
+ if not low:
109
+ return False
110
+ return any(marker in low for marker in _BROWSER_MARKERS)
111
+
112
+
113
+ def _browser_skill_names(registry: SkillRegistry) -> str:
114
+ """The browser skills to point the planner at, from the live registry."""
115
+ wanted = (
116
+ "youtube_play", "youtube_search", "google_search", "web_search",
117
+ "open_url", "new_tab", "close_tab", "switch_tab", "back", "forward",
118
+ "refresh",
119
+ )
120
+ available = [name for name in wanted if registry.get(name) is not None]
121
+ return ", ".join(available)
122
+
123
+
124
+ class SkillDispatcher:
125
+ """Coordinates execute → verify → recover for one action at a time."""
126
+
127
+ def __init__(
128
+ self,
129
+ controller: Any,
130
+ resolver: Any,
131
+ state: Any,
132
+ permissions: Any,
133
+ registry: SkillRegistry,
134
+ verifier: VerificationEngine | None = None,
135
+ recovery: RecoveryEngine | None = None,
136
+ ) -> None:
137
+ self._ctx = SkillContext(
138
+ controller=controller, resolver=resolver, state=state, permissions=permissions
139
+ )
140
+ self._registry = registry
141
+ self._verifier = verifier or VerificationEngine(
142
+ vision=getattr(controller, "vision", None),
143
+ windows=getattr(controller, "windows", None),
144
+ )
145
+ self._recovery = recovery or RecoveryEngine(controller=controller, state=state)
146
+
147
+ def _refuse_browser_ui(self, verb: str, params: dict[str, Any]) -> str | None:
148
+ """The refusal message for a ui_* verb on a browser, or None to allow.
149
+
150
+ Deliberately fails *open*: if the foreground window cannot be read, the
151
+ action proceeds. A guard that blocked on uncertainty would break native
152
+ automation on any machine whose window driver is unavailable.
153
+ """
154
+ controller = getattr(self._ctx, "controller", None)
155
+ windows = getattr(controller, "windows", None)
156
+ if windows is None:
157
+ return None
158
+ try:
159
+ active = windows.active_window()
160
+ except Exception:
161
+ return None
162
+ title = getattr(active, "title", None)
163
+ if not _is_browser_window(title):
164
+ return None
165
+ target = str(params.get("target", "")).strip()
166
+ skills = _browser_skill_names(self._registry)
167
+ return (
168
+ f"{verb} is not available on browser windows"
169
+ f'{f" (target: {target!r})" if target else ""}. '
170
+ "Browser work is handled by complete skills that navigate, dismiss "
171
+ "popups, select results, and verify the outcome in one step — "
172
+ "clicking through a page yourself is slower and breaks whenever the "
173
+ f"site changes. Use computer_run with one of: {skills}. "
174
+ "For example, to play a song: "
175
+ 'computer_run(skill="youtube_play", params={"query": "<song name>"}).'
176
+ )
177
+
178
+ def dispatch(
179
+ self, name: str, params: dict[str, Any] | None = None, expected: dict[str, Any] | None = None
180
+ ) -> DispatchResult:
181
+ """Run one skill or semantic UI verb end-to-end with verification.
182
+
183
+ Every outcome — success, failure, and everything the engine did on the
184
+ way — is appended to the structured execution log on disk.
185
+ """
186
+ result = self._dispatch(name, params, expected)
187
+ result.log.persist(label=str(name).strip().lower())
188
+ return result
189
+
190
+ def _dispatch(
191
+ self, name: str, params: dict[str, Any] | None = None, expected: dict[str, Any] | None = None
192
+ ) -> DispatchResult:
193
+ log = ExecutionLog()
194
+ params = params or {}
195
+ name = str(name).strip().lower()
196
+
197
+ skill = self._registry.get(name)
198
+ semantic = _SEMANTIC.get(name)
199
+ if skill is None and semantic is None:
200
+ log.done(f"unknown skill or action '{name}'", ok=False)
201
+ return DispatchResult(
202
+ False, f"No such skill '{name}'.", log,
203
+ replan_hint="Choose a skill from the catalog or a ui_* action.",
204
+ )
205
+
206
+ label = skill.name if skill else name
207
+ log.plan(f"selected {label} {params}")
208
+
209
+ # --- browser guard ---------------------------------------------------
210
+ # Browser workflows are owned end-to-end by the browser skills. Letting
211
+ # the AI click its way around a web page is what this architecture
212
+ # exists to prevent: it burns turns, breaks on every layout change, and
213
+ # drags OCR into a problem a URL already solves. So a mutating ui_*
214
+ # verb aimed at a browser window is refused with a pointer to the skill
215
+ # that does the whole job.
216
+ if name in _MUTATING_SEMANTIC:
217
+ refusal = self._refuse_browser_ui(name, params)
218
+ if refusal is not None:
219
+ log.done(refusal, ok=False)
220
+ return DispatchResult(False, refusal, log, replan_hint=refusal)
221
+
222
+ # --- execute ---------------------------------------------------------
223
+ def _run() -> Outcome:
224
+ if skill is not None:
225
+ return skill.run(self._ctx, params)
226
+ return semantic(self._ctx, params)
227
+
228
+ try:
229
+ outcome = _run()
230
+ except SkillError as exc:
231
+ log.execute(str(exc), ok=False)
232
+ log.done(f"{label} failed: {exc}", ok=False)
233
+ return DispatchResult(False, str(exc), log, replan_hint=str(exc))
234
+ except Exception as exc: # permission denial, driver error, ...
235
+ log.execute(f"{type(exc).__name__}: {exc}", ok=False)
236
+ log.done(f"{label} failed: {exc}", ok=False)
237
+ return DispatchResult(
238
+ False, f"{label} could not run: {exc}", log,
239
+ replan_hint=f"{type(exc).__name__}: {exc}",
240
+ )
241
+ log.execute(outcome.detail)
242
+
243
+ # Caller-supplied expectation overrides the skill's own.
244
+ want = expected or outcome.expected
245
+
246
+ # --- verify ----------------------------------------------------------
247
+ result = self._verifier.verify(want)
248
+ if result.ok:
249
+ log.verify(result.detail)
250
+ log.done(outcome.detail)
251
+ return DispatchResult(True, outcome.detail, log)
252
+ log.verify(result.detail, ok=False)
253
+
254
+ # --- recover ---------------------------------------------------------
255
+ recovery = self._recovery.recover(
256
+ action=lambda: _run().detail,
257
+ verify=lambda: self._verifier.verify(want),
258
+ window_title=outcome.window_title,
259
+ app_target=outcome.app_target,
260
+ )
261
+ if recovery.recovered:
262
+ log.recover(f"{recovery.detail} (tried: {', '.join(recovery.strategies_tried)})")
263
+ log.done(outcome.detail)
264
+ return DispatchResult(True, outcome.detail, log)
265
+
266
+ log.recover(recovery.detail, ok=False)
267
+ hint = (
268
+ f"'{label}' executed but verification failed ({result.detail}); local "
269
+ f"recovery ({', '.join(recovery.strategies_tried) or 'none'}) did not help."
270
+ )
271
+ log.done(hint, ok=False)
272
+ return DispatchResult(False, hint, log, replan_hint=hint)