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,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)