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,327 @@
|
|
|
1
|
+
"""Vision driver: the agent's eyes on the desktop.
|
|
2
|
+
|
|
3
|
+
The primary source of truth is the Windows UI Automation tree — real
|
|
4
|
+
elements (buttons, inputs, menus, dialogs) with exact coordinates, described
|
|
5
|
+
as text so EVERY provider can use desktop mode, not just multimodal ones.
|
|
6
|
+
Screenshots complement it for image-capable models, and OCR (pytesseract,
|
|
7
|
+
if the user happens to have it) is a best-effort fallback for windows that
|
|
8
|
+
expose no automation tree (games, some custom-drawn apps).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# Bound the tree walk: deep UIA trees (browsers) can hold thousands of nodes.
|
|
16
|
+
MAX_ELEMENTS = 150
|
|
17
|
+
MAX_DEPTH = 12
|
|
18
|
+
|
|
19
|
+
# Control types worth reporting to the model (interactive or informative).
|
|
20
|
+
_INTERESTING_TYPES = {
|
|
21
|
+
"ButtonControl": "button",
|
|
22
|
+
"EditControl": "input",
|
|
23
|
+
"ComboBoxControl": "combobox",
|
|
24
|
+
"CheckBoxControl": "checkbox",
|
|
25
|
+
"RadioButtonControl": "radio",
|
|
26
|
+
"HyperlinkControl": "link",
|
|
27
|
+
"MenuItemControl": "menuitem",
|
|
28
|
+
"MenuControl": "menu",
|
|
29
|
+
"TabItemControl": "tab",
|
|
30
|
+
"ListItemControl": "listitem",
|
|
31
|
+
"TextControl": "text",
|
|
32
|
+
"DocumentControl": "document",
|
|
33
|
+
"WindowControl": "dialog",
|
|
34
|
+
"TitleBarControl": "titlebar",
|
|
35
|
+
"ToolBarControl": "toolbar",
|
|
36
|
+
"TreeItemControl": "treeitem",
|
|
37
|
+
"SliderControl": "slider",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class UIElement:
|
|
43
|
+
"""One visible UI element with its clickable center point."""
|
|
44
|
+
|
|
45
|
+
role: str
|
|
46
|
+
name: str
|
|
47
|
+
x: int # center
|
|
48
|
+
y: int # center
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
enabled: bool
|
|
52
|
+
# Secondary UIA properties for the resolver's tier-2 matching. Empty when
|
|
53
|
+
# the element (or the platform) does not expose them.
|
|
54
|
+
automation_id: str = ""
|
|
55
|
+
value: str = ""
|
|
56
|
+
help_text: str = ""
|
|
57
|
+
|
|
58
|
+
def describe(self) -> str:
|
|
59
|
+
state = "" if self.enabled else " (disabled)"
|
|
60
|
+
name = self.name[:80] if self.name else "(unnamed)"
|
|
61
|
+
return f'{self.role} "{name}" at ({self.x}, {self.y}){state}'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def snapshot(window_title: str | None = None) -> tuple[str, list[UIElement]]:
|
|
65
|
+
"""UI-tree snapshot of the active (or named) window.
|
|
66
|
+
|
|
67
|
+
Returns (window title, elements). Raises ValueError when the target
|
|
68
|
+
window cannot be found.
|
|
69
|
+
"""
|
|
70
|
+
import uiautomation as auto
|
|
71
|
+
|
|
72
|
+
if window_title:
|
|
73
|
+
root = auto.WindowControl(searchDepth=1, SubName=window_title)
|
|
74
|
+
if not root.Exists(maxSearchSeconds=2):
|
|
75
|
+
raise ValueError(f"No window found matching '{window_title}'.")
|
|
76
|
+
else:
|
|
77
|
+
root = auto.GetForegroundControl()
|
|
78
|
+
if root is None:
|
|
79
|
+
raise ValueError("No foreground window to inspect.")
|
|
80
|
+
top = root.GetTopLevelControl()
|
|
81
|
+
if top is not None:
|
|
82
|
+
root = top
|
|
83
|
+
|
|
84
|
+
elements: list[UIElement] = []
|
|
85
|
+
_walk(root, elements, depth=0)
|
|
86
|
+
return (root.Name or "(untitled window)", elements)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _walk(control, out: list[UIElement], depth: int) -> None:
|
|
90
|
+
if len(out) >= MAX_ELEMENTS or depth > MAX_DEPTH:
|
|
91
|
+
return
|
|
92
|
+
for child in control.GetChildren():
|
|
93
|
+
if len(out) >= MAX_ELEMENTS:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
type_name = child.ControlTypeName
|
|
97
|
+
role = _INTERESTING_TYPES.get(type_name)
|
|
98
|
+
rect = child.BoundingRectangle
|
|
99
|
+
visible = rect is not None and rect.width() > 0 and rect.height() > 0
|
|
100
|
+
if role is not None and visible:
|
|
101
|
+
name = (child.Name or "").strip()
|
|
102
|
+
# Skip anonymous static text: it adds noise, not targets.
|
|
103
|
+
if not (role == "text" and not name):
|
|
104
|
+
out.append(
|
|
105
|
+
UIElement(
|
|
106
|
+
role=role,
|
|
107
|
+
name=name,
|
|
108
|
+
x=rect.left + rect.width() // 2,
|
|
109
|
+
y=rect.top + rect.height() // 2,
|
|
110
|
+
width=rect.width(),
|
|
111
|
+
height=rect.height(),
|
|
112
|
+
enabled=bool(child.IsEnabled),
|
|
113
|
+
automation_id=_safe_prop(child, "AutomationId"),
|
|
114
|
+
value=_value_of(child),
|
|
115
|
+
help_text=_safe_prop(child, "HelpText"),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
# A single flaky COM element must not kill the whole snapshot.
|
|
120
|
+
continue
|
|
121
|
+
_walk(child, out, depth + 1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _safe_prop(control, attr: str) -> str:
|
|
125
|
+
"""Read a UIA string property, tolerating COM hiccups and absence."""
|
|
126
|
+
try:
|
|
127
|
+
val = getattr(control, attr, "")
|
|
128
|
+
return str(val).strip() if val else ""
|
|
129
|
+
except Exception:
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _value_of(control) -> str:
|
|
134
|
+
"""The ValuePattern text of a control (e.g. an input's contents), if any."""
|
|
135
|
+
try:
|
|
136
|
+
pattern = control.GetValuePattern()
|
|
137
|
+
return str(pattern.Value or "").strip()
|
|
138
|
+
except Exception:
|
|
139
|
+
return ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def describe_snapshot(title: str, elements: list[UIElement]) -> str:
|
|
143
|
+
"""Model-facing text rendering of a snapshot."""
|
|
144
|
+
if not elements:
|
|
145
|
+
return (
|
|
146
|
+
f'Window "{title}" exposes no UI Automation elements '
|
|
147
|
+
"(custom-drawn app?). Use desktop_screenshot and OCR/vision instead."
|
|
148
|
+
)
|
|
149
|
+
lines = [f'Window "{title}" — {len(elements)} elements:']
|
|
150
|
+
lines += [f" - {el.describe()}" for el in elements]
|
|
151
|
+
if len(elements) >= MAX_ELEMENTS:
|
|
152
|
+
lines.append(f" ... (truncated at {MAX_ELEMENTS} elements)")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def element_at(x: int, y: int) -> str:
|
|
157
|
+
"""Describe the UI element under a point (used to verify actions)."""
|
|
158
|
+
try:
|
|
159
|
+
import uiautomation as auto
|
|
160
|
+
|
|
161
|
+
control = auto.ControlFromPoint(x, y)
|
|
162
|
+
if control is None:
|
|
163
|
+
return "nothing"
|
|
164
|
+
role = _INTERESTING_TYPES.get(control.ControlTypeName, control.ControlTypeName)
|
|
165
|
+
name = (control.Name or "").strip()[:80]
|
|
166
|
+
return f'{role} "{name or "(unnamed)"}"'
|
|
167
|
+
except Exception:
|
|
168
|
+
return "unknown"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ocr_available() -> bool:
|
|
172
|
+
"""Whether OCR can genuinely run here.
|
|
173
|
+
|
|
174
|
+
Checks the engine, not just the Python wrapper: ``pytesseract`` imports
|
|
175
|
+
fine without ``tesseract.exe``, and reporting OCR as available on that
|
|
176
|
+
basis made every call fail at the point of use. See :mod:`.ocr`.
|
|
177
|
+
"""
|
|
178
|
+
from . import ocr
|
|
179
|
+
|
|
180
|
+
return ocr.available()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- computer vision / image matching (optional, opencv-backed) --------------
|
|
184
|
+
|
|
185
|
+
def cv_available() -> bool:
|
|
186
|
+
"""Whether OpenCV is importable (the CV and image-match ladder tiers)."""
|
|
187
|
+
import importlib.util
|
|
188
|
+
|
|
189
|
+
return importlib.util.find_spec("cv2") is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def template_locate(
|
|
193
|
+
image_path, template_path, threshold: float = 0.87
|
|
194
|
+
) -> tuple[int, int, int, int] | None:
|
|
195
|
+
"""Locate a reference image (``template_path``) inside a screenshot.
|
|
196
|
+
|
|
197
|
+
The **image-matching** ladder tier: skills that ship a small reference PNG
|
|
198
|
+
(an icon, a logo, a button with no accessibility name) can point the
|
|
199
|
+
resolver at it. Returns the best match's (left, top, width, height) when the
|
|
200
|
+
normalized-correlation score clears ``threshold``, else None. Uses OpenCV;
|
|
201
|
+
returns None when OpenCV is unavailable so the resolver falls through.
|
|
202
|
+
"""
|
|
203
|
+
if not cv_available():
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
import cv2 # type: ignore
|
|
207
|
+
import numpy as np # type: ignore
|
|
208
|
+
|
|
209
|
+
haystack = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
|
|
210
|
+
needle = cv2.imread(str(template_path), cv2.IMREAD_GRAYSCALE)
|
|
211
|
+
if haystack is None or needle is None:
|
|
212
|
+
return None
|
|
213
|
+
th, tw = needle.shape[:2]
|
|
214
|
+
if th == 0 or tw == 0 or th > haystack.shape[0] or tw > haystack.shape[1]:
|
|
215
|
+
return None
|
|
216
|
+
result = cv2.matchTemplate(haystack, needle, cv2.TM_CCOEFF_NORMED)
|
|
217
|
+
_min_v, max_v, _min_l, max_l = cv2.minMaxLoc(result)
|
|
218
|
+
if max_v < threshold:
|
|
219
|
+
return None
|
|
220
|
+
left, top = int(max_l[0]), int(max_l[1])
|
|
221
|
+
return (left, top, int(tw), int(th))
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def refine_region_cv(
|
|
227
|
+
image_path, box: tuple[int, int, int, int]
|
|
228
|
+
) -> tuple[int, int, int, int]:
|
|
229
|
+
"""Snap an OCR text box out to the clickable control that contains it.
|
|
230
|
+
|
|
231
|
+
The **computer-vision** ladder tier. Custom-drawn buttons expose no
|
|
232
|
+
accessibility tree; OCR finds the *label* but its center may sit on text
|
|
233
|
+
rather than the true hit target. This finds the smallest rectangular
|
|
234
|
+
contour enclosing the label's center and returns that box, so the click
|
|
235
|
+
lands on the control body. Falls back to the original box when OpenCV is
|
|
236
|
+
unavailable or no better contour is found.
|
|
237
|
+
"""
|
|
238
|
+
if not cv_available():
|
|
239
|
+
return box
|
|
240
|
+
try:
|
|
241
|
+
import cv2 # type: ignore
|
|
242
|
+
|
|
243
|
+
img = cv2.imread(str(image_path))
|
|
244
|
+
if img is None:
|
|
245
|
+
return box
|
|
246
|
+
left, top, width, height = box
|
|
247
|
+
cx, cy = left + width // 2, top + height // 2
|
|
248
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
249
|
+
edges = cv2.Canny(gray, 50, 150)
|
|
250
|
+
edges = cv2.dilate(edges, None, iterations=1)
|
|
251
|
+
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
|
252
|
+
best = None
|
|
253
|
+
best_area = None
|
|
254
|
+
for cnt in contours:
|
|
255
|
+
bx, by, bw, bh = cv2.boundingRect(cnt)
|
|
256
|
+
# Must contain the label center and be at least as large as the text.
|
|
257
|
+
if bx <= cx <= bx + bw and by <= cy <= by + bh and bw >= width and bh >= height:
|
|
258
|
+
area = bw * bh
|
|
259
|
+
if best_area is None or area < best_area:
|
|
260
|
+
best, best_area = (bx, by, bw, bh), area
|
|
261
|
+
return best or box
|
|
262
|
+
except Exception:
|
|
263
|
+
return box
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def ocr_screenshot(image_path) -> str:
|
|
267
|
+
"""Best-effort OCR of a screenshot."""
|
|
268
|
+
if not ocr_available():
|
|
269
|
+
from . import ocr as ocr_module
|
|
270
|
+
|
|
271
|
+
_ok, reason = ocr_module.status()
|
|
272
|
+
return (
|
|
273
|
+
f"OCR is unavailable ({reason}). The UI Automation snapshot "
|
|
274
|
+
"(computer_see) is the primary way to read the screen."
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
import pytesseract
|
|
278
|
+
from PIL import Image
|
|
279
|
+
|
|
280
|
+
with Image.open(image_path) as img:
|
|
281
|
+
text = pytesseract.image_to_string(img)
|
|
282
|
+
return text.strip() or "(no text recognised)"
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
return f"OCR failed: {exc}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def ocr_locate(image_path, phrase: str) -> tuple[int, int, int, int] | None:
|
|
288
|
+
"""Find ``phrase`` on a screenshot; return its (left, top, width, height).
|
|
289
|
+
|
|
290
|
+
Used by the element resolver as the fallback path for windows that expose
|
|
291
|
+
no UI Automation tree. Matches a run of consecutive OCR words whose joined
|
|
292
|
+
text contains the phrase (case-insensitive), and returns the bounding box
|
|
293
|
+
that spans them. Returns None when the phrase is not found or OCR is
|
|
294
|
+
unavailable — the resolver then reports the element as unresolvable.
|
|
295
|
+
"""
|
|
296
|
+
if not ocr_available():
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
import pytesseract
|
|
300
|
+
from PIL import Image
|
|
301
|
+
|
|
302
|
+
want = " ".join((phrase or "").lower().split())
|
|
303
|
+
if not want:
|
|
304
|
+
return None
|
|
305
|
+
with Image.open(image_path) as img:
|
|
306
|
+
data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
words = data.get("text", [])
|
|
311
|
+
n = len(words)
|
|
312
|
+
# Slide a window of up to the phrase's word-count over the OCR words and
|
|
313
|
+
# accept the first run whose joined text contains the phrase.
|
|
314
|
+
span = max(1, len(want.split()))
|
|
315
|
+
for i in range(n):
|
|
316
|
+
for length in range(1, span + 1):
|
|
317
|
+
if i + length > n:
|
|
318
|
+
break
|
|
319
|
+
chunk = " ".join(w for w in words[i : i + length] if w).lower().strip()
|
|
320
|
+
if chunk and want in chunk:
|
|
321
|
+
lefts = [data["left"][k] for k in range(i, i + length)]
|
|
322
|
+
tops = [data["top"][k] for k in range(i, i + length)]
|
|
323
|
+
rights = [data["left"][k] + data["width"][k] for k in range(i, i + length)]
|
|
324
|
+
bottoms = [data["top"][k] + data["height"][k] for k in range(i, i + length)]
|
|
325
|
+
left, top = min(lefts), min(tops)
|
|
326
|
+
return (left, top, max(rights) - left, max(bottoms) - top)
|
|
327
|
+
return None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Window management driver: list, focus, open, and close applications.
|
|
2
|
+
|
|
3
|
+
Window enumeration and focus use ``pygetwindow`` (Win32 under the hood).
|
|
4
|
+
Opening applications goes through ``os.startfile``/``start`` semantics so
|
|
5
|
+
anything resolvable by the shell (path, registered app, document) works.
|
|
6
|
+
Closing is graceful first (WM_CLOSE) with a ``taskkill`` fallback by name.
|
|
7
|
+
|
|
8
|
+
**Self-protection:** every lookup skips SeedCode's own console window (see
|
|
9
|
+
:mod:`.selfguard`). Title matching here is substring-based, so without the
|
|
10
|
+
guard a request like "close the browser" could resolve to — and terminate —
|
|
11
|
+
the very terminal SeedCode is running in. Controlling our own window is
|
|
12
|
+
deliberately impossible; closing *SeedCode* is the REPL's /exit decision,
|
|
13
|
+
never a window operation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import selfguard
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class WindowInfo:
|
|
28
|
+
"""One top-level window as reported to the model."""
|
|
29
|
+
|
|
30
|
+
title: str
|
|
31
|
+
left: int
|
|
32
|
+
top: int
|
|
33
|
+
width: int
|
|
34
|
+
height: int
|
|
35
|
+
active: bool
|
|
36
|
+
minimized: bool
|
|
37
|
+
# Identity metadata (0 when the platform doesn't expose it): the owning
|
|
38
|
+
# process lets the app controller match windows to launches, and the raw
|
|
39
|
+
# handle lets semantic actions drive windows precisely. Our own console
|
|
40
|
+
# is reported with pid=0/hwnd=0 so no matching logic can ever pick it.
|
|
41
|
+
pid: int = 0
|
|
42
|
+
hwnd: int = 0
|
|
43
|
+
|
|
44
|
+
def describe(self) -> str:
|
|
45
|
+
state = "active" if self.active else ("minimized" if self.minimized else "open")
|
|
46
|
+
return (
|
|
47
|
+
f'"{self.title}" [{state}] at ({self.left}, {self.top}) '
|
|
48
|
+
f"size {self.width}x{self.height}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _gw():
|
|
53
|
+
import pygetwindow
|
|
54
|
+
|
|
55
|
+
return pygetwindow
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _window_pid_hwnd(win: Any) -> tuple[int, int]:
|
|
59
|
+
"""Owning process id + handle of a pygetwindow window (0, 0 when unknown).
|
|
60
|
+
|
|
61
|
+
``pygetwindow`` exposes the raw handle as ``_hWnd``; the pid comes from
|
|
62
|
+
Win32. Failures degrade to (0, 0) — matching code must treat that as
|
|
63
|
+
"unknown", never as a match.
|
|
64
|
+
"""
|
|
65
|
+
hwnd = getattr(win, "_hWnd", 0) or 0
|
|
66
|
+
pid = 0
|
|
67
|
+
if sys.platform == "win32" and hwnd:
|
|
68
|
+
try:
|
|
69
|
+
import ctypes
|
|
70
|
+
|
|
71
|
+
pid_val = ctypes.wintypes.DWORD()
|
|
72
|
+
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid_val))
|
|
73
|
+
pid = int(pid_val.value)
|
|
74
|
+
except Exception:
|
|
75
|
+
pid = 0
|
|
76
|
+
return pid, int(hwnd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_windows() -> list[WindowInfo]:
|
|
80
|
+
"""All titled top-level windows, active one first."""
|
|
81
|
+
gw = _gw()
|
|
82
|
+
active = gw.getActiveWindow()
|
|
83
|
+
active_handle = getattr(active, "_hWnd", None)
|
|
84
|
+
windows = []
|
|
85
|
+
for win in gw.getAllWindows():
|
|
86
|
+
if not (win.title or "").strip():
|
|
87
|
+
continue
|
|
88
|
+
if selfguard.is_own_window(win):
|
|
89
|
+
continue # never report the terminal running SeedCode
|
|
90
|
+
pid, hwnd = _window_pid_hwnd(win)
|
|
91
|
+
windows.append(
|
|
92
|
+
WindowInfo(
|
|
93
|
+
title=win.title,
|
|
94
|
+
left=int(win.left),
|
|
95
|
+
top=int(win.top),
|
|
96
|
+
width=int(win.width),
|
|
97
|
+
height=int(win.height),
|
|
98
|
+
active=getattr(win, "_hWnd", None) == active_handle,
|
|
99
|
+
minimized=bool(win.isMinimized),
|
|
100
|
+
pid=pid,
|
|
101
|
+
hwnd=hwnd,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
windows.sort(key=lambda w: not w.active)
|
|
105
|
+
return windows
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def active_window() -> WindowInfo | None:
|
|
109
|
+
"""The focused window, or None when nothing is focused."""
|
|
110
|
+
win = _gw().getActiveWindow()
|
|
111
|
+
if win is None or not (win.title or "").strip():
|
|
112
|
+
return None
|
|
113
|
+
if selfguard.is_own_window(win):
|
|
114
|
+
return None # our console is invisible to desktop logic
|
|
115
|
+
pid, hwnd = _window_pid_hwnd(win)
|
|
116
|
+
return WindowInfo(
|
|
117
|
+
title=win.title,
|
|
118
|
+
left=int(win.left),
|
|
119
|
+
top=int(win.top),
|
|
120
|
+
width=int(win.width),
|
|
121
|
+
height=int(win.height),
|
|
122
|
+
active=True,
|
|
123
|
+
minimized=bool(win.isMinimized),
|
|
124
|
+
pid=pid,
|
|
125
|
+
hwnd=hwnd,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find(title_substring: str):
|
|
130
|
+
"""First window whose title contains ``title_substring`` (case-insensitive).
|
|
131
|
+
|
|
132
|
+
SeedCode's own console is invisible to this lookup: focusing, closing, or
|
|
133
|
+
otherwise acting on the terminal hosting the agent is always a mistake.
|
|
134
|
+
"""
|
|
135
|
+
needle = title_substring.strip().lower()
|
|
136
|
+
if not needle:
|
|
137
|
+
raise ValueError("Window title (or part of it) is required.")
|
|
138
|
+
for win in _gw().getAllWindows():
|
|
139
|
+
if selfguard.is_own_window(win):
|
|
140
|
+
continue # never target the terminal running SeedCode
|
|
141
|
+
if needle in (win.title or "").lower():
|
|
142
|
+
return win
|
|
143
|
+
raise ValueError(f"No window found matching '{title_substring}'.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def focus_window(title_substring: str) -> WindowInfo | None:
|
|
147
|
+
"""Bring a window to the foreground; returns the new active window."""
|
|
148
|
+
win = _find(title_substring)
|
|
149
|
+
if win.isMinimized:
|
|
150
|
+
win.restore()
|
|
151
|
+
win.activate()
|
|
152
|
+
return active_window()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def open_app(target: str) -> str:
|
|
156
|
+
"""Launch an application or open a document via the shell.
|
|
157
|
+
|
|
158
|
+
``target`` is anything the Windows shell can resolve: an exe name on
|
|
159
|
+
PATH, a full path, or a registered app (e.g. "notepad", "calc").
|
|
160
|
+
ShellExecute via ``os.startfile`` first (documents, App Paths), then a
|
|
161
|
+
detached ``start`` as fallback — never waiting on the launched process,
|
|
162
|
+
which would hang until the app exits.
|
|
163
|
+
"""
|
|
164
|
+
import os
|
|
165
|
+
|
|
166
|
+
target = target.strip()
|
|
167
|
+
if not target:
|
|
168
|
+
raise ValueError("Application name or path is required.")
|
|
169
|
+
try:
|
|
170
|
+
os.startfile(target) # noqa: S606 - deliberate shell-open semantics
|
|
171
|
+
return f"Launched '{target}'."
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
# Fallback: 'start' resolves PATH executables and shell aliases. The
|
|
175
|
+
# process is detached (no inherited pipes) so this returns immediately.
|
|
176
|
+
try:
|
|
177
|
+
subprocess.Popen(
|
|
178
|
+
f'start "" "{target}"',
|
|
179
|
+
shell=True,
|
|
180
|
+
stdout=subprocess.DEVNULL,
|
|
181
|
+
stderr=subprocess.DEVNULL,
|
|
182
|
+
stdin=subprocess.DEVNULL,
|
|
183
|
+
)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise RuntimeError(f"Could not open '{target}': {exc}")
|
|
186
|
+
return f"Launched '{target}'."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def close_window(title_substring: str, force: bool = False) -> str:
|
|
190
|
+
"""Close a window gracefully (WM_CLOSE); ``force`` kills the process.
|
|
191
|
+
|
|
192
|
+
Refuses outright when the target *is* our own console: closing SeedCode
|
|
193
|
+
is an explicit /exit decision made by the user in the REPL, never a
|
|
194
|
+
side effect of a desktop task. The ``taskkill`` fallback is additionally
|
|
195
|
+
title-guarded so a force-kill can never match our terminal by prefix.
|
|
196
|
+
"""
|
|
197
|
+
if selfguard.is_own_title(title_substring):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
"Refusing to close SeedCode's own terminal. Use /exit in the "
|
|
200
|
+
"REPL to quit SeedCode."
|
|
201
|
+
)
|
|
202
|
+
win = _find(title_substring)
|
|
203
|
+
title = win.title
|
|
204
|
+
win.close()
|
|
205
|
+
if force:
|
|
206
|
+
# Best-effort process kill for apps that ignore WM_CLOSE. The window
|
|
207
|
+
# filter is matched as a prefix, so pin it away from our own title.
|
|
208
|
+
if selfguard.is_own_title(title):
|
|
209
|
+
return f"Closed '{title}'." # graceful close succeeded; skip the kill
|
|
210
|
+
subprocess.run(
|
|
211
|
+
f'taskkill /FI "WINDOWTITLE eq {title}*" /F',
|
|
212
|
+
shell=True,
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=15,
|
|
216
|
+
)
|
|
217
|
+
return f"Closed '{title}'."
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Configuration system: load/save the app config and its defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .defaults import CONFIG_FILENAME, ENV_KEYS
|
|
6
|
+
from .manager import load_config, save_config
|
|
7
|
+
|
|
8
|
+
__all__ = ["CONFIG_FILENAME", "ENV_KEYS", "load_config", "save_config"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration defaults and environment overrides.
|
|
2
|
+
|
|
3
|
+
Kept import-light (no project imports) so it can be referenced from anywhere
|
|
4
|
+
without risk of an import cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# Environment variables that override stored API keys (CI / power users),
|
|
10
|
+
# keyed by provider id. Ollama needs no key so it has no entry. Values are
|
|
11
|
+
# tuples so a provider can accept several variable names if ever needed.
|
|
12
|
+
ENV_KEYS: dict[str, tuple[str, ...]] = {
|
|
13
|
+
"openrouter": ("OPENROUTER_API_KEY",),
|
|
14
|
+
# One FreeModel account key works on both FreeModel backends; each
|
|
15
|
+
# provider still stores it in its OWN config slot.
|
|
16
|
+
"freemodel_claude": ("FREEMODEL_API_KEY",),
|
|
17
|
+
"freemodel_codex": ("FREEMODEL_API_KEY",),
|
|
18
|
+
"aerolink": ("AEROLINK_API_KEY",),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Filename of the JSON config document within the per-user app directory.
|
|
22
|
+
CONFIG_FILENAME = "config.json"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuration loading and saving for Seed Code.
|
|
2
|
+
|
|
3
|
+
The config is a single JSON document under ``~/.seedcode/config.json``. Loading
|
|
4
|
+
is fault-tolerant: a missing or corrupt file yields sane defaults rather than a
|
|
5
|
+
crash, honouring the rule "never crash". Saving is best-effort for the same
|
|
6
|
+
reason (a locked or full disk must not kill the session).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from ..core.models import AppConfig
|
|
15
|
+
from ..utils.helpers import config_path, restrict_permissions
|
|
16
|
+
from ..utils.logger import get_logger
|
|
17
|
+
from .defaults import ENV_KEYS
|
|
18
|
+
|
|
19
|
+
_log = get_logger("config")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config() -> AppConfig:
|
|
23
|
+
"""Load configuration from disk, falling back to defaults on any error."""
|
|
24
|
+
path = config_path()
|
|
25
|
+
config = AppConfig()
|
|
26
|
+
|
|
27
|
+
if path.exists():
|
|
28
|
+
try:
|
|
29
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
30
|
+
config = AppConfig.model_validate(raw)
|
|
31
|
+
_log.info("config loaded from %s", path)
|
|
32
|
+
except (json.JSONDecodeError, ValueError, OSError) as exc:
|
|
33
|
+
# Corrupt or unreadable config -> start from defaults instead of dying.
|
|
34
|
+
_log.warning("config unreadable (%s); using defaults", exc)
|
|
35
|
+
config = AppConfig()
|
|
36
|
+
else:
|
|
37
|
+
_log.info("no config file yet (first run)")
|
|
38
|
+
|
|
39
|
+
# Explicit environment variables always win over stored API keys.
|
|
40
|
+
for provider_id, env_names in ENV_KEYS.items():
|
|
41
|
+
for env_name in env_names:
|
|
42
|
+
env_key = os.environ.get(env_name, "").strip()
|
|
43
|
+
if env_key:
|
|
44
|
+
config.set_api_key(provider_id, env_key)
|
|
45
|
+
_log.info("api key for %s taken from %s", provider_id, env_name)
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
return config
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_config(config: AppConfig) -> None:
|
|
52
|
+
"""Persist configuration to disk (best-effort, owner-only permissions)."""
|
|
53
|
+
path = config_path()
|
|
54
|
+
try:
|
|
55
|
+
path.write_text(
|
|
56
|
+
json.dumps(config.model_dump(), indent=2, ensure_ascii=False),
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
restrict_permissions(path)
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
# Settings still apply for this session; only persistence failed.
|
|
62
|
+
_log.error("could not save config to %s: %s", path, exc)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Core logic: data models, provider backends, chat engine, streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chat import ChatEngine, ChatError
|
|
6
|
+
from .models import AppConfig, Message
|
|
7
|
+
from .providers import (
|
|
8
|
+
PROVIDERS,
|
|
9
|
+
ModelInfo,
|
|
10
|
+
Provider,
|
|
11
|
+
ProviderError,
|
|
12
|
+
ValidationResult,
|
|
13
|
+
get_provider,
|
|
14
|
+
provider_label,
|
|
15
|
+
)
|
|
16
|
+
from .streaming import iter_stream
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AppConfig",
|
|
20
|
+
"ChatEngine",
|
|
21
|
+
"ChatError",
|
|
22
|
+
"Message",
|
|
23
|
+
"ModelInfo",
|
|
24
|
+
"PROVIDERS",
|
|
25
|
+
"Provider",
|
|
26
|
+
"ProviderError",
|
|
27
|
+
"ValidationResult",
|
|
28
|
+
"get_provider",
|
|
29
|
+
"iter_stream",
|
|
30
|
+
"provider_label",
|
|
31
|
+
]
|