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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- 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})'
|