screenforge 0.7.0__tar.gz → 0.10.0__tar.gz

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 (134) hide show
  1. {screenforge-0.7.0 → screenforge-0.10.0}/PKG-INFO +3 -1
  2. {screenforge-0.7.0 → screenforge-0.10.0}/README.md +2 -0
  3. screenforge-0.10.0/cli/_version.py +1 -0
  4. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/action.py +11 -6
  5. {screenforge-0.7.0 → screenforge-0.10.0}/cli/tool_protocol_handlers.py +44 -17
  6. {screenforge-0.7.0 → screenforge-0.10.0}/common/case_memory.py +14 -2
  7. {screenforge-0.7.0 → screenforge-0.10.0}/common/failure_diagnosis.py +40 -4
  8. {screenforge-0.7.0 → screenforge-0.10.0}/common/mcp_server.py +25 -7
  9. screenforge-0.10.0/common/observation.py +48 -0
  10. {screenforge-0.7.0 → screenforge-0.10.0}/common/tool_protocol.py +6 -0
  11. {screenforge-0.7.0 → screenforge-0.10.0}/pyproject.toml +1 -1
  12. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/PKG-INFO +3 -1
  13. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/SOURCES.txt +11 -0
  14. screenforge-0.10.0/tests/test_case_memory_identity.py +93 -0
  15. screenforge-0.10.0/tests/test_failure_diagnosis_ios.py +62 -0
  16. screenforge-0.10.0/tests/test_failure_diagnosis_mobile.py +62 -0
  17. screenforge-0.10.0/tests/test_forward_candidates.py +61 -0
  18. screenforge-0.10.0/tests/test_inspect_ui_candidates.py +67 -0
  19. screenforge-0.10.0/tests/test_inspect_ui_envelope.py +92 -0
  20. screenforge-0.10.0/tests/test_inspect_ui_memory.py +116 -0
  21. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_ref_cache.py +1 -1
  22. screenforge-0.10.0/tests/test_mcp_tool_result.py +68 -0
  23. screenforge-0.10.0/tests/test_observation.py +76 -0
  24. screenforge-0.10.0/tests/test_observation_action_parity.py +59 -0
  25. screenforge-0.7.0/cli/_version.py +0 -1
  26. {screenforge-0.7.0 → screenforge-0.10.0}/LICENSE +0 -0
  27. {screenforge-0.7.0 → screenforge-0.10.0}/cli/__init__.py +0 -0
  28. {screenforge-0.7.0 → screenforge-0.10.0}/cli/dispatch.py +0 -0
  29. {screenforge-0.7.0 → screenforge-0.10.0}/cli/doctor.py +0 -0
  30. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/__init__.py +0 -0
  31. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/default.py +0 -0
  32. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/demo.py +0 -0
  33. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/dry_run.py +0 -0
  34. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/init.py +0 -0
  35. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/plan.py +0 -0
  36. {screenforge-0.7.0 → screenforge-0.10.0}/cli/modes/workflow.py +0 -0
  37. {screenforge-0.7.0 → screenforge-0.10.0}/cli/parser.py +0 -0
  38. {screenforge-0.7.0 → screenforge-0.10.0}/cli/playground_sink.py +0 -0
  39. {screenforge-0.7.0 → screenforge-0.10.0}/cli/reporter.py +0 -0
  40. {screenforge-0.7.0 → screenforge-0.10.0}/cli/session.py +0 -0
  41. {screenforge-0.7.0 → screenforge-0.10.0}/cli/shared.py +0 -0
  42. {screenforge-0.7.0 → screenforge-0.10.0}/cli/shorthand.py +0 -0
  43. {screenforge-0.7.0 → screenforge-0.10.0}/common/__init__.py +0 -0
  44. {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/__init__.py +0 -0
  45. {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/android_adapter.py +0 -0
  46. {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/base_adapter.py +0 -0
  47. {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/ios_adapter.py +0 -0
  48. {screenforge-0.7.0 → screenforge-0.10.0}/common/adapters/web_adapter.py +0 -0
  49. {screenforge-0.7.0 → screenforge-0.10.0}/common/ai.py +0 -0
  50. {screenforge-0.7.0 → screenforge-0.10.0}/common/ai_autonomous.py +0 -0
  51. {screenforge-0.7.0 → screenforge-0.10.0}/common/ai_heal.py +0 -0
  52. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/__init__.py +0 -0
  53. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_hash.py +0 -0
  54. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_manager.py +0 -0
  55. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_stats.py +0 -0
  56. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/cache_storage.py +0 -0
  57. {screenforge-0.7.0 → screenforge-0.10.0}/common/cache/embedding_loader.py +0 -0
  58. {screenforge-0.7.0 → screenforge-0.10.0}/common/capabilities.py +0 -0
  59. {screenforge-0.7.0 → screenforge-0.10.0}/common/error_codes.py +0 -0
  60. {screenforge-0.7.0 → screenforge-0.10.0}/common/exceptions.py +0 -0
  61. {screenforge-0.7.0 → screenforge-0.10.0}/common/executor.py +0 -0
  62. {screenforge-0.7.0 → screenforge-0.10.0}/common/history_manager.py +0 -0
  63. {screenforge-0.7.0 → screenforge-0.10.0}/common/logs.py +0 -0
  64. {screenforge-0.7.0 → screenforge-0.10.0}/common/preflight.py +0 -0
  65. {screenforge-0.7.0 → screenforge-0.10.0}/common/progress.py +0 -0
  66. {screenforge-0.7.0 → screenforge-0.10.0}/common/run_reporter.py +0 -0
  67. {screenforge-0.7.0 → screenforge-0.10.0}/common/run_resume.py +0 -0
  68. {screenforge-0.7.0 → screenforge-0.10.0}/common/runtime_modes.py +0 -0
  69. {screenforge-0.7.0 → screenforge-0.10.0}/common/visual_fallback.py +0 -0
  70. {screenforge-0.7.0 → screenforge-0.10.0}/common/workflow_schema.py +0 -0
  71. {screenforge-0.7.0 → screenforge-0.10.0}/config/__init__.py +0 -0
  72. {screenforge-0.7.0 → screenforge-0.10.0}/config/config.py +0 -0
  73. {screenforge-0.7.0 → screenforge-0.10.0}/config/env_loader.py +0 -0
  74. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/dependency_links.txt +0 -0
  75. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/entry_points.txt +0 -0
  76. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/requires.txt +0 -0
  77. {screenforge-0.7.0 → screenforge-0.10.0}/screenforge.egg-info/top_level.txt +0 -0
  78. {screenforge-0.7.0 → screenforge-0.10.0}/setup.cfg +0 -0
  79. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_action_mode_observation_stash.py +0 -0
  80. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_autonomous.py +0 -0
  81. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_brain.py +0 -0
  82. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ai_heal.py +0 -0
  83. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_android_smoke_live.py +0 -0
  84. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cache_hash.py +0 -0
  85. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cache_manager.py +0 -0
  86. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_capabilities.py +0 -0
  87. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_cli_action_json.py +0 -0
  88. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_codegen_quality.py +0 -0
  89. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_dispatch.py +0 -0
  90. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_doctor_orphan_browser.py +0 -0
  91. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_dom_capture.py +0 -0
  92. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_error_codes.py +0 -0
  93. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_executor.py +0 -0
  94. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_failure_diagnosis.py +0 -0
  95. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_interaction_actions.py +0 -0
  96. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ios_smoke_live.py +0 -0
  97. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_execute_observation.py +0 -0
  98. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mcp_execute_parity_live.py +0 -0
  99. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_ml_optional.py +0 -0
  100. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_mode_error_code_threading.py +0 -0
  101. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_parser.py +0 -0
  102. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_app.py +0 -0
  103. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_sink.py +0 -0
  104. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_playground_sink_integration.py +0 -0
  105. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_cases.py +0 -0
  106. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_conftest_gating.py +0 -0
  107. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_filmstrip.py +0 -0
  108. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_html.py +0 -0
  109. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_json.py +0 -0
  110. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_locate_frame.py +0 -0
  111. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_patching.py +0 -0
  112. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_recorder.py +0 -0
  113. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_screenshot.py +0 -0
  114. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_review_smoke_live.py +0 -0
  115. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_run_reporter.py +0 -0
  116. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_run_resume.py +0 -0
  117. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_runtime_modes.py +0 -0
  118. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_screenshot_annotator.py +0 -0
  119. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_shared_observation_stash.py +0 -0
  120. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_shorthand.py +0 -0
  121. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  122. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_ios.py +0 -0
  123. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_web.py +0 -0
  124. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_utils_xml.py +0 -0
  125. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_visual_fallback.py +0 -0
  126. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_adapter.py +0 -0
  127. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_dom_complex_live.py +0 -0
  128. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_web_smoke_live.py +0 -0
  129. {screenforge-0.7.0 → screenforge-0.10.0}/tests/test_workflow_mode_observation_stash.py +0 -0
  130. {screenforge-0.7.0 → screenforge-0.10.0}/utils/__init__.py +0 -0
  131. {screenforge-0.7.0 → screenforge-0.10.0}/utils/screenshot_annotator.py +0 -0
  132. {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_ios.py +0 -0
  133. {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_web.py +0 -0
  134. {screenforge-0.7.0 → screenforge-0.10.0}/utils/utils_xml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.7.0
3
+ Version: 0.10.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -120,6 +120,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
120
120
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
121
121
  - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
122
122
  - **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
123
+ - **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
124
+ - **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
123
125
 
124
126
  ## Agent Integration (Claude Code / Cursor / Codex)
125
127
 
@@ -75,6 +75,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
75
75
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
76
76
  - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
77
77
  - **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
78
+ - **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
79
+ - **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
78
80
 
79
81
  ## Agent Integration (Claude Code / Cursor / Codex)
80
82
 
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -26,6 +26,7 @@ from cli.shared import (
26
26
  save_to_disk,
27
27
  )
28
28
  from common.failure_diagnosis import diagnose
29
+ from common.observation import build_observation
29
30
  from common.runtime_modes import MODE_RUN
30
31
 
31
32
 
@@ -69,9 +70,11 @@ def build_failure_payload(
69
70
  ui_elements=ui_tree.get("ui_elements", []) or [],
70
71
  )
71
72
  payload.update(diag.to_dict())
72
- payload["ui_tree"] = ui_tree
73
- payload["element_count"] = len(ui_tree.get("ui_elements", []) or [])
74
- payload["current_url"] = current_url
73
+ observation = build_observation(ui_tree=ui_tree, current_url=current_url)
74
+ observation.pop("screenshot_base64") # engine_error has never carried one
75
+ observation.pop("memory") # decision-time hint; action is post-decision
76
+ observation.pop("candidates") # engine_error already has its own diagnosis candidates
77
+ payload.update(observation)
75
78
  return payload
76
79
 
77
80
 
@@ -88,14 +91,16 @@ def build_success_payload(
88
91
  Single source of truth shared by the shell --json stdout write and the MCP
89
92
  observation stash, so the two can never drift. Pure: no I/O, no device.
90
93
  """
94
+ observation = build_observation(ui_tree=ui_tree, current_url=current_url)
95
+ observation.pop("screenshot_base64") # action payloads have never carried one
96
+ observation.pop("memory") # decision-time hint; action is post-decision
97
+ observation.pop("candidates") # engine_error already has its own diagnosis candidates
91
98
  return {
92
99
  "ok": True,
93
100
  "action": action_name,
94
101
  "platform": platform,
95
- "ui_tree": ui_tree,
96
- "element_count": len(ui_tree.get("ui_elements", []) or []),
102
+ **observation,
97
103
  "output_script": output_script,
98
- "current_url": current_url,
99
104
  }
100
105
 
101
106
 
@@ -20,6 +20,8 @@ from cli.shared import (
20
20
  current_url,
21
21
  log,
22
22
  )
23
+ from common.failure_diagnosis import rank_candidates
24
+ from common.observation import build_observation
23
25
  from common.run_resume import RunContextLoadError, load_run_bundle
24
26
  from common.runtime_modes import MODE_DOCTOR, resolve_execution_mode
25
27
  from common.tool_protocol import (
@@ -137,6 +139,30 @@ def _find_case_memory_hit(args, execution_mode: str) -> dict | None:
137
139
  )
138
140
 
139
141
 
142
+ def _lookup_task_memory(platform: str, task: str) -> dict:
143
+ """Case-memory hint for an inspect_ui request, or {} when there's nothing.
144
+
145
+ A HINT, not an instruction — see common/observation.py. Matching is fuzzy by
146
+ design (find_entry ORs control_label with source_ref), so a wrong hit costs
147
+ one useless hint, never a wrong click. control_kind is "action" because an
148
+ inspect precedes a single action, and that's the kind those runs record under.
149
+ Any read failure degrades to {}: a missing hint must never cost the agent
150
+ its UI tree.
151
+ """
152
+ if not str(task).strip():
153
+ return {}
154
+ try:
155
+ entry = _load_case_memory_store().find_entry(
156
+ platform=platform,
157
+ control_kind="action",
158
+ control_label=task,
159
+ )
160
+ except Exception as e:
161
+ log.warning(f"⚠️ [Warning] Case-memory lookup failed, continuing without hint: {e}")
162
+ return {}
163
+ return entry or {}
164
+
165
+
140
166
  def build_load_case_memory_payload(
141
167
  platform: str = "",
142
168
  control_kind: str = "",
@@ -182,13 +208,6 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
182
208
  1,
183
209
  )
184
210
 
185
- if not screenshot_base64:
186
- try:
187
- img_bytes = adapter.take_screenshot()
188
- screenshot_base64 = base64.b64encode(img_bytes).decode("utf-8")
189
- except Exception as e:
190
- log.warning(f"⚠️ [Warning] inspect_ui screenshot capture failed: {e}")
191
-
192
211
  try:
193
212
  ui_tree = json.loads(ui_json)
194
213
  except json.JSONDecodeError:
@@ -207,30 +226,36 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
207
226
  except Exception as e:
208
227
  log.warning(f"⚠️ [Warning] Failed to sync ref cache from inspect_ui: {e}")
209
228
 
210
- annotated_screenshot_base64 = ""
229
+ # Annotate in place: ref labels are only meaningful where the compressor
230
+ # emits ref + bbox + clickable (web). Mobile trees carry none of those, so
231
+ # annotate_screenshot draws nothing and just re-encodes the image — same
232
+ # picture, no labels. Either way exactly ONE image ships.
211
233
  if screenshot_base64 and ui_tree.get("ui_elements"):
212
234
  try:
213
235
  from utils.screenshot_annotator import annotate_screenshot
214
236
  raw_bytes = base64.b64decode(screenshot_base64)
215
237
  annotated_bytes = annotate_screenshot(raw_bytes, ui_tree["ui_elements"])
216
- annotated_screenshot_base64 = base64.b64encode(annotated_bytes).decode("utf-8")
238
+ screenshot_base64 = base64.b64encode(annotated_bytes).decode("utf-8")
217
239
  except Exception as e:
218
240
  log.warning(f"⚠️ [Warning] Annotated screenshot generation failed: {e}")
219
241
 
220
- page_url = current_url(adapter, request.platform)
221
-
222
242
  return {
223
243
  "ok": True,
224
244
  "operation": "inspect_ui",
225
245
  "exit_code": 0,
226
246
  "platform": request.platform,
227
247
  "env": request.env,
228
- "ui_json": ui_json,
229
- "ui_tree": ui_tree,
230
- "element_count": len(ui_tree.get("ui_elements", []) or []),
231
- "screenshot_base64": screenshot_base64 or "",
232
- "annotated_screenshot_base64": annotated_screenshot_base64,
233
- "current_url": page_url,
248
+ **build_observation(
249
+ ui_tree=ui_tree,
250
+ current_url=current_url(adapter, request.platform),
251
+ screenshot_base64=screenshot_base64 or "",
252
+ memory=_lookup_task_memory(request.platform, request.task),
253
+ candidates=(
254
+ rank_candidates(request.intent, ui_tree.get("ui_elements") or [])
255
+ if str(request.intent).strip()
256
+ else []
257
+ ),
258
+ ),
234
259
  }
235
260
  except Exception as e:
236
261
  return {
@@ -241,6 +266,8 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
241
266
  "env": request.env,
242
267
  "error": str(e),
243
268
  "current_url": "",
269
+ "memory": {},
270
+ "candidates": [],
244
271
  }
245
272
  finally:
246
273
  if owns_adapter and adapter:
@@ -24,6 +24,17 @@ def _slugify(value: str) -> str:
24
24
  return text.lower() or "memory"
25
25
 
26
26
 
27
+ # Inline `--action` runs all record this same source_ref: it says "typed on the
28
+ # command line", not "which control". Treating it as an identity made the first
29
+ # inline action on a platform absorb every later one, so a named action could
30
+ # never get its own entry — or be found by its name.
31
+ _NON_IDENTIFYING_SOURCE_REFS = frozenset({"inline://action"})
32
+
33
+
34
+ def _identifying_source_ref(source_ref: str) -> str:
35
+ return "" if source_ref in _NON_IDENTIFYING_SOURCE_REFS else source_ref
36
+
37
+
27
38
  def _build_memory_id(
28
39
  platform: str,
29
40
  control_kind: str,
@@ -240,7 +251,7 @@ class CaseMemoryStore:
240
251
  normalized_platform = _normalize_text(platform).lower()
241
252
  normalized_kind = _normalize_text(control_kind).lower()
242
253
  normalized_label = _normalize_text(control_label)
243
- normalized_source_ref = _normalize_text(source_ref)
254
+ normalized_source_ref = _identifying_source_ref(_normalize_text(source_ref))
244
255
  document = self.load_document()
245
256
 
246
257
  for entry in document.entries:
@@ -277,10 +288,11 @@ class CaseMemoryStore:
277
288
 
278
289
  document = self.load_document()
279
290
  existing_entry = None
291
+ identifying_source_ref = _identifying_source_ref(source_ref)
280
292
  for entry in document.entries:
281
293
  if entry.platform != platform or entry.control_kind != control_kind:
282
294
  continue
283
- if source_ref and entry.source_ref == source_ref:
295
+ if identifying_source_ref and entry.source_ref == identifying_source_ref:
284
296
  existing_entry = entry
285
297
  break
286
298
  if entry.control_label == control_label:
@@ -17,9 +17,12 @@ from common.error_codes import lookup
17
17
  CANDIDATE_THRESHOLD = 0.55 # difflib ratio floor; below this is not a candidate
18
18
  MAX_CANDIDATES = 3
19
19
 
20
- # Element text fields to match against, in locator-priority order. `ref` is web
21
- # only; mobile elements omit it, so a text/description match is the fallback.
22
- _MATCH_FIELDS = ("text", "desc", "name")
20
+ # Element text fields to match against. Every compressor's label-bearing keys
21
+ # must appear here or that platform silently yields zero candidates:
22
+ # utils_xml.py (Android) writes text/desc, utils_web.py writes text/desc/name,
23
+ # utils_ios.py writes ONLY label/name — and suppresses `name` when it equals
24
+ # `label`, so without "label" the typical iOS control matched nothing at all.
25
+ _MATCH_FIELDS = ("text", "desc", "label", "name")
23
26
 
24
27
 
25
28
  @dataclass
@@ -62,12 +65,27 @@ def _best_field(el: dict, target: str) -> tuple[float, str, str]:
62
65
 
63
66
 
64
67
  def _suggested_locator(el: dict, matched_text: str, matched_field: str) -> dict:
65
- """Locator priority: ref (web) > text > description. Mirrors agent_guide."""
68
+ """Locator priority: ref (web) > resourceId > text > description.
69
+
70
+ Mirrors the repo's locator law (CLAUDE.md, and the brain prompt in
71
+ common/ai.py). Mobile compressors (utils/utils_xml.py, utils/utils_ios.py)
72
+ emit `id` but never `ref`, so without the resourceId branch every mobile
73
+ candidate degraded to a text locator — weaker than what the page offered.
74
+ """
66
75
  ref = str(el.get("ref", "")).strip()
67
76
  if ref:
68
77
  return {"type": "ref", "value": ref}
78
+ resource_id = str(el.get("id", "")).strip()
79
+ if resource_id:
80
+ return {"type": "resourceId", "value": resource_id}
69
81
  if matched_field == "text":
70
82
  return {"type": "text", "value": matched_text}
83
+ # iOS is the only platform that reaches here on a `name` match (web always
84
+ # carries a ref, Android an id). executor.py's ios_key_map sends
85
+ # resourceId->name and description->label, so a name match MUST say
86
+ # resourceId or the agent would query the wrong attribute.
87
+ if matched_field == "name":
88
+ return {"type": "resourceId", "value": matched_text}
71
89
  return {"type": "description", "value": matched_text}
72
90
 
73
91
 
@@ -91,6 +109,24 @@ def _rank_candidates(locator_value: str, ui_elements: list[dict]) -> list[Candid
91
109
  return scored[:MAX_CANDIDATES]
92
110
 
93
111
 
112
+ def rank_candidates(phrase: str, ui_elements: list[dict]) -> list[dict]:
113
+ """Rank elements against a plain-language phrase; JSON-ready dicts.
114
+
115
+ The forward half of did-you-mean: instead of explaining a locator that just
116
+ failed, answer "which elements could the agent mean by this sentence?" Same
117
+ ranking, same 0.55 floor, same at-most-3, same rather-nothing-than-a-guess.
118
+
119
+ ponytail: difflib is LITERAL similarity, not semantic — "登录" vs "Sign in"
120
+ scores ~0, so this returns [] on mixed-language pages. Upgrade path if that
121
+ matters: embed phrase + labels and rank by cosine, reusing the embedding
122
+ infrastructure already behind the L2 semantic cache (common/cache/).
123
+ """
124
+ return [
125
+ {"text": c.text, "score": c.score, "locator": c.locator}
126
+ for c in _rank_candidates(phrase, ui_elements)
127
+ ]
128
+
129
+
94
130
  def _recommend(error_code: str, fix: str, candidates: list[Candidate]) -> dict:
95
131
  if candidates:
96
132
  top = candidates[0]
@@ -167,6 +167,14 @@ def _build_inspect_ui_tool_schema() -> dict:
167
167
  "type": "boolean",
168
168
  "description": "是否同时返回截图 base64",
169
169
  },
170
+ "task": {
171
+ "type": "string",
172
+ "description": "可选的任务标签(如 \"点击登录\")。提供时,返回该任务上次成功的定位器作为提示(memory 字段);不提供则 memory 为空对象。",
173
+ },
174
+ "intent": {
175
+ "type": "string",
176
+ "description": "可选的人话短语(如 \"登录按钮\")。提供时,返回按字面相似度排序的候选元素及可直接使用的定位器(candidates 字段);匹配不到则为空数组,不会瞎猜。",
177
+ },
170
178
  },
171
179
  "additionalProperties": False,
172
180
  }
@@ -280,15 +288,25 @@ def _build_initialize_result(protocol_version: str) -> dict:
280
288
 
281
289
 
282
290
  def _build_tool_result(payload: dict) -> dict:
291
+ # structuredContent IS the payload — the MCP client reads it directly. The
292
+ # content array must be present and non-empty per spec, so it carries a SHORT
293
+ # summary rather than a second json.dumps of the same dict. Duplicating a
294
+ # payload that holds a UI tree plus a screenshot was the largest avoidable
295
+ # cost on the wire.
296
+ ok = bool(payload.get("ok", False))
297
+ operation = payload.get("operation", "unknown")
298
+ if ok:
299
+ summary = f"{operation}: ok"
300
+ count = payload.get("element_count")
301
+ if count is not None:
302
+ summary += f" ({count} elements)"
303
+ else:
304
+ detail = str(payload.get("error", "") or payload.get("result", "") or "failed")
305
+ summary = f"{operation}: failed — {detail[:120]}"
283
306
  return {
284
- "content": [
285
- {
286
- "type": "text",
287
- "text": json.dumps(payload, ensure_ascii=False, indent=2),
288
- }
289
- ],
307
+ "content": [{"type": "text", "text": summary}],
290
308
  "structuredContent": payload,
291
- "isError": not bool(payload.get("ok", False)),
309
+ "isError": not ok,
292
310
  }
293
311
 
294
312
 
@@ -0,0 +1,48 @@
1
+ """The ONE agent-facing observation envelope.
2
+
3
+ Every payload the external agent reads — inspect_ui, --action --json success and
4
+ engine_error, the MCP observation stash — composes this function. Adding a field
5
+ means editing THIS file and nothing else; that is the whole point. Two
6
+ constructors used to hand-assemble their own keys and drifted (the inspect
7
+ payload shipped the tree twice and two screenshots; case-memory reached the
8
+ agent only AFTER it had already acted).
9
+
10
+ Pure: no I/O, no device, no config reads — same contract as
11
+ cli/modes/action.py::build_success_payload.
12
+
13
+ Invariants:
14
+ 1. Additive only. Never shrink or truncate ui_tree — a trimmed tree makes the
15
+ agent hit [E030] Ref not found and re-inspect, which costs MORE tokens.
16
+ 2. Honest empties. Absent data is {} / [] / "", never a guess (same rule as
17
+ common/failure_diagnosis.py's below-threshold behavior).
18
+ 3. element_count is DERIVED here, never accepted from a caller, so it can
19
+ never disagree with the tree it describes.
20
+ """
21
+
22
+
23
+ def build_observation(
24
+ *,
25
+ ui_tree: dict,
26
+ current_url: str = "",
27
+ screenshot_base64: str = "",
28
+ memory: dict | None = None,
29
+ candidates: list[dict] | None = None,
30
+ ) -> dict:
31
+ elements = (ui_tree or {}).get("ui_elements") or []
32
+ return {
33
+ "ui_tree": ui_tree or {},
34
+ "element_count": len(elements),
35
+ "current_url": current_url,
36
+ "screenshot_base64": screenshot_base64,
37
+ # A HINT, never an instruction: what the last run of this task DID —
38
+ # its locator and its outcome, success or failure (case_memory records
39
+ # both, so `last_status`/`success_count` are what say which). The agent
40
+ # reads it alongside the live tree and decides whether to trust it. We
41
+ # deliberately do NOT replay it (that is Midscene's XPath-cache route,
42
+ # which silently clicks the wrong thing after a redesign).
43
+ "memory": memory or {},
44
+ # Elements matching the agent's plain-language phrase, best first. Empty
45
+ # is a valid answer — below the similarity floor we return nothing rather
46
+ # than a confident wrong guess.
47
+ "candidates": candidates or [],
48
+ }
@@ -65,6 +65,12 @@ class ToolRequest(BaseModel):
65
65
  resume_run_id: str = ""
66
66
  run_id: str = ""
67
67
  goal: str = ""
68
+ # Optional task label for inspect_ui: lets the payload carry the case-memory
69
+ # hint for THIS task at decision time. Omitted → no lookup, no hint.
70
+ task: str = ""
71
+ # Optional plain-language phrase for inspect_ui: returns ranked candidate
72
+ # elements (the `candidates` field). Omitted → no ranking, empty list.
73
+ intent: str = ""
68
74
  query: str = ""
69
75
  source_ref: str = ""
70
76
  control_kind: str = ""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "screenforge"
3
- version = "0.7.0"
3
+ version = "0.10.0"
4
4
  description = "AI-driven cross-platform UI automation engine with test script generation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.7.0
3
+ Version: 0.10.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -120,6 +120,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
120
120
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
121
121
  - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
122
122
  - **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
123
+ - **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
124
+ - **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
123
125
 
124
126
  ## Agent Integration (Claude Code / Cursor / Codex)
125
127
 
@@ -33,6 +33,7 @@ common/failure_diagnosis.py
33
33
  common/history_manager.py
34
34
  common/logs.py
35
35
  common/mcp_server.py
36
+ common/observation.py
36
37
  common/preflight.py
37
38
  common/progress.py
38
39
  common/run_reporter.py
@@ -69,6 +70,7 @@ tests/test_android_smoke_live.py
69
70
  tests/test_cache_hash.py
70
71
  tests/test_cache_manager.py
71
72
  tests/test_capabilities.py
73
+ tests/test_case_memory_identity.py
72
74
  tests/test_cli_action_json.py
73
75
  tests/test_codegen_quality.py
74
76
  tests/test_dispatch.py
@@ -77,13 +79,22 @@ tests/test_dom_capture.py
77
79
  tests/test_error_codes.py
78
80
  tests/test_executor.py
79
81
  tests/test_failure_diagnosis.py
82
+ tests/test_failure_diagnosis_ios.py
83
+ tests/test_failure_diagnosis_mobile.py
84
+ tests/test_forward_candidates.py
85
+ tests/test_inspect_ui_candidates.py
86
+ tests/test_inspect_ui_envelope.py
87
+ tests/test_inspect_ui_memory.py
80
88
  tests/test_interaction_actions.py
81
89
  tests/test_ios_smoke_live.py
82
90
  tests/test_mcp_execute_observation.py
83
91
  tests/test_mcp_execute_parity_live.py
84
92
  tests/test_mcp_ref_cache.py
93
+ tests/test_mcp_tool_result.py
85
94
  tests/test_ml_optional.py
86
95
  tests/test_mode_error_code_threading.py
96
+ tests/test_observation.py
97
+ tests/test_observation_action_parity.py
87
98
  tests/test_parser.py
88
99
  tests/test_playground_app.py
89
100
  tests/test_playground_sink.py
@@ -0,0 +1,93 @@
1
+ """Inline actions must not all collapse into one case-memory entry.
2
+
3
+ Every inline `--action` run writes the same sentinel `source_ref`
4
+ ("inline://action"), and both the upsert and the lookup matched `source_ref`
5
+ BEFORE `control_label`. So the first inline action recorded on a platform
6
+ absorbed every later one: `--action-name "点击登录"` bumped the counter on the
7
+ pre-existing `swipe:up` entry instead of creating its own, and `task="点击登录"`
8
+ could never hit. The sentinel is provenance for reports, never an identity.
9
+ """
10
+
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from common.case_memory import CaseMemoryStore
15
+
16
+ _SENTINEL = "inline://action"
17
+
18
+
19
+ def _store() -> CaseMemoryStore:
20
+ return CaseMemoryStore(file_path=Path(tempfile.mkdtemp()) / "case_memory.json")
21
+
22
+
23
+ def _run(store: CaseMemoryStore, label: str, source_ref: str = _SENTINEL) -> None:
24
+ store.upsert_from_run(
25
+ summary={
26
+ "execution_mode": "run",
27
+ "platform": "android",
28
+ "status": "success",
29
+ "run_id": "r1",
30
+ "finished_at": "2026-07-27T20:00:00",
31
+ "control_summary": {
32
+ "control_kind": "action",
33
+ "control_label": label,
34
+ "source_ref": source_ref,
35
+ },
36
+ },
37
+ step_records=[],
38
+ )
39
+
40
+
41
+ def test_distinct_action_labels_get_distinct_entries():
42
+ store = _store()
43
+ _run(store, "swipe:up")
44
+ _run(store, "点击登录")
45
+ labels = sorted(e.control_label for e in store.load_document().entries)
46
+ assert labels == ["swipe:up", "点击登录"]
47
+
48
+
49
+ def test_repeating_the_same_label_still_updates_one_entry():
50
+ store = _store()
51
+ _run(store, "swipe:up")
52
+ _run(store, "swipe:up")
53
+ entries = store.load_document().entries
54
+ assert len(entries) == 1
55
+ assert entries[0].success_count == 2
56
+
57
+
58
+ def test_named_action_is_findable_by_its_label():
59
+ store = _store()
60
+ _run(store, "swipe:up")
61
+ _run(store, "点击登录")
62
+ hit = store.find_entry(
63
+ platform="android", control_kind="action", control_label="点击登录"
64
+ )
65
+ assert hit is not None
66
+ assert hit["control_label"] == "点击登录"
67
+
68
+
69
+ def test_sentinel_source_ref_never_matches_a_different_label():
70
+ # The bug's read half: a lookup carrying the sentinel returned whichever
71
+ # inline entry came first, regardless of the label asked for.
72
+ store = _store()
73
+ _run(store, "swipe:up")
74
+ assert (
75
+ store.find_entry(
76
+ platform="android",
77
+ control_kind="action",
78
+ control_label="点击登录",
79
+ source_ref=_SENTINEL,
80
+ )
81
+ is None
82
+ )
83
+
84
+
85
+ def test_real_source_ref_still_identifies_across_a_rename():
86
+ # Workflows carry a real path: renaming the workflow must not orphan its
87
+ # history. This is the behavior the sentinel was free-riding on.
88
+ store = _store()
89
+ _run(store, "old_name", source_ref="/tmp/flow.yaml")
90
+ _run(store, "new_name", source_ref="/tmp/flow.yaml")
91
+ entries = store.load_document().entries
92
+ assert len(entries) == 1
93
+ assert entries[0].success_count == 2
@@ -0,0 +1,62 @@
1
+ """iOS elements carry `label`/`name` and nothing else — match on them.
2
+
3
+ utils/utils_ios.py's compress_ios_source writes only type/label/name/value/
4
+ accessible/disabled: no `text`, no `desc`, no `id`, no `ref`. While _MATCH_FIELDS
5
+ was ("text", "desc", "name") the whole did-you-mean feature was dead on iOS —
6
+ and dead in the worst way, because the compressor drops `name` when it equals
7
+ `label`, so the ordinary control matched nothing while an odd one matched by its
8
+ internal identifier and got handed back as a `description` locator. That is the
9
+ wrong attribute: executor.py's ios_key_map sends description->label.
10
+
11
+ Threshold note: CANDIDATE_THRESHOLD = 0.55, and probes here are exact or near
12
+ matches so they clear it comfortably.
13
+ """
14
+
15
+ from common.failure_diagnosis import diagnose, rank_candidates
16
+
17
+
18
+ def test_label_only_element_is_a_candidate():
19
+ """The common iOS shape: label present, name suppressed as a duplicate."""
20
+ diag = diagnose(
21
+ error_code="E030",
22
+ locator_value="登录",
23
+ ui_elements=[{"type": "Button", "label": "登录"}],
24
+ )
25
+ assert diag.candidates
26
+ assert diag.candidates[0].locator == {"type": "description", "value": "登录"}
27
+
28
+
29
+ def test_label_match_wins_over_name_and_maps_to_label_lookup():
30
+ """A label match must not be reported as resourceId — that would query `name`."""
31
+ diag = diagnose(
32
+ error_code="E030",
33
+ locator_value="登录",
34
+ ui_elements=[{"type": "Button", "label": "登录", "name": "loginBtn"}],
35
+ )
36
+ assert diag.candidates[0].locator == {"type": "description", "value": "登录"}
37
+
38
+
39
+ def test_name_match_maps_to_resource_id_not_description():
40
+ """`name` is iOS's identifier: resourceId->name in executor's ios_key_map."""
41
+ diag = diagnose(
42
+ error_code="E030",
43
+ locator_value="loginBtn",
44
+ ui_elements=[{"type": "Button", "label": "Sign in", "name": "loginBtn"}],
45
+ )
46
+ assert diag.candidates[0].locator == {"type": "resourceId", "value": "loginBtn"}
47
+
48
+
49
+ def test_forward_lookup_also_sees_ios_labels():
50
+ """rank_candidates() shares the matcher, so `intent` works on iOS too."""
51
+ hits = rank_candidates("登录", [{"type": "Button", "label": "登录"}])
52
+ assert [h["locator"] for h in hits] == [{"type": "description", "value": "登录"}]
53
+
54
+
55
+ def test_web_name_attribute_still_yields_ref():
56
+ """Adding `label` must not disturb web: ref outranks every text field."""
57
+ diag = diagnose(
58
+ error_code="E030",
59
+ locator_value="username",
60
+ ui_elements=[{"class": "input", "name": "username", "ref": "@3", "id": "user"}],
61
+ )
62
+ assert diag.candidates[0].locator == {"type": "ref", "value": "@3"}