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,434 @@
1
+ """Element resolver: descriptions in, coordinates out.
2
+
3
+ This is the boundary that keeps coordinates away from the AI. Every semantic UI
4
+ action (``ui_click("Sign in button")``, ``ui_type("search box", ...)``) hands
5
+ the resolver a *description*; the resolver finds the matching on-screen element
6
+ and returns its clickable point. Resolution walks a fixed strategy ladder,
7
+ cheapest and most reliable first, and stops at the first confident hit:
8
+
9
+ 1. **Accessibility tree** (UI Automation) — the primary, exact source: fuzzy
10
+ match the description against element role + name.
11
+ 2. **UIA properties** — a second UIA pass matching automation-id / value /
12
+ help-text for elements a name match missed (unnamed inputs, icon buttons).
13
+ 3. **DOM inspection** — when the target is a web page and the browser exposes a
14
+ DevTools endpoint, ask the *page* where the element is. Exact, and immune to
15
+ theme/zoom/scroll. Skipped silently when no browser is attached.
16
+ 4. **OCR** — for windows that expose no automation tree (games, custom-drawn
17
+ apps): locate the text on a screenshot and click its center.
18
+ 5. **Computer vision** — snap an OCR hit out to the clickable control that
19
+ encloses it, so the click lands on the button body, not the glyphs.
20
+ 6. **Image matching** — a caller-supplied reference image (an icon/logo with no
21
+ accessibility name), located by normalized-correlation template match.
22
+ 7. **Relative positioning** — "the field below Username", "the button next to
23
+ Search": resolve the *anchor* by the tiers above, then offset from it.
24
+ 8. **Absolute coordinates** — last resort, internal only (recovery replays a
25
+ known point); the AI never supplies coordinates.
26
+
27
+ The AI is never shown, and never supplies, the resulting coordinates. If
28
+ nothing resolves, a :class:`ResolveError` flows back so the dispatcher's
29
+ recovery engine can retry (refocus, re-snapshot) or, as a last resort, ask the
30
+ AI to replan against a fresh semantic snapshot.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from dataclasses import dataclass, field
37
+ from typing import Any
38
+
39
+ from ..ui.fuzzy import fuzzy_match
40
+
41
+ # A fuzzy score below this means "no confident match" — better to fail and let
42
+ # recovery re-snapshot than to click the wrong thing.
43
+ _MIN_SCORE = 200.0
44
+
45
+ # Role words the AI naturally appends to a description ("Save button", "search
46
+ # box"); stripped before matching so they don't fight the element's own role.
47
+ _ROLE_HINTS = {
48
+ "button": "button",
49
+ "btn": "button",
50
+ "input": "input",
51
+ "field": "input",
52
+ "box": "input",
53
+ "textbox": "input",
54
+ "link": "link",
55
+ "menu": "menu",
56
+ "menuitem": "menuitem",
57
+ "item": "listitem",
58
+ "tab": "tab",
59
+ "checkbox": "checkbox",
60
+ "check": "checkbox",
61
+ "radio": "radio",
62
+ "combobox": "combobox",
63
+ "dropdown": "combobox",
64
+ "icon": "button",
65
+ }
66
+
67
+ # Direction phrases for the relative-positioning tier, mapped to a unit vector
68
+ # in screen space (x right, y down). Longest phrases first so "to the right of"
69
+ # is matched before "right".
70
+ _DIRECTIONS: list[tuple[str, tuple[int, int]]] = [
71
+ ("to the right of", (1, 0)),
72
+ ("to the left of", (-1, 0)),
73
+ ("right of", (1, 0)),
74
+ ("left of", (-1, 0)),
75
+ ("underneath", (0, 1)),
76
+ ("beneath", (0, 1)),
77
+ ("below", (0, 1)),
78
+ ("under", (0, 1)),
79
+ ("above", (0, -1)),
80
+ ("over", (0, -1)),
81
+ ("next to", (1, 0)),
82
+ ("beside", (1, 0)),
83
+ ("near", (0, 0)),
84
+ ]
85
+
86
+
87
+ class ResolveError(Exception):
88
+ """A described element could not be located on screen."""
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class ResolvedElement:
93
+ """An element the resolver located, with the point to act on."""
94
+
95
+ x: int
96
+ y: int
97
+ role: str
98
+ name: str
99
+ score: float
100
+ # Which ladder tier produced the hit: accessibility | uia | dom | ocr | cv |
101
+ # image | relative | absolute.
102
+ source: str
103
+ # The ordered list of tiers attempted before this one succeeded (diagnostic
104
+ # trail, surfaced in logs and to recovery).
105
+ tried: list[str] = field(default_factory=list)
106
+
107
+ def describe(self) -> str:
108
+ return f'{self.role} "{self.name or "(unnamed)"}" via {self.source}'
109
+
110
+
111
+ class ElementResolver:
112
+ """Turns element descriptions into concrete screen points.
113
+
114
+ Deterministic and offline. Drivers are injectable so the resolution logic
115
+ can be unit-tested without a live desktop.
116
+ """
117
+
118
+ def __init__(self, vision: Any = None, screen: Any = None, dom: Any = None) -> None:
119
+ if vision is None:
120
+ from . import vision as vision # type: ignore
121
+ if screen is None:
122
+ from . import screen as screen # type: ignore
123
+ if dom is None:
124
+ from . import browser_cdp as dom # type: ignore
125
+ self._vision = vision
126
+ self._screen = screen
127
+ # The DOM tier's provider. Injectable so resolution can be tested
128
+ # without a live browser, and so a caller can disable the tier by
129
+ # passing a stub.
130
+ self._dom = dom
131
+
132
+ def resolve(
133
+ self,
134
+ description: str,
135
+ window_title: str | None = None,
136
+ *,
137
+ template: str | None = None,
138
+ ) -> ResolvedElement:
139
+ """Locate the element matching ``description`` and return its point.
140
+
141
+ ``template`` optionally points at a reference image for the
142
+ image-matching tier (skills supply it; the AI never does).
143
+ """
144
+ desc = (description or "").strip()
145
+ if not desc:
146
+ raise ResolveError("No element description was given.")
147
+
148
+ tried: list[str] = []
149
+
150
+ # Snapshot the accessibility tree once; tiers 1–2 reuse it.
151
+ try:
152
+ _title, elements = self._vision.snapshot(window_title)
153
+ except Exception:
154
+ elements = []
155
+
156
+ # A spatial phrase ("the field below Username") is an explicit
157
+ # instruction, not a fallback: honour it before fuzzy matching, which
158
+ # would otherwise match the *anchor* itself and click the wrong control.
159
+ # The anchor is still located via the accessibility tiers below, so
160
+ # mechanism preference is preserved.
161
+ anchor_desc, vector = self._split_relative(desc)
162
+ if anchor_desc is not None:
163
+ tried.append("relative")
164
+ rel = self._resolve_relative(anchor_desc, vector, elements, window_title, tried)
165
+ if rel is not None:
166
+ return rel
167
+
168
+ # 1) Accessibility — exact, preferred.
169
+ tried.append("accessibility")
170
+ hit = self._match_elements(desc, elements)
171
+ if hit is not None:
172
+ return self._tag(hit, tried)
173
+
174
+ # 2) UIA properties — automation-id / value / help-text.
175
+ tried.append("uia")
176
+ hit = self._match_uia_properties(desc, elements)
177
+ if hit is not None:
178
+ return self._tag(hit, tried)
179
+
180
+ # 3) DOM — ask the page itself, when one is attached.
181
+ tried.append("dom")
182
+ dom_hit = self._locate_dom(desc)
183
+ if dom_hit is not None:
184
+ return self._tag(dom_hit, tried)
185
+
186
+ # 4) OCR — text on a screenshot (+ 5) CV refinement of the hit box).
187
+ tried.append("ocr")
188
+ ocr_box = self._locate_ocr(desc, window_title)
189
+ if ocr_box is not None:
190
+ tried.append("cv")
191
+ box, source = self._refine_with_cv(ocr_box, window_title)
192
+ left, top, width, height = box
193
+ return ResolvedElement(
194
+ x=int(left + width // 2), y=int(top + height // 2),
195
+ role="text", name=desc, score=_MIN_SCORE, source=source, tried=list(tried),
196
+ )
197
+
198
+ # 6) Image matching — a caller-supplied reference image.
199
+ if template:
200
+ tried.append("image")
201
+ img_hit = self._match_template(template, window_title)
202
+ if img_hit is not None:
203
+ return self._tag(img_hit, tried)
204
+
205
+ raise ResolveError(
206
+ f'Could not find "{desc}" on screen (tried: {", ".join(tried)}). '
207
+ "Take a fresh computer_see snapshot and describe a visible element."
208
+ )
209
+
210
+ def resolve_point(self, x: int, y: int) -> ResolvedElement:
211
+ """Absolute-coordinate tier — internal last resort (never AI-driven)."""
212
+ return ResolvedElement(
213
+ x=int(x), y=int(y), role="point", name=f"({x}, {y})",
214
+ score=_MIN_SCORE, source="absolute", tried=["absolute"],
215
+ )
216
+
217
+ # --- accessibility matching ---------------------------------------------
218
+ def _match_elements(self, desc: str, elements: list) -> ResolvedElement | None:
219
+ if not elements:
220
+ return None
221
+ query, wanted_role = self._split_role_hint(desc)
222
+ best: ResolvedElement | None = None
223
+ for el in elements:
224
+ # Score the query against the element name (primary) and, more
225
+ # weakly, its role, so "search box" still finds an unnamed input.
226
+ name_score = fuzzy_match(query, el.name).score if query else 0.0
227
+ role_score = 0.0
228
+ if wanted_role and el.role == wanted_role:
229
+ role_score = 150.0
230
+ elif wanted_role and el.role != wanted_role:
231
+ role_score = -60.0 # penalise a role mismatch, don't exclude
232
+ score = name_score + role_score
233
+ # An unnamed element that matches only by role still counts when
234
+ # the description was essentially just a role ("the input").
235
+ if not query and wanted_role and el.role == wanted_role:
236
+ score = _MIN_SCORE + 1.0
237
+ if not getattr(el, "enabled", True):
238
+ score -= 80.0
239
+ if best is None or score > best.score:
240
+ best = ResolvedElement(
241
+ x=el.x, y=el.y, role=el.role, name=el.name,
242
+ score=score, source="accessibility",
243
+ )
244
+ if best is not None and best.score >= _MIN_SCORE:
245
+ return best
246
+ return None
247
+
248
+ def _match_uia_properties(self, desc: str, elements: list) -> ResolvedElement | None:
249
+ """Second UIA pass: match automation-id / value / help-text.
250
+
251
+ Buttons drawn with only an icon, and inputs whose accessible name is
252
+ empty, often still carry an AutomationId ("searchBox"), a Value, or
253
+ help text. Name-based matching misses those; this catches them.
254
+ Defensive: elements without these attributes (test fakes, minimal
255
+ drivers) simply contribute nothing.
256
+ """
257
+ query, _role = self._split_role_hint(desc)
258
+ needle = " ".join(query.lower().split())
259
+ if not needle:
260
+ return None
261
+ best: ResolvedElement | None = None
262
+ for el in elements:
263
+ haystacks = [
264
+ str(getattr(el, "automation_id", "") or ""),
265
+ str(getattr(el, "value", "") or ""),
266
+ str(getattr(el, "help_text", "") or ""),
267
+ ]
268
+ score = 0.0
269
+ for hay in haystacks:
270
+ if not hay:
271
+ continue
272
+ hay_norm = " ".join(hay.lower().split())
273
+ if needle in hay_norm or hay_norm in needle:
274
+ score = max(score, _MIN_SCORE + 20.0)
275
+ else:
276
+ score = max(score, fuzzy_match(query, hay).score)
277
+ if not getattr(el, "enabled", True):
278
+ score -= 80.0
279
+ if score >= _MIN_SCORE and (best is None or score > best.score):
280
+ best = ResolvedElement(
281
+ x=el.x, y=el.y, role=getattr(el, "role", "element"),
282
+ name=getattr(el, "name", "") or needle, score=score, source="uia",
283
+ )
284
+ return best
285
+
286
+ def _split_role_hint(self, desc: str) -> tuple[str, str | None]:
287
+ """Peel a trailing role word off the description, if present."""
288
+ words = desc.split()
289
+ if len(words) >= 2:
290
+ role = _ROLE_HINTS.get(words[-1].lower())
291
+ if role is not None:
292
+ return " ".join(words[:-1]).strip(), role
293
+ # A one-word role-only description ("button") still carries the hint.
294
+ if len(words) == 1:
295
+ role = _ROLE_HINTS.get(words[0].lower())
296
+ if role is not None:
297
+ return "", role
298
+ return desc, None
299
+
300
+ # --- DOM -----------------------------------------------------------------
301
+ def _locate_dom(self, desc: str) -> ResolvedElement | None:
302
+ """Ask the attached web page where the described element is.
303
+
304
+ Returns None — cheaply and silently — whenever no browser is attached,
305
+ which is the common case for native-app automation. The page reports
306
+ screen coordinates directly, so no conversion is needed here.
307
+ """
308
+ dom = self._dom
309
+ if dom is None:
310
+ return None
311
+ try:
312
+ if not dom.is_available():
313
+ return None
314
+ box = dom.locate_text(self._strip_role(desc))
315
+ except Exception:
316
+ return None
317
+ if box is None:
318
+ return None
319
+ return ResolvedElement(
320
+ x=int(box.x), y=int(box.y), role="dom",
321
+ name=getattr(box, "text", "") or desc,
322
+ score=_MIN_SCORE, source="dom",
323
+ )
324
+
325
+ def _strip_role(self, desc: str) -> str:
326
+ """Drop a trailing role word — the DOM matches on visible text."""
327
+ query, _role = self._split_role_hint(desc)
328
+ return query or desc
329
+
330
+ # --- OCR + CV ------------------------------------------------------------
331
+ def _locate_ocr(self, desc: str, window_title: str | None) -> tuple[int, int, int, int] | None:
332
+ """Find text on a screenshot; return its (left, top, width, height)."""
333
+ locate = getattr(self._vision, "ocr_locate", None)
334
+ if locate is None or self._screen is None or not self._vision.ocr_available():
335
+ return None
336
+ try:
337
+ path = self._screen.capture()
338
+ return locate(path, desc) # (left, top, width, height) or None
339
+ except Exception:
340
+ return None
341
+
342
+ def _refine_with_cv(
343
+ self, box: tuple[int, int, int, int], window_title: str | None
344
+ ) -> tuple[tuple[int, int, int, int], str]:
345
+ """Snap an OCR box to its enclosing control via CV; report the tier."""
346
+ refine = getattr(self._vision, "refine_region_cv", None)
347
+ if refine is None or self._screen is None or not getattr(self._vision, "cv_available", lambda: False)():
348
+ return box, "ocr"
349
+ try:
350
+ path = self._screen.capture()
351
+ refined = refine(path, box)
352
+ return (refined, "cv") if refined != box else (box, "ocr")
353
+ except Exception:
354
+ return box, "ocr"
355
+
356
+ # --- image matching ------------------------------------------------------
357
+ def _match_template(self, template: str, window_title: str | None) -> ResolvedElement | None:
358
+ locate = getattr(self._vision, "template_locate", None)
359
+ if locate is None or self._screen is None:
360
+ return None
361
+ try:
362
+ path = self._screen.capture()
363
+ box = locate(path, template)
364
+ except Exception:
365
+ return None
366
+ if not box:
367
+ return None
368
+ left, top, width, height = box
369
+ return ResolvedElement(
370
+ x=int(left + width // 2), y=int(top + height // 2),
371
+ role="image", name=str(template), score=_MIN_SCORE, source="image",
372
+ )
373
+
374
+ # --- relative positioning ------------------------------------------------
375
+ def _split_relative(self, desc: str) -> tuple[str | None, tuple[int, int]]:
376
+ """Split "<target> <direction> <anchor>" into (anchor, unit vector).
377
+
378
+ Returns (None, (0, 0)) when the description carries no spatial phrase.
379
+ """
380
+ low = desc.lower()
381
+ for phrase, vector in _DIRECTIONS:
382
+ m = re.search(rf"\b{re.escape(phrase)}\b", low)
383
+ if m:
384
+ anchor = desc[m.end():].strip(" .,:") or desc[: m.start()].strip(" .,:")
385
+ if anchor:
386
+ return anchor, vector
387
+ return None, (0, 0)
388
+
389
+ def _resolve_relative(
390
+ self,
391
+ anchor_desc: str,
392
+ vector: tuple[int, int],
393
+ elements: list,
394
+ window_title: str | None,
395
+ tried: list[str],
396
+ ) -> ResolvedElement | None:
397
+ """Resolve the anchor element, then offset by the direction vector."""
398
+ anchor = self._match_elements(anchor_desc, elements)
399
+ if anchor is None:
400
+ anchor = self._match_uia_properties(anchor_desc, elements)
401
+ if anchor is None:
402
+ return None
403
+ # Offset by roughly the element's own extent so we land on the adjacent
404
+ # control, not still inside the anchor. Fall back to a sensible step.
405
+ el = self._element_named(elements, anchor.name)
406
+ step_x = (getattr(el, "width", 0) or 120) if vector[0] else 0
407
+ step_y = (getattr(el, "height", 0) or 32) if vector[1] else 0
408
+ # A little breathing room past the edge of the anchor.
409
+ gap_x = int(vector[0] * (step_x // 2 + 16))
410
+ gap_y = int(vector[1] * (step_y // 2 + 16))
411
+ return ResolvedElement(
412
+ x=anchor.x + gap_x, y=anchor.y + gap_y,
413
+ role="relative", name=f"{_vec_name(vector)} {anchor.name}".strip(),
414
+ score=_MIN_SCORE, source="relative", tried=list(tried),
415
+ )
416
+
417
+ @staticmethod
418
+ def _element_named(elements: list, name: str):
419
+ for el in elements:
420
+ if getattr(el, "name", None) == name:
421
+ return el
422
+ return None
423
+
424
+ @staticmethod
425
+ def _tag(hit: ResolvedElement, tried: list[str]) -> ResolvedElement:
426
+ hit.tried = list(tried)
427
+ return hit
428
+
429
+
430
+ def _vec_name(vector: tuple[int, int]) -> str:
431
+ return {
432
+ (1, 0): "right of", (-1, 0): "left of",
433
+ (0, 1): "below", (0, -1): "above", (0, 0): "near",
434
+ }.get(vector, "near")
@@ -0,0 +1,130 @@
1
+ """Screen driver: screenshots, resolution, and multi-monitor geometry.
2
+
3
+ Uses ``mss`` for capture (fast, multi-monitor aware) and Pillow only to
4
+ encode PNGs. All imports are lazy so the rest of Seed Code loads without the
5
+ desktop extra installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from ..utils.helpers import app_dir
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class MonitorInfo:
19
+ """One physical monitor in virtual-desktop coordinates."""
20
+
21
+ index: int # 1-based, matching mss numbering (0 is the combined desktop)
22
+ left: int
23
+ top: int
24
+ width: int
25
+ height: int
26
+ primary: bool
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ScreenGeometry:
31
+ """The combined virtual desktop plus its monitors."""
32
+
33
+ left: int
34
+ top: int
35
+ width: int
36
+ height: int
37
+ monitors: list[MonitorInfo]
38
+
39
+ def contains(self, x: int, y: int) -> bool:
40
+ return (
41
+ self.left <= x < self.left + self.width
42
+ and self.top <= y < self.top + self.height
43
+ )
44
+
45
+
46
+ def screenshots_dir() -> Path:
47
+ """Directory screenshots are saved to (created on demand)."""
48
+ path = app_dir() / "screenshots"
49
+ path.mkdir(parents=True, exist_ok=True)
50
+ return path
51
+
52
+
53
+ def geometry() -> ScreenGeometry:
54
+ """Current virtual-desktop geometry (all monitors)."""
55
+ import mss
56
+
57
+ with mss.mss() as sct:
58
+ combined = sct.monitors[0]
59
+ monitors = [
60
+ MonitorInfo(
61
+ index=i,
62
+ left=m["left"],
63
+ top=m["top"],
64
+ width=m["width"],
65
+ height=m["height"],
66
+ primary=(m["left"] == 0 and m["top"] == 0),
67
+ )
68
+ for i, m in enumerate(sct.monitors[1:], start=1)
69
+ ]
70
+ return ScreenGeometry(
71
+ left=combined["left"],
72
+ top=combined["top"],
73
+ width=combined["width"],
74
+ height=combined["height"],
75
+ monitors=monitors,
76
+ )
77
+
78
+
79
+ def capture(
80
+ region: tuple[int, int, int, int] | None = None,
81
+ monitor: int | None = None,
82
+ save_to: Path | None = None,
83
+ ) -> Path:
84
+ """Capture the screen to a PNG file and return its path.
85
+
86
+ ``region`` is (left, top, width, height) in virtual-desktop coordinates;
87
+ ``monitor`` is a 1-based monitor index; neither means the whole desktop.
88
+ """
89
+ import mss
90
+ import mss.tools
91
+
92
+ with mss.mss() as sct:
93
+ if region is not None:
94
+ left, top, width, height = region
95
+ grab_area = {"left": left, "top": top, "width": width, "height": height}
96
+ elif monitor is not None:
97
+ if not 1 <= monitor < len(sct.monitors):
98
+ raise ValueError(
99
+ f"Monitor {monitor} does not exist (found {len(sct.monitors) - 1})."
100
+ )
101
+ grab_area = sct.monitors[monitor]
102
+ else:
103
+ grab_area = sct.monitors[0]
104
+ shot = sct.grab(grab_area)
105
+
106
+ if save_to is None:
107
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
108
+ save_to = screenshots_dir() / f"screenshot-{stamp}.png"
109
+ save_to.parent.mkdir(parents=True, exist_ok=True)
110
+ mss.tools.to_png(shot.rgb, shot.size, output=str(save_to))
111
+ return save_to
112
+
113
+
114
+ def encode_png_base64(path: Path, max_dim: int = 1568) -> str:
115
+ """Base64-encode a screenshot PNG, downscaling large captures first.
116
+
117
+ Vision models cap useful input resolution; downscaling keeps payloads
118
+ small without losing the layout the model needs.
119
+ """
120
+ import base64
121
+ import io
122
+
123
+ from PIL import Image
124
+
125
+ with Image.open(path) as img:
126
+ if max(img.size) > max_dim:
127
+ img.thumbnail((max_dim, max_dim))
128
+ buffer = io.BytesIO()
129
+ img.save(buffer, format="PNG")
130
+ return base64.b64encode(buffer.getvalue()).decode("ascii")