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,100 @@
1
+ """Semantic element actions: id in, verified action out.
2
+
3
+ The bridge between the Screen Intelligence Engine (:mod:`.screen_state`) and
4
+ the low-level drivers. The AI never supplies coordinates here; it supplies a
5
+ stable element id from a previous query, and this module:
6
+
7
+ 1. resolves the id through the engine (cache → fresh validation),
8
+ 2. re-locates the element if the UI changed (bounded stale-requery),
9
+ 3. performs the action through the controller's guarded drivers,
10
+ 4. returns a compact, model-readable result with post-action state.
11
+
12
+ Failures raise structured operator errors (ELEMENT_NOT_FOUND,
13
+ STALE_ELEMENT) that flow back to the planner like any other tool result.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from ..core.errors import ElementNotFoundError, StaleElementError
21
+ from ..core.limits import MAX_STALE_REQUERY
22
+ from .screen_state import Element, ScreenEngine, get_screen_engine
23
+
24
+
25
+ class SemanticActions:
26
+ """Element-id-addressed actions over the screen engine + controller."""
27
+
28
+ def __init__(self, engine: ScreenEngine | None = None, controller: Any = None) -> None:
29
+ self._engine = engine
30
+ self._controller = controller
31
+
32
+ # --- plumbing -------------------------------------------------------------
33
+ @property
34
+ def engine(self) -> ScreenEngine:
35
+ if self._engine is None:
36
+ self._engine = get_screen_engine()
37
+ return self._engine
38
+
39
+ @property
40
+ def controller(self) -> Any:
41
+ if self._controller is None:
42
+ from .controller import ComputerController
43
+
44
+ self._controller = ComputerController()
45
+ return self._controller
46
+
47
+ def _resolve(self, element_id: str) -> Element:
48
+ """Resolve an id to a fresh element with bounded stale-requery.
49
+
50
+ A stale id (UI changed between query and action) triggers up to
51
+ ``MAX_STALE_REQUERY`` re-locate cycles by the element's identity
52
+ (role + name + automation id) before giving up with STALE_ELEMENT.
53
+ """
54
+ try:
55
+ return self.engine.get_element(element_id)
56
+ except StaleElementError as exc:
57
+ cached = self.engine._by_id.get((element_id or "").strip().lower())
58
+ identity = getattr(cached, "name", "") or element_id
59
+ for _ in range(MAX_STALE_REQUERY):
60
+ try:
61
+ return self.engine.find_element(identity, fresh=True)
62
+ except ElementNotFoundError:
63
+ continue
64
+ raise exc
65
+
66
+ # --- actions ---------------------------------------------------------------
67
+ def click(self, element_id: str, *, button: str = "left", double: bool = False) -> str:
68
+ el = self._resolve(element_id)
69
+ self.controller.mouse_click(el.x, el.y, button=button, double=double)
70
+ return f"clicked {el.describe()}"
71
+
72
+ def type_into(self, element_id: str, text: str, *, secret: bool = False) -> str:
73
+ el = self._resolve(element_id)
74
+ self.controller.mouse_click(el.x, el.y) # focus first
75
+ self.controller.type_text(text)
76
+ shown = "•" * len(text) if secret else text
77
+ return f"typed '{shown}' into {el.describe()}"
78
+
79
+ def focus(self, element_id: str) -> str:
80
+ el = self._resolve(element_id)
81
+ self.controller.mouse_click(el.x, el.y)
82
+ return f"focused {el.describe()}"
83
+
84
+ def press(self, element_id: str, keys: list[str]) -> str:
85
+ """Focus the element, then send a (guarded) hotkey to it."""
86
+ el = self._resolve(element_id)
87
+ self.controller.mouse_click(el.x, el.y)
88
+ self.controller.hotkey([str(k) for k in keys])
89
+ return f"pressed {'+'.join(keys)} on {el.describe()}"
90
+
91
+ def select(self, element_id: str) -> str:
92
+ """Select a combobox/list option by clicking it (checkbox/radio included)."""
93
+ return self.click(element_id)
94
+
95
+ def check(self, element_id: str, *, checked: bool = True) -> str:
96
+ el = self._resolve(element_id)
97
+ if el.checked is checked:
98
+ return f"{el.describe()} already {'checked' if checked else 'unchecked'}"
99
+ self.controller.mouse_click(el.x, el.y)
100
+ return f"{'checked' if checked else 'unchecked'} {el.describe()}"
@@ -0,0 +1,139 @@
1
+ """Skill engine: high-level, deterministic procedures the AI selects by name.
2
+
3
+ A *skill* is Seed Code's unit of knowledge — "launch an app", "search
4
+ YouTube", "create a Python project". The AI planner chooses a skill and
5
+ supplies parameters; the skill expands into a fixed sequence of deterministic
6
+ Computer Engine operations. The AI never sees those inner steps and never
7
+ produces coordinates, keystrokes, or click sequences.
8
+
9
+ Each skill declares:
10
+
11
+ * ``name`` / ``summary`` / ``params`` — its catalog entry (what the AI reads).
12
+ * ``level`` — the :class:`PermissionLevel` required to run it.
13
+ * ``run(ctx, params) -> Outcome`` — the deterministic body, given a
14
+ :class:`SkillContext` that exposes the controller, element resolver, state
15
+ manager and permission manager.
16
+
17
+ Skills raise :class:`SkillError` on unrecoverable failure; they return an
18
+ :class:`Outcome` carrying the expectation the dispatcher will verify. Skills
19
+ contain no AI calls and work offline.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Callable
26
+
27
+ from ..tools.permissions import PermissionLevel
28
+
29
+
30
+ class SkillError(Exception):
31
+ """A skill could not complete (after its own inline handling)."""
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class Outcome:
36
+ """What a skill produced, plus how to verify it succeeded."""
37
+
38
+ detail: str
39
+ expected: dict[str, Any] | None = None
40
+ # Hints the dispatcher passes to recovery if verification fails.
41
+ window_title: str | None = None
42
+ app_target: str | None = None
43
+
44
+
45
+ @dataclass
46
+ class SkillContext:
47
+ """Everything a skill body may use — all deterministic, no AI."""
48
+
49
+ controller: Any # ComputerController: mouse/keyboard/windows/...
50
+ resolver: Any # ElementResolver: description -> point
51
+ state: Any # StateManager: engine memory
52
+ permissions: Any # PermissionManager: require()/confirm_action()
53
+
54
+ def click_described(self, description: str, *, button: str = "left", double: bool = False) -> str:
55
+ """Resolve a described element and click it — the resolver owns coords."""
56
+ hit = self.resolver.resolve(description)
57
+ self.controller.mouse_click(hit.x, hit.y, button, double)
58
+ self.state.record_action(f"clicked {description}")
59
+ return f"clicked {hit.describe()}"
60
+
61
+ def type_into(self, description: str, text: str, *, secret: bool = False) -> str:
62
+ """Focus a described field, then type into it."""
63
+ hit = self.resolver.resolve(description)
64
+ self.controller.mouse_click(hit.x, hit.y)
65
+ self.controller.type_text(text)
66
+ self.state.record_action(f"typed into {description}")
67
+ shown = "•" * len(text) if secret else text
68
+ return f"typed '{shown}' into {hit.describe()}"
69
+
70
+
71
+ # A skill body: (ctx, params) -> Outcome.
72
+ SkillBody = Callable[[SkillContext, dict[str, Any]], Outcome]
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class Skill:
77
+ """A named, permissioned, deterministic procedure."""
78
+
79
+ name: str
80
+ summary: str
81
+ level: PermissionLevel
82
+ body: SkillBody
83
+ params: dict[str, str] = field(default_factory=dict)
84
+ sensitive: bool = False # requires per-action confirmation even if level ok
85
+
86
+ def run(self, ctx: SkillContext, params: dict[str, Any]) -> Outcome:
87
+ # Enforce the skill's permission floor before doing anything.
88
+ ctx.permissions.require(self.level, f"skill '{self.name}'")
89
+ return self.body(ctx, params or {})
90
+
91
+
92
+ class SkillRegistry:
93
+ """The catalog of known skills, keyed by name."""
94
+
95
+ def __init__(self) -> None:
96
+ self._skills: dict[str, Skill] = {}
97
+
98
+ def register(self, skill: Skill) -> Skill:
99
+ self._skills[skill.name] = skill
100
+ return skill
101
+
102
+ def get(self, name: str) -> Skill | None:
103
+ return self._skills.get(str(name).strip().lower())
104
+
105
+ def all(self) -> list[Skill]:
106
+ return sorted(self._skills.values(), key=lambda s: s.name)
107
+
108
+ def manifest(self, max_level: PermissionLevel | None = None) -> str:
109
+ """Human/AI-readable catalog, optionally hiding skills above a level."""
110
+ lines = []
111
+ for skill in self.all():
112
+ if max_level is not None and skill.level > max_level:
113
+ continue
114
+ args = ", ".join(skill.params) if skill.params else "no params"
115
+ lines.append(f"- {skill.name}({args}) — {skill.summary}")
116
+ return "\n".join(lines)
117
+
118
+
119
+ # Module-level catalog populated by ``catalog.py`` at import time.
120
+ REGISTRY = SkillRegistry()
121
+
122
+
123
+ def skill(name, summary, level, params=None, sensitive=False):
124
+ """Decorator: register a function as a skill body."""
125
+
126
+ def deco(fn: SkillBody) -> SkillBody:
127
+ REGISTRY.register(
128
+ Skill(
129
+ name=name.strip().lower(),
130
+ summary=summary,
131
+ level=level,
132
+ body=fn,
133
+ params=params or {},
134
+ sensitive=sensitive,
135
+ )
136
+ )
137
+ return fn
138
+
139
+ return deco
@@ -0,0 +1,199 @@
1
+ """Computer Engine execution state.
2
+
3
+ The engine keeps its own memory of the machine so the AI never has to re-ask
4
+ for it: what app is focused, where the pointer is, what's on the clipboard,
5
+ which directory the terminal is in, and a short trail of the last actions. The
6
+ :class:`StateManager` refreshes the live parts (focused window, pointer) on
7
+ demand from the drivers and records the rest as the dispatcher executes skills.
8
+
9
+ This module is deterministic and offline — it only reads driver state and holds
10
+ values; it never talks to an AI provider.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ # Keep the action trail short; it exists to give the AI recent context, not a
20
+ # full audit log (that's the logbook's job).
21
+ _MAX_TRAIL = 12
22
+
23
+
24
+ @dataclass
25
+ class ComputerState:
26
+ """A snapshot of what the Computer Engine believes about the machine."""
27
+
28
+ focused_window: str | None = None
29
+ focused_app: str | None = None
30
+ pointer: tuple[int, int] | None = None
31
+ clipboard_preview: str | None = None
32
+ terminal_cwd: str | None = None
33
+ current_project: str | None = None
34
+ open_browser_url: str | None = None
35
+ modifiers_held: tuple[str, ...] = ()
36
+ # Titles of the apps currently holding a visible window, refreshed from the
37
+ # window driver so the planner knows what is already open (and the engine
38
+ # can focus instead of launching a duplicate).
39
+ running_apps: tuple[str, ...] = ()
40
+ # The high-level task the engine is currently executing, set by the engine
41
+ # when a skill is dispatched — gives the AI continuity across steps.
42
+ current_task: str | None = None
43
+ recent_actions: list[str] = field(default_factory=list)
44
+
45
+ @property
46
+ def previous_action(self) -> str | None:
47
+ """The last action the engine performed, if any."""
48
+ return self.recent_actions[-1] if self.recent_actions else None
49
+
50
+ def describe(self) -> str:
51
+ """Compact, model-facing rendering of the current state."""
52
+ rows: list[tuple[str, str]] = []
53
+ if self.current_task:
54
+ rows.append(("task", self.current_task))
55
+ if self.focused_app:
56
+ rows.append(("app", self.focused_app))
57
+ if self.focused_window:
58
+ rows.append(("window", self.focused_window))
59
+ if self.pointer is not None:
60
+ rows.append(("pointer", f"({self.pointer[0]}, {self.pointer[1]})"))
61
+ if self.clipboard_preview:
62
+ rows.append(("clipboard", self.clipboard_preview))
63
+ if self.terminal_cwd:
64
+ rows.append(("terminal", self.terminal_cwd))
65
+ if self.current_project:
66
+ rows.append(("project", self.current_project))
67
+ if self.open_browser_url:
68
+ rows.append(("browser", self.open_browser_url))
69
+ if self.modifiers_held:
70
+ rows.append(("modifiers", "+".join(self.modifiers_held)))
71
+ if self.running_apps:
72
+ rows.append(("open apps", ", ".join(self.running_apps[:8])))
73
+ if not rows and not self.recent_actions:
74
+ return "Computer state: (nothing observed yet)"
75
+ lines = ["Computer state:"]
76
+ lines += [f" {label}: {value}" for label, value in rows]
77
+ if self.recent_actions:
78
+ lines.append(" recent:")
79
+ lines += [f" - {a}" for a in self.recent_actions[-5:]]
80
+ return "\n".join(lines)
81
+
82
+
83
+ class StateManager:
84
+ """Owns the engine's :class:`ComputerState` and keeps it fresh.
85
+
86
+ The live bits (focused window, pointer position) are pulled from the driver
87
+ modules on :meth:`refresh`; everything else is recorded by the dispatcher as
88
+ skills run so the AI gets continuity across steps for free.
89
+ """
90
+
91
+ def __init__(self, windows: Any = None, mouse: Any = None) -> None:
92
+ # Drivers are injectable so tests can drive the manager without a real
93
+ # desktop; lazy-imported here to keep import order clean off-win32.
94
+ if windows is None:
95
+ from . import windows as windows # type: ignore
96
+ if mouse is None:
97
+ from . import mouse as mouse # type: ignore
98
+ self._windows = windows
99
+ self._mouse = mouse
100
+ self.state = ComputerState()
101
+
102
+ def refresh(self) -> ComputerState:
103
+ """Re-read the live parts of the state from the drivers.
104
+
105
+ Best-effort: a driver that raises (no desktop, flaky COM) leaves the
106
+ previous value in place rather than crashing the turn.
107
+ """
108
+ try:
109
+ active = self._windows.active_window()
110
+ if active is not None:
111
+ self.state.focused_window = active.title or None
112
+ self.state.focused_app = _app_from_title(active.title)
113
+ except Exception:
114
+ pass
115
+ try:
116
+ pos = self._mouse.position()
117
+ if pos is not None:
118
+ self.state.pointer = (int(pos[0]), int(pos[1]))
119
+ except Exception:
120
+ pass
121
+ try:
122
+ # Which apps already have a window: lets the planner focus an open
123
+ # app instead of launching a second copy.
124
+ apps: list[str] = []
125
+ for win in self._windows.list_windows():
126
+ app = _app_from_title(getattr(win, "title", None))
127
+ if app and app not in apps:
128
+ apps.append(app)
129
+ if apps:
130
+ self.state.running_apps = tuple(apps)
131
+ except Exception:
132
+ pass
133
+ try:
134
+ url = self._browser_url()
135
+ if url:
136
+ self.state.open_browser_url = url
137
+ except Exception:
138
+ pass
139
+ return self.state
140
+
141
+ def _browser_url(self) -> str | None:
142
+ """The last URL the default browser was sent to (best-effort)."""
143
+ try:
144
+ from . import browser as browser_driver
145
+
146
+ return browser_driver.current_url()
147
+ except Exception:
148
+ return None
149
+
150
+ # --- recording (called by the dispatcher / skills) ----------------------
151
+ def record_action(self, description: str) -> None:
152
+ trail = self.state.recent_actions
153
+ trail.append(description)
154
+ if len(trail) > _MAX_TRAIL:
155
+ del trail[: len(trail) - _MAX_TRAIL]
156
+
157
+ def set_clipboard(self, text: str | None) -> None:
158
+ if not text:
159
+ self.state.clipboard_preview = None
160
+ else:
161
+ preview = text.strip().replace("\n", " ")
162
+ self.state.clipboard_preview = preview[:60] + ("…" if len(preview) > 60 else "")
163
+
164
+ def set_terminal_cwd(self, cwd: str | None) -> None:
165
+ self.state.terminal_cwd = cwd
166
+
167
+ def set_project(self, project: str | None) -> None:
168
+ self.state.current_project = project
169
+
170
+ def set_browser_url(self, url: str | None) -> None:
171
+ self.state.open_browser_url = url
172
+
173
+ def set_task(self, task: str | None) -> None:
174
+ """Record the high-level task the engine is currently executing."""
175
+ self.state.current_task = task
176
+
177
+ def set_modifiers(self, modifiers: "tuple[str, ...] | list[str]") -> None:
178
+ """Record which modifier keys the engine is currently holding down."""
179
+ self.state.modifiers_held = tuple(str(m).lower() for m in modifiers)
180
+
181
+ def note_focus(self, window_title: str | None) -> None:
182
+ """Record a focus change the driver just performed."""
183
+ if window_title:
184
+ self.state.focused_window = window_title
185
+ self.state.focused_app = _app_from_title(window_title)
186
+
187
+
188
+ def _app_from_title(title: str | None) -> str | None:
189
+ """Best-effort app name from a window title.
190
+
191
+ Windows titles are usually ``Document — App`` or ``Document - App``; the
192
+ trailing segment is the app. Falls back to the whole title.
193
+ """
194
+ if not title:
195
+ return None
196
+ for sep in (" — ", " - ", " – "):
197
+ if sep in title:
198
+ return title.rsplit(sep, 1)[-1].strip() or title.strip()
199
+ return title.strip()
@@ -0,0 +1,177 @@
1
+ """Verification engine: never trust an action without checking its result.
2
+
3
+ After the Computer Engine performs an action, the verifier confirms the world
4
+ actually changed the way the skill expected — a window appeared, an element is
5
+ present, text is on screen, a file exists, a process is running. Verification
6
+ is deterministic and offline; it reads driver/OS state and returns a
7
+ :class:`VerifyResult` the dispatcher uses to decide success vs. recovery.
8
+
9
+ Expectations are plain data (a dict the skill or the AI supplies as
10
+ ``expected``), so the AI can state *what* success looks like without ever
11
+ touching *how* it is checked.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class VerifyResult:
24
+ """Outcome of a verification check."""
25
+
26
+ ok: bool
27
+ detail: str
28
+
29
+ def __bool__(self) -> bool: # lets callers write ``if verify(...):``
30
+ return self.ok
31
+
32
+
33
+ # Supported expectation kinds. An ``expected`` dict names one of these plus its
34
+ # argument, e.g. {"window": "Notepad"} or {"file_exists": "C:/x/out.txt"}.
35
+ _KINDS = (
36
+ "window", "window_gone", "element", "text", "file_exists",
37
+ "folder_exists", "process", "browser_url",
38
+ )
39
+
40
+
41
+ class VerificationEngine:
42
+ """Checks expected outcomes against live machine state."""
43
+
44
+ # How long to keep re-checking a not-yet-true expectation before failing;
45
+ # UI and windows settle asynchronously, so a single check is too eager.
46
+ def __init__(
47
+ self,
48
+ vision: Any = None,
49
+ windows: Any = None,
50
+ browser: Any = None,
51
+ timeout_s: float = 4.0,
52
+ poll_s: float = 0.3,
53
+ ) -> None:
54
+ if vision is None:
55
+ from . import vision as vision # type: ignore
56
+ if windows is None:
57
+ from . import windows as windows # type: ignore
58
+ self._vision = vision
59
+ self._windows = windows
60
+ # Browser is optional (selenium may be absent); resolve lazily on use.
61
+ self._browser = browser
62
+ self._timeout_s = timeout_s
63
+ self._poll_s = poll_s
64
+
65
+ def verify(self, expected: dict[str, Any] | None) -> VerifyResult:
66
+ """Check an ``expected`` outcome dict; no expectation ⇒ pass.
67
+
68
+ Polls until the expectation holds or the timeout elapses, so a window
69
+ or element that appears a moment after the action still verifies.
70
+ """
71
+ if not expected:
72
+ return VerifyResult(True, "no expectation to verify")
73
+ kind = next((k for k in _KINDS if k in expected), None)
74
+ if kind is None:
75
+ return VerifyResult(True, f"unknown expectation {list(expected)}; skipped")
76
+ arg = expected[kind]
77
+
78
+ deadline = time.monotonic() + self._timeout_s
79
+ last = ""
80
+ while True:
81
+ ok, last = self._check(kind, arg)
82
+ if ok:
83
+ return VerifyResult(True, last)
84
+ if time.monotonic() >= deadline:
85
+ return VerifyResult(False, last)
86
+ time.sleep(self._poll_s)
87
+
88
+ # --- individual checks ---------------------------------------------------
89
+ def _check(self, kind: str, arg: Any) -> tuple[bool, str]:
90
+ try:
91
+ handler = getattr(self, f"_check_{kind}")
92
+ except AttributeError:
93
+ return True, f"no checker for {kind}"
94
+ try:
95
+ return handler(arg)
96
+ except Exception as exc:
97
+ return False, f"{kind} check errored: {exc}"
98
+
99
+ def _check_window(self, title: str) -> tuple[bool, str]:
100
+ want = str(title).strip().lower()
101
+ for w in self._windows.list_windows():
102
+ if want in w.title.lower():
103
+ return True, f'window "{w.title}" present'
104
+ return False, f'no window matching "{title}"'
105
+
106
+ def _check_window_gone(self, title: str) -> tuple[bool, str]:
107
+ want = str(title).strip().lower()
108
+ present = [w for w in self._windows.list_windows() if want in w.title.lower()]
109
+ if present:
110
+ return False, f'window "{title}" still open'
111
+ return True, f'window "{title}" closed'
112
+
113
+ def _check_element(self, description: str) -> tuple[bool, str]:
114
+ from .resolver import ElementResolver, ResolveError
115
+
116
+ try:
117
+ hit = ElementResolver(vision=self._vision).resolve(str(description))
118
+ return True, f"element present: {hit.describe()}"
119
+ except ResolveError as exc:
120
+ return False, str(exc)
121
+
122
+ def _check_text(self, text: str) -> tuple[bool, str]:
123
+ want = str(text).strip().lower()
124
+ try:
125
+ _title, elements = self._vision.snapshot()
126
+ except Exception:
127
+ elements = []
128
+ for el in elements:
129
+ if want in (el.name or "").lower():
130
+ return True, f'text "{text}" visible'
131
+ return False, f'text "{text}" not found on screen'
132
+
133
+ def _check_file_exists(self, path: str) -> tuple[bool, str]:
134
+ exists = os.path.isfile(str(path))
135
+ return exists, f'file {"exists" if exists else "missing"}: {path}'
136
+
137
+ def _check_folder_exists(self, path: str) -> tuple[bool, str]:
138
+ exists = os.path.isdir(str(path))
139
+ return exists, f'folder {"exists" if exists else "missing"}: {path}'
140
+
141
+ def _check_process(self, name: str) -> tuple[bool, str]:
142
+ want = str(name).strip().lower()
143
+ try:
144
+ import psutil # type: ignore
145
+
146
+ for proc in psutil.process_iter(["name"]):
147
+ pname = (proc.info.get("name") or "").lower()
148
+ if want in pname:
149
+ return True, f'process "{name}" running'
150
+ return False, f'process "{name}" not running'
151
+ except Exception:
152
+ # No psutil: fall back to a window-title heuristic rather than
153
+ # claiming success we cannot substantiate.
154
+ return self._check_window(name)
155
+
156
+ def _check_browser_url(self, fragment: str) -> tuple[bool, str]:
157
+ """Confirm the default browser is on the expected page.
158
+
159
+ Checks the URL Seed Code last opened *and* the live browser window
160
+ title, so a match on either counts — the title is what actually proves
161
+ the page loaded, while the URL proves we asked for the right one.
162
+ """
163
+ if self._browser is None:
164
+ from . import browser as browser # type: ignore
165
+
166
+ self._browser = browser
167
+ want = str(fragment).lower().strip()
168
+ try:
169
+ info = str(self._browser.get_page_info() or "")
170
+ except Exception as exc:
171
+ return False, f"browser info unavailable: {exc}"
172
+ if not info:
173
+ return False, f'no browser page open (expected "{fragment}")'
174
+ if want in info.lower():
175
+ return True, f'browser shows "{fragment}"'
176
+ first_line = info.splitlines()[0] if info.splitlines() else info
177
+ return False, f'browser does not show "{fragment}" (currently: {first_line})'