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
@@ -0,0 +1,446 @@
1
+ """Tool protocol handlers: MCP server, tool-request, tool-stdin, inspect_ui."""
2
+
3
+ import base64
4
+ import json
5
+ import sys
6
+ from contextlib import nullcontext
7
+ from pathlib import Path
8
+
9
+ from cli.parser import build_parser, validate_cli_args
10
+ from cli.reporter import (
11
+ _load_context_content,
12
+ _resolve_control_identity,
13
+ _resolve_output_script_path,
14
+ )
15
+ from cli.shared import (
16
+ _capture_ui_state,
17
+ _connect_adapter,
18
+ _SharedAdapterManager,
19
+ config,
20
+ current_url,
21
+ log,
22
+ )
23
+ from common.run_resume import RunContextLoadError, load_run_bundle
24
+ from common.runtime_modes import MODE_DOCTOR, resolve_execution_mode
25
+ from common.tool_protocol import (
26
+ ToolRequestError,
27
+ build_capabilities_response,
28
+ build_cli_arg_overrides,
29
+ load_tool_request,
30
+ load_tool_request_from_stdin,
31
+ )
32
+
33
+
34
+ class _NullRunReporter:
35
+ def emit_event(self, event: str, **payload) -> None:
36
+ return None
37
+
38
+ def save_screenshot(self, img_bytes: bytes, step_index: int, name: str | None = None) -> str:
39
+ return ""
40
+
41
+
42
+ def _list_run_dirs(base_dir: Path) -> set[Path]:
43
+ if not base_dir.exists():
44
+ return set()
45
+ return {item for item in base_dir.iterdir() if item.is_dir()}
46
+
47
+
48
+ def _resolve_new_run_dir(before: set[Path], base_dir: Path) -> Path | None:
49
+ after = _list_run_dirs(base_dir)
50
+ new_dirs = sorted(after - before)
51
+ if new_dirs:
52
+ return new_dirs[-1]
53
+ if not after:
54
+ return None
55
+ return sorted(after)[-1]
56
+
57
+
58
+ def _emit_tool_response(payload: dict) -> int:
59
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
60
+ sys.stdout.flush()
61
+ return int(payload.get("exit_code", 0))
62
+
63
+
64
+ def _empty_run_assets() -> dict:
65
+ return {
66
+ "summary_path": "",
67
+ "artifacts_path": "",
68
+ "pytest_replay_path": "",
69
+ "failure_analysis": {},
70
+ "pytest_asset": {},
71
+ "resume_commands": {},
72
+ "recommended_next_step": None,
73
+ }
74
+
75
+
76
+ def _load_run_assets(run_dir: Path | None) -> dict:
77
+ if not run_dir:
78
+ return {
79
+ "summary": {},
80
+ "run_assets": _empty_run_assets(),
81
+ "resume_context": {},
82
+ }
83
+
84
+ bundle = load_run_bundle(run_dir)
85
+ return {
86
+ "summary": bundle.get("summary", {}) or {},
87
+ "run_assets": bundle.get("run_assets", {}) or _empty_run_assets(),
88
+ "resume_context": bundle.get("resume_context", {}) or {},
89
+ }
90
+
91
+
92
+ def _load_case_memory_store():
93
+ from common.case_memory import CaseMemoryStore
94
+
95
+ return CaseMemoryStore()
96
+
97
+
98
+ def _find_case_memory_hit(args, execution_mode: str) -> dict | None:
99
+ if execution_mode == MODE_DOCTOR:
100
+ return None
101
+
102
+ control_identity = _resolve_control_identity(args, execution_mode)
103
+ control_kind = str(control_identity.get("control_kind", "")).strip()
104
+ if control_kind == "doctor":
105
+ return None
106
+
107
+ return _load_case_memory_store().find_entry(
108
+ platform=args.platform,
109
+ control_kind=control_kind,
110
+ control_label=control_identity.get("control_label", ""),
111
+ source_ref=control_identity.get("control_source_ref", ""),
112
+ )
113
+
114
+
115
+ def build_load_case_memory_payload(
116
+ platform: str = "",
117
+ control_kind: str = "",
118
+ query: str = "",
119
+ source_ref: str = "",
120
+ limit: int = 20,
121
+ ) -> dict:
122
+ entries = _load_case_memory_store().query_entries(
123
+ platform=platform,
124
+ control_kind=control_kind,
125
+ query=query,
126
+ source_ref=source_ref,
127
+ limit=limit,
128
+ )
129
+ return {
130
+ "ok": True,
131
+ "operation": "load_case_memory",
132
+ "exit_code": 0,
133
+ "case_memory_path": str(config.CASE_MEMORY_PATH),
134
+ "entries": entries,
135
+ }
136
+
137
+
138
+ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterManager | None = None) -> dict:
139
+ parser = build_parser()
140
+ request_args = parser.parse_args([])
141
+ request_args.platform = request.platform
142
+ request_args.env = request.env
143
+ request_args.vision = request.vision
144
+
145
+ adapter = None
146
+ owns_adapter = False
147
+ try:
148
+ if shared_adapter_manager:
149
+ adapter = shared_adapter_manager.get_or_create(request.platform, request.env)
150
+ else:
151
+ adapter = _connect_adapter(request_args, _NullRunReporter())
152
+ owns_adapter = True
153
+ ui_json, screenshot_base64 = _capture_ui_state(
154
+ request_args,
155
+ adapter,
156
+ _NullRunReporter(),
157
+ 1,
158
+ )
159
+
160
+ if not screenshot_base64:
161
+ try:
162
+ img_bytes = adapter.take_screenshot()
163
+ screenshot_base64 = base64.b64encode(img_bytes).decode("utf-8")
164
+ except Exception as e:
165
+ log.warning(f"⚠️ [Warning] inspect_ui screenshot capture failed: {e}")
166
+
167
+ try:
168
+ ui_tree = json.loads(ui_json)
169
+ except json.JSONDecodeError:
170
+ ui_tree = {"ui_elements": [], "raw": ui_json}
171
+
172
+ # Sync the shared executor's web ref cache to THIS inspect, so a
173
+ # subsequent `--action --locator-type ref @N` in the same MCP session
174
+ # resolves against the page just inspected, not a stale prior page. The
175
+ # cache lives on the per-platform UIExecutor the adapter manager owns;
176
+ # without a manager (one-shot tool call) there's no follow-up action to
177
+ # share with, so syncing would be a no-op and is skipped.
178
+ if request.platform == "web" and shared_adapter_manager:
179
+ try:
180
+ executor = shared_adapter_manager.get_executor(request.platform, request.env)
181
+ executor.set_ui_elements(ui_tree.get("ui_elements", []) or [])
182
+ except Exception as e:
183
+ log.warning(f"⚠️ [Warning] Failed to sync ref cache from inspect_ui: {e}")
184
+
185
+ annotated_screenshot_base64 = ""
186
+ if screenshot_base64 and ui_tree.get("ui_elements"):
187
+ try:
188
+ from utils.screenshot_annotator import annotate_screenshot
189
+ raw_bytes = base64.b64decode(screenshot_base64)
190
+ annotated_bytes = annotate_screenshot(raw_bytes, ui_tree["ui_elements"])
191
+ annotated_screenshot_base64 = base64.b64encode(annotated_bytes).decode("utf-8")
192
+ except Exception as e:
193
+ log.warning(f"⚠️ [Warning] Annotated screenshot generation failed: {e}")
194
+
195
+ page_url = current_url(adapter, request.platform)
196
+
197
+ return {
198
+ "ok": True,
199
+ "operation": "inspect_ui",
200
+ "exit_code": 0,
201
+ "platform": request.platform,
202
+ "env": request.env,
203
+ "ui_json": ui_json,
204
+ "ui_tree": ui_tree,
205
+ "element_count": len(ui_tree.get("ui_elements", []) or []),
206
+ "screenshot_base64": screenshot_base64 or "",
207
+ "annotated_screenshot_base64": annotated_screenshot_base64,
208
+ "current_url": page_url,
209
+ }
210
+ except Exception as e:
211
+ return {
212
+ "ok": False,
213
+ "operation": "inspect_ui",
214
+ "exit_code": 1,
215
+ "platform": request.platform,
216
+ "env": request.env,
217
+ "error": str(e),
218
+ "current_url": "",
219
+ }
220
+ finally:
221
+ if owns_adapter and adapter:
222
+ try:
223
+ adapter.teardown()
224
+ except Exception as e:
225
+ log.warning(f"⚠️ [Warning] Cleanup failed: {e}")
226
+
227
+
228
+ def _requires_model_runtime(args, execution_mode: str) -> bool:
229
+ if execution_mode == MODE_DOCTOR:
230
+ return False
231
+ if str(getattr(args, "workflow", "")).strip():
232
+ return False
233
+ if str(getattr(args, "action", "")).strip():
234
+ return False
235
+ return bool(str(getattr(args, "goal", "")).strip())
236
+
237
+
238
+ def build_tool_response_payload(request, shared_adapter_manager: _SharedAdapterManager | None = None) -> dict:
239
+ if request.operation == "capabilities":
240
+ payload = build_capabilities_response()
241
+ payload["exit_code"] = 0
242
+ return payload
243
+ if request.operation == "load_run":
244
+ return build_load_run_payload(request.run_id)
245
+ if request.operation == "load_case_memory":
246
+ return build_load_case_memory_payload(
247
+ platform=request.platform,
248
+ control_kind=request.control_kind,
249
+ query=request.query,
250
+ source_ref=request.source_ref,
251
+ limit=request.limit,
252
+ )
253
+ if request.operation == "inspect_ui":
254
+ return build_inspect_ui_payload(request, shared_adapter_manager=shared_adapter_manager)
255
+
256
+ parser = build_parser()
257
+ request_args = parser.parse_args([])
258
+ for key, value in build_cli_arg_overrides(request).items():
259
+ setattr(request_args, key, value)
260
+
261
+ try:
262
+ validate_cli_args(request_args)
263
+ except ValueError as e:
264
+ return {
265
+ "ok": False,
266
+ "operation": "execute",
267
+ "exit_code": 2,
268
+ "error": str(e),
269
+ }
270
+
271
+ execution_mode = resolve_execution_mode(
272
+ doctor=request_args.doctor,
273
+ plan_only=request_args.plan_only,
274
+ dry_run=request_args.dry_run,
275
+ )
276
+ case_memory_hit = _find_case_memory_hit(request_args, execution_mode)
277
+ output_script_path = _resolve_output_script_path(request_args)
278
+ run_base_dir = Path(config.RUN_REPORT_BASE_DIR)
279
+ previous_run_dirs = _list_run_dirs(run_base_dir)
280
+
281
+ try:
282
+ context_content, resume_context = _load_context_content(request_args)
283
+ except RunContextLoadError as e:
284
+ return {
285
+ "ok": False,
286
+ "operation": "execute",
287
+ "exit_code": 2,
288
+ "mode": execution_mode,
289
+ "error": str(e),
290
+ }
291
+
292
+ if _requires_model_runtime(request_args, execution_mode) and not config.validate_config():
293
+ return {
294
+ "ok": False,
295
+ "operation": "execute",
296
+ "exit_code": 1,
297
+ "mode": execution_mode,
298
+ "error": "Configuration validation failed",
299
+ }
300
+
301
+ mute_logs_context = nullcontext
302
+ try:
303
+ from common.logs import mute_stderr_logs as _mute_stderr_logs
304
+
305
+ mute_logs_context = _mute_stderr_logs
306
+ except Exception:
307
+ mute_logs_context = nullcontext
308
+
309
+ from cli.dispatch import _dispatch_execution
310
+
311
+ with mute_logs_context():
312
+ exit_code = _dispatch_execution(
313
+ request_args,
314
+ execution_mode,
315
+ output_script_path,
316
+ context_content,
317
+ resume_context,
318
+ shared_adapter_manager=shared_adapter_manager,
319
+ )
320
+ run_dir = _resolve_new_run_dir(previous_run_dirs, run_base_dir)
321
+ loaded_assets = _load_run_assets(run_dir) if run_dir and (run_dir / "summary.json").exists() else {
322
+ "summary": {},
323
+ "run_assets": _empty_run_assets(),
324
+ "resume_context": {},
325
+ }
326
+ summary = loaded_assets["summary"]
327
+ run_assets = loaded_assets["run_assets"]
328
+ summary_path = run_assets.get("summary_path", "")
329
+
330
+ # Minimal MCP-execute enrichment: error_code + fix from the single-source
331
+ # table (NO did-you-mean candidates — this run-report path has no live
332
+ # ui_elements). NOTE: this stays {} until the autonomous run reporter
333
+ # propagates the executor's error_code into summary.json; today
334
+ # run_reporter writes category/stage/last_error but not error_code, so the
335
+ # `if code:` guard is the honest no-op — never a fabricated code. Wiring it
336
+ # live is a follow-up (propagate error_code through the run summary).
337
+ failure_diagnosis = {}
338
+ if exit_code != 0:
339
+ from common.error_codes import lookup
340
+
341
+ code = str(summary.get("error_code", "") or "").strip()
342
+ if code:
343
+ msg, fix = lookup(code)
344
+ failure_diagnosis = {"error_code": code, "message": msg, "fix": fix}
345
+
346
+ return {
347
+ "ok": exit_code == 0,
348
+ "operation": "execute",
349
+ "mode": execution_mode,
350
+ "exit_code": exit_code,
351
+ "run_dir": str(run_dir) if run_dir else "",
352
+ "summary_path": summary_path,
353
+ "summary": summary,
354
+ "run_assets": run_assets,
355
+ "case_memory_hit": bool(case_memory_hit),
356
+ "case_memory_entry": summary.get("case_memory_entry") or case_memory_hit,
357
+ "recommended_next_step": run_assets.get("recommended_next_step"),
358
+ "failure_diagnosis": failure_diagnosis,
359
+ }
360
+
361
+
362
+ def build_load_run_payload(run_id: str) -> dict:
363
+ run_id = str(run_id).strip()
364
+ run_dir = Path(config.RUN_REPORT_BASE_DIR) / run_id
365
+ try:
366
+ bundle = load_run_bundle(run_dir)
367
+ except RunContextLoadError as e:
368
+ return {
369
+ "ok": False,
370
+ "operation": "load_run",
371
+ "exit_code": 2,
372
+ "run_id": run_id,
373
+ "error": str(e),
374
+ "run_assets": _empty_run_assets(),
375
+ }
376
+
377
+ run_assets = bundle.get("run_assets", {}) or _empty_run_assets()
378
+ return {
379
+ "ok": True,
380
+ "operation": "load_run",
381
+ "exit_code": 0,
382
+ "run_id": bundle.get("run_id", "") or run_id,
383
+ "run_dir": bundle.get("run_dir", str(run_dir)),
384
+ "summary_path": run_assets.get("summary_path", ""),
385
+ "summary": bundle.get("summary", {}) or {},
386
+ "run_assets": run_assets,
387
+ "recommended_next_step": run_assets.get("recommended_next_step"),
388
+ "resume_context": bundle.get("resume_context", {}) or {},
389
+ }
390
+
391
+
392
+ def _run_tool_request(request) -> int:
393
+ return _emit_tool_response(build_tool_response_payload(request))
394
+
395
+
396
+ def run_tool_request_mode(args) -> int:
397
+ try:
398
+ request = load_tool_request(args.tool_request)
399
+ except ToolRequestError as e:
400
+ return _emit_tool_response(
401
+ {
402
+ "ok": False,
403
+ "operation": "tool_request",
404
+ "exit_code": 2,
405
+ "error": str(e),
406
+ }
407
+ )
408
+ return _run_tool_request(request)
409
+
410
+
411
+ def run_tool_stdin_mode(args) -> int:
412
+ try:
413
+ request = load_tool_request_from_stdin(sys.stdin.read())
414
+ except ToolRequestError as e:
415
+ return _emit_tool_response(
416
+ {
417
+ "ok": False,
418
+ "operation": "tool_stdin",
419
+ "exit_code": 2,
420
+ "error": str(e),
421
+ }
422
+ )
423
+ return _run_tool_request(request)
424
+
425
+
426
+ def run_mcp_server_mode(args) -> int:
427
+ from functools import partial
428
+
429
+ from common.mcp_server import run_stdio_mcp_server
430
+
431
+ shared_mgr = _SharedAdapterManager()
432
+ try:
433
+ return run_stdio_mcp_server(
434
+ partial(build_tool_response_payload, shared_adapter_manager=shared_mgr),
435
+ build_load_run_payload,
436
+ partial(build_inspect_ui_payload, shared_adapter_manager=shared_mgr),
437
+ lambda request: build_load_case_memory_payload(
438
+ platform=request.platform,
439
+ control_kind=request.control_kind,
440
+ query=request.query,
441
+ source_ref=request.source_ref,
442
+ limit=request.limit,
443
+ ),
444
+ )
445
+ finally:
446
+ shared_mgr.teardown_all()
common/__init__.py ADDED
File without changes
@@ -0,0 +1,21 @@
1
+ from .base_adapter import BasePlatformAdapter
2
+
3
+ __all__ = [
4
+ "BasePlatformAdapter",
5
+ "AndroidU2Adapter",
6
+ "IosWdaAdapter",
7
+ "WebPlaywrightAdapter",
8
+ ]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "AndroidU2Adapter":
13
+ from .android_adapter import AndroidU2Adapter
14
+ return AndroidU2Adapter
15
+ if name == "IosWdaAdapter":
16
+ from .ios_adapter import IosWdaAdapter
17
+ return IosWdaAdapter
18
+ if name == "WebPlaywrightAdapter":
19
+ from .web_adapter import WebPlaywrightAdapter
20
+ return WebPlaywrightAdapter
21
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")