screenforge 0.4.0__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 (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. utils/utils_xml.py +218 -0
common/capabilities.py ADDED
@@ -0,0 +1,121 @@
1
+ from common.runtime_modes import MODE_DOCTOR, MODE_DRY_RUN, MODE_PLAN_ONLY, MODE_RUN
2
+
3
+ SUPPORTED_PLATFORMS = ("android", "ios", "web")
4
+ SUPPORTED_ACTIONS = (
5
+ "goto",
6
+ "click",
7
+ "long_click",
8
+ "hover",
9
+ "input",
10
+ "swipe",
11
+ "press",
12
+ "scroll_into_view",
13
+ "select",
14
+ "upload",
15
+ "double_click",
16
+ "right_click",
17
+ "drag",
18
+ "wait_for",
19
+ "assert_exist",
20
+ "assert_not_exist",
21
+ "assert_text_equals",
22
+ "assert_text_contains",
23
+ "assert_value",
24
+ "assert_url",
25
+ )
26
+ # Assertions produce a verification VERDICT (the system-under-test did/did not
27
+ # meet the condition) rather than an engine error — execute_and_record tags
28
+ # their failures with assertion_failed so callers/--json can disambiguate.
29
+ ASSERTION_ACTIONS = {
30
+ "assert_exist",
31
+ "assert_not_exist",
32
+ "assert_text_equals",
33
+ "assert_text_contains",
34
+ "assert_value",
35
+ "assert_url",
36
+ }
37
+ # assert_url reads page.url, not an element — it needs no locator (web-global).
38
+ GLOBAL_ACTIONS = {"goto", "swipe", "press", "assert_url"}
39
+ # Actions with a clean Playwright API but no robust coordinate-free mobile
40
+ # equivalent. Engaged only on web; on android/ios the handler fails honestly
41
+ # rather than emitting a brittle coordinate-based step (see P2 coordinate
42
+ # honesty). assert_url is web-only for a different reason (reads page.url).
43
+ WEB_ONLY_ACTIONS = {
44
+ "goto",
45
+ "scroll_into_view",
46
+ "select",
47
+ "upload",
48
+ "double_click",
49
+ "right_click",
50
+ "drag",
51
+ "assert_url",
52
+ }
53
+ ACTIONS_REQUIRING_EXTRA_VALUE = {
54
+ "goto",
55
+ "input",
56
+ "select",
57
+ "upload",
58
+ "drag",
59
+ "assert_text_equals",
60
+ "assert_text_contains",
61
+ "assert_value",
62
+ "assert_url",
63
+ }
64
+ CONTROL_PLANES = ("goal", "workflow", "action", "doctor")
65
+ EXECUTION_MODES = (MODE_RUN, MODE_DOCTOR, MODE_PLAN_ONLY, MODE_DRY_RUN)
66
+
67
+ # Which locator_type values actually resolve on each platform. This mirrors the
68
+ # real executor / UI-compressor behavior, NOT aspiration:
69
+ # - web: compress_web_dom emits ref/bbox; LocatorBuilder maps css/text/desc.
70
+ # - android: utils_xml emits resource-id/text/content-desc (no ref/bbox).
71
+ # - ios: utils_ios maps text/desc -> label/name (no ref/bbox); resourceId->name.
72
+ # An agent should read this instead of assuming `ref` works everywhere.
73
+ LOCATORS_BY_PLATFORM = {
74
+ "web": ["css", "ref", "text", "description"],
75
+ "android": ["resourceId", "text", "description"],
76
+ "ios": ["text", "description"],
77
+ }
78
+
79
+ # Platform-gated location features. ref/bbox and the VLM visual fallback are
80
+ # web-only (mobile UI-tree compressors don't emit ref/bbox, and the visual
81
+ # fallback in executor.py is gated on platform == "web").
82
+ FEATURES_BY_PLATFORM = {
83
+ "ref_bbox": ["web"],
84
+ "screenshot_annotation": ["web"],
85
+ "visual_fallback": ["web"],
86
+ }
87
+
88
+
89
+ def get_capabilities_payload() -> dict:
90
+ return {
91
+ "platforms": list(SUPPORTED_PLATFORMS),
92
+ "execution_modes": list(EXECUTION_MODES),
93
+ "control_planes": list(CONTROL_PLANES),
94
+ "supported_actions": list(SUPPORTED_ACTIONS),
95
+ "global_actions": sorted(GLOBAL_ACTIONS),
96
+ "web_only_actions": sorted(WEB_ONLY_ACTIONS),
97
+ "actions_requiring_extra_value": sorted(ACTIONS_REQUIRING_EXTRA_VALUE),
98
+ "locators": {p: list(v) for p, v in LOCATORS_BY_PLATFORM.items()},
99
+ "locator_priority": ["css", "resourceId", "text", "description"],
100
+ "features": {f: list(v) for f, v in FEATURES_BY_PLATFORM.items()},
101
+ "supports": {
102
+ "doctor": True,
103
+ "resume": True,
104
+ "workflow": True,
105
+ "workflow_vars": True,
106
+ "action": True,
107
+ "inspect_ui": True,
108
+ "case_memory": True,
109
+ "run_assets": True,
110
+ "load_run": True,
111
+ "tool_request": True,
112
+ "tool_stdin": True,
113
+ "mcp_server": True,
114
+ "json_events": True,
115
+ "goal_cli_human_mode_only": True,
116
+ },
117
+ "docs": {
118
+ "capability_matrix": "docs/capability-matrix.md",
119
+ "agent_guide": "docs/agent_guide.md",
120
+ },
121
+ }
common/case_memory.py ADDED
@@ -0,0 +1,327 @@
1
+ import hashlib
2
+ import json
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ import config.config as config
11
+ from common.logs import log
12
+
13
+
14
+ def _now_iso() -> str:
15
+ return datetime.now().isoformat(timespec="seconds")
16
+
17
+
18
+ def _normalize_text(value: str) -> str:
19
+ return str(value or "").strip()
20
+
21
+
22
+ def _slugify(value: str) -> str:
23
+ text = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", _normalize_text(value)).strip("-")
24
+ return text.lower() or "memory"
25
+
26
+
27
+ def _build_memory_id(
28
+ platform: str,
29
+ control_kind: str,
30
+ control_label: str,
31
+ source_ref: str,
32
+ ) -> str:
33
+ identity_seed = f"{platform}|{control_kind}|{control_label}|{source_ref}"
34
+ digest = hashlib.sha1(identity_seed.encode("utf-8")).hexdigest()[:10]
35
+ human_label = _slugify(source_ref or control_label)[:48]
36
+ return f"{platform}:{control_kind}:{human_label}:{digest}"
37
+
38
+
39
+ def _merge_unique_strings(existing: list[str], incoming: list[str]) -> list[str]:
40
+ merged = []
41
+ seen = set()
42
+ for item in [*existing, *incoming]:
43
+ normalized = _normalize_text(item)
44
+ if not normalized or normalized in seen:
45
+ continue
46
+ merged.append(normalized)
47
+ seen.add(normalized)
48
+ return merged
49
+
50
+
51
+ def _merge_locator_hints(
52
+ existing: list["LocatorHint"],
53
+ incoming: list["LocatorHint"],
54
+ ) -> list["LocatorHint"]:
55
+ merged: list["LocatorHint"] = []
56
+ seen = set()
57
+ for item in [*existing, *incoming]:
58
+ key = (
59
+ _normalize_text(item.action),
60
+ _normalize_text(item.locator_type),
61
+ _normalize_text(item.locator_value),
62
+ )
63
+ if not all(key) or key in seen:
64
+ continue
65
+ merged.append(
66
+ LocatorHint(
67
+ action=key[0],
68
+ locator_type=key[1],
69
+ locator_value=key[2],
70
+ )
71
+ )
72
+ seen.add(key)
73
+ return merged
74
+
75
+
76
+ class LocatorHint(BaseModel):
77
+ action: str = ""
78
+ locator_type: str = ""
79
+ locator_value: str = ""
80
+
81
+
82
+ class CaseMemoryEntry(BaseModel):
83
+ memory_id: str
84
+ platform: str
85
+ control_kind: str
86
+ control_label: str
87
+ source_ref: str = ""
88
+ success_count: int = 0
89
+ failure_count: int = 0
90
+ last_status: str = ""
91
+ last_run_id: str = ""
92
+ last_used_at: str = ""
93
+ successful_actions: list[str] = Field(default_factory=list)
94
+ locator_hints: list[LocatorHint] = Field(default_factory=list)
95
+ pytest_asset: dict[str, Any] = Field(default_factory=dict)
96
+ recommended_next_step: dict[str, Any] | None = None
97
+
98
+
99
+ class CaseMemoryDocument(BaseModel):
100
+ version: int = 1
101
+ updated_at: str = ""
102
+ entries: list[CaseMemoryEntry] = Field(default_factory=list)
103
+
104
+
105
+ def _collect_successful_actions(step_records: list[dict[str, Any]]) -> list[str]:
106
+ return [
107
+ _normalize_text(item.get("action_description", ""))
108
+ for item in step_records
109
+ if item.get("event") == "action_executed"
110
+ and item.get("success") is True
111
+ and _normalize_text(item.get("action_description", ""))
112
+ ]
113
+
114
+
115
+ def _collect_locator_hints(
116
+ summary: dict[str, Any],
117
+ step_records: list[dict[str, Any]],
118
+ ) -> list[LocatorHint]:
119
+ hints: list[LocatorHint] = []
120
+ control_summary = summary.get("control_summary", {}) or {}
121
+ tuples_seen = set()
122
+
123
+ def _append_hint(action: str, locator_type: str, locator_value: str) -> None:
124
+ normalized_action = _normalize_text(action)
125
+ normalized_type = _normalize_text(locator_type)
126
+ normalized_value = _normalize_text(locator_value)
127
+ if (
128
+ not normalized_action
129
+ or not normalized_type
130
+ or normalized_type.lower() == "global"
131
+ or not normalized_value
132
+ or normalized_value.lower() == "global"
133
+ ):
134
+ return
135
+ key = (normalized_action, normalized_type, normalized_value)
136
+ if key in tuples_seen:
137
+ return
138
+ tuples_seen.add(key)
139
+ hints.append(
140
+ LocatorHint(
141
+ action=normalized_action,
142
+ locator_type=normalized_type,
143
+ locator_value=normalized_value,
144
+ )
145
+ )
146
+
147
+ _append_hint(
148
+ control_summary.get("action", ""),
149
+ control_summary.get("locator_type", ""),
150
+ control_summary.get("locator_value", ""),
151
+ )
152
+
153
+ for item in step_records:
154
+ _append_hint(
155
+ item.get("action", ""),
156
+ item.get("locator_type", ""),
157
+ item.get("locator_value", ""),
158
+ )
159
+
160
+ return hints
161
+
162
+
163
+ class CaseMemoryStore:
164
+ def __init__(self, file_path: str | Path | None = None):
165
+ self._file_path = Path(file_path or config.CASE_MEMORY_PATH).expanduser()
166
+
167
+ @property
168
+ def file_path(self) -> Path:
169
+ return self._file_path
170
+
171
+ def load_document(self) -> CaseMemoryDocument:
172
+ if not self._file_path.exists():
173
+ return CaseMemoryDocument(updated_at=_now_iso())
174
+
175
+ try:
176
+ payload = json.loads(self._file_path.read_text(encoding="utf-8"))
177
+ return CaseMemoryDocument.model_validate(payload)
178
+ except Exception as e:
179
+ log.warning(f"[Warning] Failed to read case memory, falling back to empty store: {e}")
180
+ return CaseMemoryDocument(updated_at=_now_iso())
181
+
182
+ def save_document(self, document: CaseMemoryDocument) -> None:
183
+ self._file_path.parent.mkdir(parents=True, exist_ok=True)
184
+ tmp_path = self._file_path.with_suffix(self._file_path.suffix + ".tmp")
185
+ tmp_path.write_text(
186
+ json.dumps(document.model_dump(), ensure_ascii=False, indent=2),
187
+ encoding="utf-8",
188
+ )
189
+ tmp_path.replace(self._file_path)
190
+
191
+ def query_entries(
192
+ self,
193
+ platform: str = "",
194
+ control_kind: str = "",
195
+ query: str = "",
196
+ source_ref: str = "",
197
+ limit: int = 20,
198
+ ) -> list[dict[str, Any]]:
199
+ document = self.load_document()
200
+ normalized_platform = _normalize_text(platform).lower()
201
+ normalized_kind = _normalize_text(control_kind).lower()
202
+ normalized_query = _normalize_text(query).lower()
203
+ normalized_source_ref = _normalize_text(source_ref)
204
+ limit = max(1, int(limit or 20))
205
+
206
+ matched_entries = []
207
+ for entry in document.entries:
208
+ if normalized_platform and entry.platform.lower() != normalized_platform:
209
+ continue
210
+ if normalized_kind and entry.control_kind.lower() != normalized_kind:
211
+ continue
212
+ if normalized_source_ref and entry.source_ref != normalized_source_ref:
213
+ continue
214
+ if normalized_query:
215
+ haystacks = [
216
+ entry.control_label.lower(),
217
+ entry.source_ref.lower(),
218
+ " ".join(entry.successful_actions).lower(),
219
+ ]
220
+ if not any(normalized_query in haystack for haystack in haystacks):
221
+ continue
222
+ matched_entries.append(entry.model_dump())
223
+
224
+ matched_entries.sort(
225
+ key=lambda item: (
226
+ item.get("last_used_at", ""),
227
+ item.get("success_count", 0),
228
+ ),
229
+ reverse=True,
230
+ )
231
+ return matched_entries[:limit]
232
+
233
+ def find_entry(
234
+ self,
235
+ platform: str,
236
+ control_kind: str,
237
+ control_label: str,
238
+ source_ref: str = "",
239
+ ) -> dict[str, Any] | None:
240
+ normalized_platform = _normalize_text(platform).lower()
241
+ normalized_kind = _normalize_text(control_kind).lower()
242
+ normalized_label = _normalize_text(control_label)
243
+ normalized_source_ref = _normalize_text(source_ref)
244
+ document = self.load_document()
245
+
246
+ for entry in document.entries:
247
+ if entry.platform.lower() != normalized_platform:
248
+ continue
249
+ if entry.control_kind.lower() != normalized_kind:
250
+ continue
251
+ if normalized_source_ref and entry.source_ref == normalized_source_ref:
252
+ return entry.model_dump()
253
+ if normalized_label and entry.control_label == normalized_label:
254
+ return entry.model_dump()
255
+ return None
256
+
257
+ def upsert_from_run(
258
+ self,
259
+ summary: dict[str, Any],
260
+ step_records: list[dict[str, Any]],
261
+ ) -> dict[str, Any] | None:
262
+ if _normalize_text(summary.get("execution_mode", "")) != "run":
263
+ return None
264
+
265
+ control_summary = summary.get("control_summary", {}) or {}
266
+ control_kind = _normalize_text(control_summary.get("control_kind", ""))
267
+ if not control_kind or control_kind == "doctor":
268
+ return None
269
+
270
+ platform = _normalize_text(summary.get("platform", ""))
271
+ control_label = _normalize_text(control_summary.get("control_label", "")) or _normalize_text(
272
+ summary.get("goal", "")
273
+ )
274
+ source_ref = _normalize_text(control_summary.get("source_ref", ""))
275
+ if not platform or not control_label:
276
+ return None
277
+
278
+ document = self.load_document()
279
+ existing_entry = None
280
+ for entry in document.entries:
281
+ if entry.platform != platform or entry.control_kind != control_kind:
282
+ continue
283
+ if source_ref and entry.source_ref == source_ref:
284
+ existing_entry = entry
285
+ break
286
+ if entry.control_label == control_label:
287
+ existing_entry = entry
288
+ break
289
+
290
+ if existing_entry is None:
291
+ existing_entry = CaseMemoryEntry(
292
+ memory_id=_build_memory_id(
293
+ platform=platform,
294
+ control_kind=control_kind,
295
+ control_label=control_label,
296
+ source_ref=source_ref,
297
+ ),
298
+ platform=platform,
299
+ control_kind=control_kind,
300
+ control_label=control_label,
301
+ source_ref=source_ref,
302
+ )
303
+ document.entries.append(existing_entry)
304
+
305
+ status = _normalize_text(summary.get("status", ""))
306
+ if status == "success":
307
+ existing_entry.success_count += 1
308
+ else:
309
+ existing_entry.failure_count += 1
310
+
311
+ existing_entry.last_status = status
312
+ existing_entry.last_run_id = _normalize_text(summary.get("run_id", ""))
313
+ existing_entry.last_used_at = _normalize_text(summary.get("finished_at", "")) or _now_iso()
314
+ existing_entry.successful_actions = _merge_unique_strings(
315
+ existing_entry.successful_actions,
316
+ _collect_successful_actions(step_records),
317
+ )
318
+ existing_entry.locator_hints = _merge_locator_hints(
319
+ existing_entry.locator_hints,
320
+ _collect_locator_hints(summary, step_records),
321
+ )
322
+ existing_entry.pytest_asset = dict(summary.get("pytest_asset", {}) or {})
323
+ existing_entry.recommended_next_step = dict(summary.get("failure_analysis", {}) or {}) or None
324
+
325
+ document.updated_at = existing_entry.last_used_at
326
+ self.save_document(document)
327
+ return existing_entry.model_dump()
common/error_codes.py ADDED
@@ -0,0 +1,61 @@
1
+ """Single source of truth for agent-facing error codes.
2
+
3
+ Both the stderr log (`log.error(format_log("E037"))`) and the `--action --json`
4
+ failure payload read message + fix from this one table, so the two channels can
5
+ never drift. Scope is deliberately narrow: only the locate/action codes an agent
6
+ hits on the `--action` path. Connection codes (E04x/E05x) and `--goal`-only codes
7
+ (E02x stagnation / circuit-breaker / max-steps) are intentionally NOT here — this
8
+ iteration does not touch those paths.
9
+ """
10
+
11
+ # code -> (message, fix)
12
+ ERROR_CODES: dict[str, tuple[str, str]] = {
13
+ "E030": (
14
+ "Ref not found in cache.",
15
+ "Run inspect_ui first to refresh the element cache.",
16
+ ),
17
+ "E031": (
18
+ "Unsupported action type.",
19
+ "See `screenforge --capabilities` for the supported action list.",
20
+ ),
21
+ "E032": (
22
+ "Element action missing locator_type.",
23
+ "Provide locator_type (css/text/resourceId/description).",
24
+ ),
25
+ "E033": (
26
+ "Element locator is empty after resolution.",
27
+ "Verify the target exists on the current page via inspect_ui.",
28
+ ),
29
+ "E035": (
30
+ "AI returned empty action type.",
31
+ "Check that MODEL_NAME supports structured JSON output.",
32
+ ),
33
+ "E036": (
34
+ "Ref has no stable locator (only coordinates).",
35
+ "Re-inspect; use a text/css locator instead of a coordinate-only ref.",
36
+ ),
37
+ "E037": (
38
+ "Element could not be located for the action.",
39
+ "Re-inspect, scroll the target into view, or add --vision.",
40
+ ),
41
+ "E038": (
42
+ "Element located but the action failed or was blocked.",
43
+ "Check for overlays; ensure the element is enabled and in the viewport.",
44
+ ),
45
+ }
46
+
47
+ _GENERIC = (
48
+ "Action failed.",
49
+ "Re-inspect via inspect_ui and adjust strategy.",
50
+ )
51
+
52
+
53
+ def lookup(code: str) -> tuple[str, str]:
54
+ """Return (message, fix) for a code; unknown codes get a generic, non-raising fallback."""
55
+ return ERROR_CODES.get(code, _GENERIC)
56
+
57
+
58
+ def format_log(code: str) -> str:
59
+ """Format a code for stderr: '[E037] <message> Fix: <fix>'."""
60
+ msg, fix = lookup(code)
61
+ return f"[{code}] {msg} Fix: {fix}"
common/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ class UIAgentError(Exception):
2
+ pass
3
+
4
+
5
+ class AdapterError(UIAgentError):
6
+ pass
7
+
8
+
9
+ class AIError(UIAgentError):
10
+ pass
11
+
12
+
13
+ class CacheError(UIAgentError):
14
+ pass
15
+
16
+
17
+ class ExecutorError(UIAgentError):
18
+ pass