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
seedcode/computer/dpi.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""DPI / display-scaling resilience for the Computer Engine.
|
|
2
|
+
|
|
3
|
+
The engine drives three subsystems that each report coordinates in their own
|
|
4
|
+
space unless told otherwise:
|
|
5
|
+
|
|
6
|
+
* **UI Automation** (``uiautomation``) — ``BoundingRectangle`` is in *physical*
|
|
7
|
+
pixels once the process is per-monitor DPI aware, otherwise virtualized.
|
|
8
|
+
* **mss** (screenshots) — always captures *physical* pixels.
|
|
9
|
+
* **pyautogui** (mouse/keyboard) — operates in *physical* pixels only when the
|
|
10
|
+
process is DPI aware; otherwise Windows silently rescales its clicks, so a
|
|
11
|
+
point read from UIA/mss lands in the wrong place on any display scaled above
|
|
12
|
+
100 %.
|
|
13
|
+
|
|
14
|
+
The fix is to declare the process **Per-Monitor-DPI-Aware v2** exactly once, as
|
|
15
|
+
early as possible. Then all three agree on one physical-pixel space and a
|
|
16
|
+
coordinate resolved from the accessibility tree or a screenshot can be clicked
|
|
17
|
+
verbatim — on 4K laptops at 150 %, mixed-DPI multi-monitor rigs, and 100 %
|
|
18
|
+
desktops alike.
|
|
19
|
+
|
|
20
|
+
Everything here is Windows-only and defensive: on any other platform, or if the
|
|
21
|
+
Win32 calls are unavailable, the functions degrade to safe no-ops so the rest of
|
|
22
|
+
Seed Code keeps working. Nothing in this module touches an AI provider.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from functools import lru_cache
|
|
30
|
+
|
|
31
|
+
# Windows DPI-awareness context handles (see SetProcessDpiAwarenessContext).
|
|
32
|
+
_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
|
|
33
|
+
_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = -3
|
|
34
|
+
# PROCESS_DPI_AWARENESS values (SetProcessDpiAwareness, Win 8.1+).
|
|
35
|
+
_PROCESS_PER_MONITOR_DPI_AWARE = 2
|
|
36
|
+
# The standard baseline DPI Windows scales everything relative to.
|
|
37
|
+
BASELINE_DPI = 96
|
|
38
|
+
|
|
39
|
+
_applied: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class DpiInfo:
|
|
44
|
+
"""The DPI-awareness state the engine achieved, for diagnostics."""
|
|
45
|
+
|
|
46
|
+
mode: str # e.g. "per_monitor_v2", "per_monitor", "system", "none"
|
|
47
|
+
aware: bool # whether physical-pixel alignment can be trusted
|
|
48
|
+
platform: str
|
|
49
|
+
|
|
50
|
+
def describe(self) -> str:
|
|
51
|
+
if not self.aware:
|
|
52
|
+
return f"DPI awareness: {self.mode} (coordinates may be virtualized)"
|
|
53
|
+
return f"DPI awareness: {self.mode} (physical-pixel aligned)"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_dpi_awareness() -> DpiInfo:
|
|
57
|
+
"""Declare this process per-monitor-DPI-aware v2 — idempotent, best-effort.
|
|
58
|
+
|
|
59
|
+
Called once at engine start (and safe to call again). Tries the strongest
|
|
60
|
+
awareness Windows offers, falling back through older APIs on downlevel
|
|
61
|
+
systems. If awareness was already set by the host process (an embedding
|
|
62
|
+
app, a manifest), Windows refuses the change and we simply report the
|
|
63
|
+
current state rather than fighting it.
|
|
64
|
+
"""
|
|
65
|
+
global _applied
|
|
66
|
+
if _applied is not None:
|
|
67
|
+
return _current_info(_applied)
|
|
68
|
+
|
|
69
|
+
if sys.platform != "win32":
|
|
70
|
+
_applied = "none"
|
|
71
|
+
return _current_info("none")
|
|
72
|
+
|
|
73
|
+
mode = _apply_windows_awareness()
|
|
74
|
+
_applied = mode
|
|
75
|
+
return _current_info(mode)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _apply_windows_awareness() -> str:
|
|
79
|
+
"""Walk the DPI-awareness APIs newest-first; return the mode that stuck."""
|
|
80
|
+
import ctypes
|
|
81
|
+
|
|
82
|
+
# 1) Windows 10 1703+ : richest, allows Per-Monitor v2 (dialogs, non-client
|
|
83
|
+
# areas, and child windows all scale correctly).
|
|
84
|
+
try:
|
|
85
|
+
user32 = ctypes.windll.user32
|
|
86
|
+
if user32.SetProcessDpiAwarenessContext(
|
|
87
|
+
ctypes.c_void_p(_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
|
|
88
|
+
):
|
|
89
|
+
return "per_monitor_v2"
|
|
90
|
+
if user32.SetProcessDpiAwarenessContext(
|
|
91
|
+
ctypes.c_void_p(_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE)
|
|
92
|
+
):
|
|
93
|
+
return "per_monitor"
|
|
94
|
+
except (AttributeError, OSError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# 2) Windows 8.1+ : per-monitor awareness via shcore.
|
|
98
|
+
try:
|
|
99
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(_PROCESS_PER_MONITOR_DPI_AWARE)
|
|
100
|
+
return "per_monitor"
|
|
101
|
+
except (AttributeError, OSError):
|
|
102
|
+
# E_ACCESSDENIED here means awareness was already set for the process;
|
|
103
|
+
# treat that as success and read the real state below.
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# 3) Vista+ : system-DPI aware (single global scale factor).
|
|
107
|
+
try:
|
|
108
|
+
if ctypes.windll.user32.SetProcessDPIAware():
|
|
109
|
+
return "system"
|
|
110
|
+
except (AttributeError, OSError):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return _detect_existing_mode()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detect_existing_mode() -> str:
|
|
117
|
+
"""Read the process's current awareness when we could not set it."""
|
|
118
|
+
if sys.platform != "win32":
|
|
119
|
+
return "none"
|
|
120
|
+
import ctypes
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
awareness = ctypes.c_int()
|
|
124
|
+
ctypes.windll.shcore.GetProcessDpiAwareness(0, ctypes.byref(awareness))
|
|
125
|
+
return {0: "none", 1: "system", 2: "per_monitor"}.get(
|
|
126
|
+
awareness.value, "unknown"
|
|
127
|
+
)
|
|
128
|
+
except (AttributeError, OSError):
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _current_info(mode: str) -> DpiInfo:
|
|
133
|
+
aware = mode in ("per_monitor_v2", "per_monitor", "system")
|
|
134
|
+
return DpiInfo(mode=mode, aware=aware, platform=sys.platform)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@lru_cache(maxsize=16)
|
|
138
|
+
def scale_for_point(x: int, y: int) -> float:
|
|
139
|
+
"""The DPI scale factor (1.0 == 100 %) of the monitor containing a point.
|
|
140
|
+
|
|
141
|
+
Used to translate physical-pixel geometry to/from a display's logical
|
|
142
|
+
layout when a driver reports logical coordinates. Returns 1.0 whenever the
|
|
143
|
+
real factor cannot be determined, so callers never divide by an unknown.
|
|
144
|
+
"""
|
|
145
|
+
dpi = _dpi_for_point(x, y)
|
|
146
|
+
return (dpi / BASELINE_DPI) if dpi else 1.0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _dpi_for_point(x: int, y: int) -> int | None:
|
|
150
|
+
if sys.platform != "win32":
|
|
151
|
+
return None
|
|
152
|
+
import ctypes
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST=2)
|
|
156
|
+
pt = _Point(int(x), int(y))
|
|
157
|
+
monitor = ctypes.windll.user32.MonitorFromPoint(pt, 2)
|
|
158
|
+
dpi_x = ctypes.c_uint()
|
|
159
|
+
dpi_y = ctypes.c_uint()
|
|
160
|
+
# GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI=0, &x, &y)
|
|
161
|
+
if ctypes.windll.shcore.GetDpiForMonitor(
|
|
162
|
+
monitor, 0, ctypes.byref(dpi_x), ctypes.byref(dpi_y)
|
|
163
|
+
) == 0:
|
|
164
|
+
return int(dpi_x.value)
|
|
165
|
+
except (AttributeError, OSError):
|
|
166
|
+
return None
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if sys.platform == "win32":
|
|
171
|
+
import ctypes
|
|
172
|
+
|
|
173
|
+
class _Point(ctypes.Structure):
|
|
174
|
+
_fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
|
|
175
|
+
else: # pragma: no cover - non-Windows stub so imports never fail
|
|
176
|
+
class _Point: # type: ignore[no-redef]
|
|
177
|
+
def __init__(self, x: int, y: int) -> None:
|
|
178
|
+
self.x, self.y = x, y
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def reset_for_tests() -> None:
|
|
182
|
+
"""Clear the cached applied-state (test hook only)."""
|
|
183
|
+
global _applied
|
|
184
|
+
_applied = None
|
|
185
|
+
scale_for_point.cache_clear()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Computer Engine facade: the stable, offline, AI-independent entry point.
|
|
2
|
+
|
|
3
|
+
This is the single object the rest of Seed Code talks to. It owns and wires the
|
|
4
|
+
deterministic subsystems — controller (drivers), element resolver, state
|
|
5
|
+
manager, verification engine, recovery engine and the skill dispatcher — and
|
|
6
|
+
exposes a small, stable API:
|
|
7
|
+
|
|
8
|
+
* :meth:`run_skill` — run a named skill (or ``ui_*`` semantic verb) end-to-end
|
|
9
|
+
with verification and recovery; returns a :class:`DispatchResult`.
|
|
10
|
+
* :meth:`state` — the current :class:`ComputerState` (engine memory).
|
|
11
|
+
* :meth:`see` — a semantic snapshot for the AI to replan against.
|
|
12
|
+
* :meth:`catalog` — the skill manifest text for the AI, filtered by level.
|
|
13
|
+
|
|
14
|
+
The engine contains no provider/AI code and works offline. It is created once
|
|
15
|
+
per session (lazily) and holds no per-request state beyond the shared
|
|
16
|
+
StateManager.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..tools.permissions import PermissionLevel
|
|
24
|
+
from . import catalog as _catalog # noqa: F401 — import populates the skill registry
|
|
25
|
+
from .dispatcher import DispatchResult, SkillDispatcher
|
|
26
|
+
from .recovery import RecoveryEngine
|
|
27
|
+
from .resolver import ElementResolver
|
|
28
|
+
from .skills import REGISTRY
|
|
29
|
+
from .state import ComputerState, StateManager
|
|
30
|
+
from .verifier import VerificationEngine
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ComputerEngine:
|
|
34
|
+
"""The deterministic hands, eyes, memory and reflexes of Seed Code."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, permissions: Any, controller: Any = None) -> None:
|
|
37
|
+
self._permissions = permissions
|
|
38
|
+
if controller is None:
|
|
39
|
+
from .controller import ComputerController
|
|
40
|
+
|
|
41
|
+
controller = ComputerController()
|
|
42
|
+
self._controller = controller
|
|
43
|
+
|
|
44
|
+
# Subsystems, all deterministic and offline.
|
|
45
|
+
self._resolver = ElementResolver(
|
|
46
|
+
vision=getattr(controller, "vision", None),
|
|
47
|
+
screen=getattr(controller, "screen", None),
|
|
48
|
+
)
|
|
49
|
+
self._state = StateManager(
|
|
50
|
+
windows=getattr(controller, "windows", None),
|
|
51
|
+
mouse=getattr(controller, "mouse", None),
|
|
52
|
+
)
|
|
53
|
+
self._verifier = VerificationEngine(
|
|
54
|
+
vision=getattr(controller, "vision", None),
|
|
55
|
+
windows=getattr(controller, "windows", None),
|
|
56
|
+
)
|
|
57
|
+
self._recovery = RecoveryEngine(controller=controller, state=self._state)
|
|
58
|
+
self._dispatcher = SkillDispatcher(
|
|
59
|
+
controller=controller,
|
|
60
|
+
resolver=self._resolver,
|
|
61
|
+
state=self._state,
|
|
62
|
+
permissions=permissions,
|
|
63
|
+
registry=REGISTRY,
|
|
64
|
+
verifier=self._verifier,
|
|
65
|
+
recovery=self._recovery,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# --- public API ----------------------------------------------------------
|
|
69
|
+
def run_skill(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
params: dict[str, Any] | None = None,
|
|
73
|
+
expected: dict[str, Any] | None = None,
|
|
74
|
+
) -> DispatchResult:
|
|
75
|
+
"""Execute a skill / semantic UI verb with verification and recovery."""
|
|
76
|
+
# Keep engine memory fresh before acting so state-aware skills are right.
|
|
77
|
+
self._state.refresh()
|
|
78
|
+
# Record what we're doing so the AI has continuity without re-asking.
|
|
79
|
+
self._state.set_task(f"{name} {params or ''}".strip())
|
|
80
|
+
result = self._dispatcher.dispatch(name, params, expected)
|
|
81
|
+
# The task is finished (successfully or not); the trail keeps the detail.
|
|
82
|
+
self._state.set_task(None)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
def state(self) -> ComputerState:
|
|
86
|
+
"""Current engine memory (refreshed from the live desktop)."""
|
|
87
|
+
return self._state.refresh()
|
|
88
|
+
|
|
89
|
+
def see(self, window_title: str | None = None) -> str:
|
|
90
|
+
"""A semantic snapshot (element descriptions + text) for replanning."""
|
|
91
|
+
return self._controller.see(window_title)
|
|
92
|
+
|
|
93
|
+
def catalog(self, max_level: PermissionLevel | None = None) -> str:
|
|
94
|
+
"""Skill manifest text for the AI, hiding skills above ``max_level``."""
|
|
95
|
+
if max_level is None:
|
|
96
|
+
max_level = self._permissions.level
|
|
97
|
+
return REGISTRY.manifest(max_level=max_level)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def controller(self) -> Any:
|
|
101
|
+
return self._controller
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def state_manager(self) -> StateManager:
|
|
105
|
+
return self._state
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Keyboard driver: text typing and hotkeys via pyautogui.
|
|
2
|
+
|
|
3
|
+
Typing uses a small per-key interval so target applications reliably receive
|
|
4
|
+
every keystroke; hotkeys accept the pyautogui key-name vocabulary
|
|
5
|
+
("ctrl", "alt", "shift", "win", "enter", "f5", single characters, ...).
|
|
6
|
+
|
|
7
|
+
**Self-protection:** synthesized input goes to whatever window currently has
|
|
8
|
+
focus. When a browser/app focus step silently failed, that window is often
|
|
9
|
+
SeedCode's own terminal — so keystrokes meant for a web page would land in
|
|
10
|
+
the user's prompt or, worse, an Alt+F4-style combo would close our console
|
|
11
|
+
(the reported auto-exit bug). Two guards close both holes: window-closing
|
|
12
|
+
combos are refused outright, and any input is refused while *our* window is
|
|
13
|
+
focused (see :mod:`.selfguard`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from . import selfguard
|
|
19
|
+
|
|
20
|
+
# Bound one type_text call: pathological lengths point at a confused model.
|
|
21
|
+
MAX_TEXT_LENGTH = 5_000
|
|
22
|
+
|
|
23
|
+
# Keys that can dismiss or destroy a window. SeedCode never synthesizes
|
|
24
|
+
# window-level close/dismiss keystrokes: closing an app is done through
|
|
25
|
+
# windows.close_window (guarded), closing a browser tab through the browser
|
|
26
|
+
# engine — never by pressing Alt+F4/Cmd+W blind at whatever has focus.
|
|
27
|
+
_FORBIDDEN_COMBOS = (
|
|
28
|
+
{"alt", "f4"}, # close the focused window
|
|
29
|
+
{"cmd", "w"}, # macOS-style window close
|
|
30
|
+
{"ctrl", "shift", "w"}, # close all browser windows (not a plain tab)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Keys allowed in hotkey combos (a safety vocabulary, not an exhaustive list
|
|
34
|
+
# of what pyautogui supports — unknown names are rejected loudly).
|
|
35
|
+
_MODIFIERS = {"ctrl", "alt", "shift", "win", "cmd", "fn"}
|
|
36
|
+
_NAMED_KEYS = {
|
|
37
|
+
"enter", "return", "tab", "space", "backspace", "delete", "del", "esc",
|
|
38
|
+
"escape", "home", "end", "pageup", "pagedown", "up", "down", "left",
|
|
39
|
+
"right", "insert", "printscreen", "capslock", "numlock",
|
|
40
|
+
} | {f"f{i}" for i in range(1, 25)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pyautogui():
|
|
44
|
+
import pyautogui
|
|
45
|
+
|
|
46
|
+
pyautogui.FAILSAFE = True
|
|
47
|
+
return pyautogui
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_keys(keys: list[str]) -> list[str]:
|
|
51
|
+
"""Normalise and validate hotkey names; raises ValueError on junk."""
|
|
52
|
+
cleaned = []
|
|
53
|
+
for key in keys:
|
|
54
|
+
name = str(key).strip().lower()
|
|
55
|
+
if not name:
|
|
56
|
+
continue
|
|
57
|
+
if name in _MODIFIERS or name in _NAMED_KEYS or len(name) == 1:
|
|
58
|
+
cleaned.append(name)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Unknown key '{key}' in hotkey combination.")
|
|
61
|
+
if not cleaned:
|
|
62
|
+
raise ValueError("Hotkey combination is empty.")
|
|
63
|
+
return cleaned
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _assert_safe_input() -> None:
|
|
67
|
+
"""Refuse synthesized input while SeedCode's own window has focus.
|
|
68
|
+
|
|
69
|
+
A keystroke aimed at another application whose focus step failed must
|
|
70
|
+
never be delivered into the agent's own prompt or console.
|
|
71
|
+
"""
|
|
72
|
+
if selfguard.foreground_is_own():
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"SeedCode's own window is focused; refusing to send keystrokes. "
|
|
75
|
+
"Focus the target application first (desktop focus_app / "
|
|
76
|
+
"focus_window)."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def type_text(text: str, interval: float = 0.02) -> None:
|
|
81
|
+
_assert_safe_input()
|
|
82
|
+
if len(text) > MAX_TEXT_LENGTH:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Text is too long to type ({len(text)} chars; max {MAX_TEXT_LENGTH})."
|
|
85
|
+
)
|
|
86
|
+
_pyautogui().typewrite(text, interval=interval)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def hotkey(keys: list[str]) -> None:
|
|
90
|
+
normalized = [k.strip().lower() for k in validate_keys(keys)]
|
|
91
|
+
combo = {k for k in normalized if k not in ("",)}
|
|
92
|
+
for forbidden in _FORBIDDEN_COMBOS:
|
|
93
|
+
if forbidden <= combo:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"Refusing to send a window-closing hotkey ("
|
|
96
|
+
+ "+".join(sorted(forbidden))
|
|
97
|
+
+ "). Close applications via the close_app skill or windows "
|
|
98
|
+
"driver instead — never with a blind keyboard shortcut."
|
|
99
|
+
)
|
|
100
|
+
_assert_safe_input()
|
|
101
|
+
_pyautogui().hotkey(*normalized)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Structured execution log for the Computer Engine.
|
|
2
|
+
|
|
3
|
+
Every run through the dispatcher records what actually happened — the skill
|
|
4
|
+
chosen, the deterministic steps taken, whether verification passed, which
|
|
5
|
+
recovery strategies were used, and the final status — as an ordered list of
|
|
6
|
+
:class:`LogEntry` records. This is the engine's honest account of its own work:
|
|
7
|
+
the dispatcher writes it, the AI reads a compact rendering of it, and nothing
|
|
8
|
+
in it is fabricated. Success is only ever recorded when verification actually
|
|
9
|
+
passed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Stage(str, Enum):
|
|
21
|
+
PLAN = "plan" # a skill/action was selected
|
|
22
|
+
EXECUTE = "execute" # deterministic steps ran
|
|
23
|
+
VERIFY = "verify" # outcome checked
|
|
24
|
+
RECOVER = "recover" # recovery strategies attempted
|
|
25
|
+
DONE = "done" # final status
|
|
26
|
+
ERROR = "error" # hard failure
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class LogEntry:
|
|
31
|
+
stage: Stage
|
|
32
|
+
message: str
|
|
33
|
+
ok: bool = True
|
|
34
|
+
|
|
35
|
+
def render(self) -> str:
|
|
36
|
+
mark = "✓" if self.ok else "✗"
|
|
37
|
+
return f"{mark} [{self.stage.value}] {self.message}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExecutionLog:
|
|
42
|
+
"""An append-only trail of a single dispatched request."""
|
|
43
|
+
|
|
44
|
+
entries: list[LogEntry] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def add(self, stage: Stage, message: str, ok: bool = True) -> None:
|
|
47
|
+
self.entries.append(LogEntry(stage, message, ok))
|
|
48
|
+
|
|
49
|
+
def plan(self, message: str) -> None:
|
|
50
|
+
self.add(Stage.PLAN, message)
|
|
51
|
+
|
|
52
|
+
def execute(self, message: str, ok: bool = True) -> None:
|
|
53
|
+
self.add(Stage.EXECUTE, message, ok)
|
|
54
|
+
|
|
55
|
+
def verify(self, message: str, ok: bool = True) -> None:
|
|
56
|
+
self.add(Stage.VERIFY, message, ok)
|
|
57
|
+
|
|
58
|
+
def recover(self, message: str, ok: bool = True) -> None:
|
|
59
|
+
self.add(Stage.RECOVER, message, ok)
|
|
60
|
+
|
|
61
|
+
def done(self, message: str, ok: bool = True) -> None:
|
|
62
|
+
self.add(Stage.DONE if ok else Stage.ERROR, message, ok)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def succeeded(self) -> bool:
|
|
66
|
+
"""True only if a DONE (not ERROR) entry was recorded and nothing failed."""
|
|
67
|
+
return any(e.stage is Stage.DONE and e.ok for e in self.entries)
|
|
68
|
+
|
|
69
|
+
def render(self) -> str:
|
|
70
|
+
return "\n".join(e.render() for e in self.entries)
|
|
71
|
+
|
|
72
|
+
def summary(self) -> str:
|
|
73
|
+
"""One-line result for the AI: the final DONE/ERROR message."""
|
|
74
|
+
for e in reversed(self.entries):
|
|
75
|
+
if e.stage in (Stage.DONE, Stage.ERROR):
|
|
76
|
+
return e.message
|
|
77
|
+
return self.entries[-1].message if self.entries else "nothing happened"
|
|
78
|
+
|
|
79
|
+
def persist(self, label: str = "") -> None:
|
|
80
|
+
"""Append this run to ``~/.seedcode/logs/execution-<date>.jsonl``.
|
|
81
|
+
|
|
82
|
+
Best-effort: a full disk or locked file must never fail the action the
|
|
83
|
+
log describes. One JSON line per dispatched request keeps the file
|
|
84
|
+
greppable and machine-readable.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
from ..utils.helpers import app_dir
|
|
88
|
+
|
|
89
|
+
logs = app_dir() / "logs"
|
|
90
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
path = logs / time.strftime("execution-%Y%m%d.jsonl", time.localtime())
|
|
92
|
+
record = {
|
|
93
|
+
"ts": round(time.time(), 3),
|
|
94
|
+
"label": label,
|
|
95
|
+
"ok": self.succeeded,
|
|
96
|
+
"stages": [
|
|
97
|
+
{"stage": e.stage.value, "ok": e.ok, "message": e.message}
|
|
98
|
+
for e in self.entries
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
102
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # logging is evidence, not a dependency
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Mouse driver: move, click, drag, scroll via pyautogui.
|
|
2
|
+
|
|
3
|
+
Coordinate validation lives in the controller (which knows the screen
|
|
4
|
+
geometry); this module only performs the raw actions. ``FAILSAFE`` stays on:
|
|
5
|
+
slamming the pointer into the top-left corner aborts any action — the user's
|
|
6
|
+
emergency brake.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pyautogui():
|
|
13
|
+
import pyautogui
|
|
14
|
+
|
|
15
|
+
pyautogui.FAILSAFE = True
|
|
16
|
+
pyautogui.PAUSE = 0.05 # small settle between low-level actions
|
|
17
|
+
return pyautogui
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def position() -> tuple[int, int]:
|
|
21
|
+
"""Current pointer position."""
|
|
22
|
+
point = _pyautogui().position()
|
|
23
|
+
return int(point.x), int(point.y)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def move(x: int, y: int, duration: float = 0.2) -> None:
|
|
27
|
+
_pyautogui().moveTo(x, y, duration=duration)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def click(x: int, y: int, button: str = "left", double: bool = False) -> None:
|
|
31
|
+
gui = _pyautogui()
|
|
32
|
+
clicks = 2 if double else 1
|
|
33
|
+
gui.click(x=x, y=y, clicks=clicks, button=button)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drag(x1: int, y1: int, x2: int, y2: int, duration: float = 0.5) -> None:
|
|
37
|
+
"""Drag & drop: press at (x1, y1), release at (x2, y2)."""
|
|
38
|
+
gui = _pyautogui()
|
|
39
|
+
gui.moveTo(x1, y1, duration=0.2)
|
|
40
|
+
gui.dragTo(x2, y2, duration=max(0.2, duration), button="left")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scroll(amount: int, x: int | None = None, y: int | None = None) -> None:
|
|
44
|
+
"""Scroll by ``amount`` notches (positive = up) at an optional position."""
|
|
45
|
+
gui = _pyautogui()
|
|
46
|
+
if x is not None and y is not None:
|
|
47
|
+
gui.moveTo(x, y, duration=0.1)
|
|
48
|
+
gui.scroll(amount)
|