screenforge 0.6.1__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 (135) hide show
  1. {screenforge-0.6.1 → screenforge-0.10.0}/PKG-INFO +4 -1
  2. {screenforge-0.6.1 → screenforge-0.10.0}/README.md +3 -0
  3. screenforge-0.10.0/cli/_version.py +1 -0
  4. {screenforge-0.6.1 → screenforge-0.10.0}/cli/doctor.py +0 -4
  5. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/action.py +11 -6
  6. {screenforge-0.6.1 → screenforge-0.10.0}/cli/session.py +1 -5
  7. {screenforge-0.6.1 → screenforge-0.10.0}/cli/shared.py +0 -9
  8. {screenforge-0.6.1 → screenforge-0.10.0}/cli/tool_protocol_handlers.py +44 -17
  9. {screenforge-0.6.1 → screenforge-0.10.0}/common/adapters/base_adapter.py +5 -0
  10. {screenforge-0.6.1 → screenforge-0.10.0}/common/ai.py +13 -8
  11. {screenforge-0.6.1 → screenforge-0.10.0}/common/ai_autonomous.py +7 -27
  12. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/cache_stats.py +0 -5
  13. {screenforge-0.6.1 → screenforge-0.10.0}/common/case_memory.py +14 -2
  14. {screenforge-0.6.1 → screenforge-0.10.0}/common/executor.py +134 -125
  15. {screenforge-0.6.1 → screenforge-0.10.0}/common/failure_diagnosis.py +40 -4
  16. {screenforge-0.6.1 → screenforge-0.10.0}/common/mcp_server.py +25 -7
  17. screenforge-0.10.0/common/observation.py +48 -0
  18. {screenforge-0.6.1 → screenforge-0.10.0}/common/tool_protocol.py +6 -0
  19. {screenforge-0.6.1 → screenforge-0.10.0}/config/config.py +2 -4
  20. screenforge-0.10.0/config/env_loader.py +23 -0
  21. {screenforge-0.6.1 → screenforge-0.10.0}/pyproject.toml +10 -1
  22. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/PKG-INFO +4 -1
  23. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/SOURCES.txt +22 -0
  24. screenforge-0.10.0/tests/test_cache_hash.py +52 -0
  25. screenforge-0.10.0/tests/test_case_memory_identity.py +93 -0
  26. screenforge-0.10.0/tests/test_failure_diagnosis_ios.py +62 -0
  27. screenforge-0.10.0/tests/test_failure_diagnosis_mobile.py +62 -0
  28. screenforge-0.10.0/tests/test_forward_candidates.py +61 -0
  29. screenforge-0.10.0/tests/test_inspect_ui_candidates.py +67 -0
  30. screenforge-0.10.0/tests/test_inspect_ui_envelope.py +92 -0
  31. screenforge-0.10.0/tests/test_inspect_ui_memory.py +116 -0
  32. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_mcp_ref_cache.py +1 -1
  33. screenforge-0.10.0/tests/test_mcp_tool_result.py +68 -0
  34. screenforge-0.10.0/tests/test_observation.py +76 -0
  35. screenforge-0.10.0/tests/test_observation_action_parity.py +59 -0
  36. screenforge-0.10.0/tests/test_review_cases.py +98 -0
  37. screenforge-0.10.0/tests/test_review_conftest_gating.py +24 -0
  38. screenforge-0.10.0/tests/test_review_filmstrip.py +46 -0
  39. screenforge-0.10.0/tests/test_review_html.py +73 -0
  40. screenforge-0.10.0/tests/test_review_json.py +26 -0
  41. screenforge-0.10.0/tests/test_review_locate_frame.py +32 -0
  42. screenforge-0.10.0/tests/test_review_patching.py +87 -0
  43. screenforge-0.10.0/tests/test_review_recorder.py +66 -0
  44. screenforge-0.10.0/tests/test_review_screenshot.py +41 -0
  45. screenforge-0.10.0/tests/test_review_smoke_live.py +89 -0
  46. screenforge-0.6.1/cli/_version.py +0 -1
  47. screenforge-0.6.1/config/env_loader.py +0 -76
  48. {screenforge-0.6.1 → screenforge-0.10.0}/LICENSE +0 -0
  49. {screenforge-0.6.1 → screenforge-0.10.0}/cli/__init__.py +0 -0
  50. {screenforge-0.6.1 → screenforge-0.10.0}/cli/dispatch.py +0 -0
  51. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/__init__.py +0 -0
  52. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/default.py +0 -0
  53. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/demo.py +0 -0
  54. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/dry_run.py +0 -0
  55. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/init.py +0 -0
  56. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/plan.py +0 -0
  57. {screenforge-0.6.1 → screenforge-0.10.0}/cli/modes/workflow.py +0 -0
  58. {screenforge-0.6.1 → screenforge-0.10.0}/cli/parser.py +0 -0
  59. {screenforge-0.6.1 → screenforge-0.10.0}/cli/playground_sink.py +0 -0
  60. {screenforge-0.6.1 → screenforge-0.10.0}/cli/reporter.py +0 -0
  61. {screenforge-0.6.1 → screenforge-0.10.0}/cli/shorthand.py +0 -0
  62. {screenforge-0.6.1 → screenforge-0.10.0}/common/__init__.py +0 -0
  63. {screenforge-0.6.1 → screenforge-0.10.0}/common/adapters/__init__.py +0 -0
  64. {screenforge-0.6.1 → screenforge-0.10.0}/common/adapters/android_adapter.py +0 -0
  65. {screenforge-0.6.1 → screenforge-0.10.0}/common/adapters/ios_adapter.py +0 -0
  66. {screenforge-0.6.1 → screenforge-0.10.0}/common/adapters/web_adapter.py +0 -0
  67. {screenforge-0.6.1 → screenforge-0.10.0}/common/ai_heal.py +0 -0
  68. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/__init__.py +0 -0
  69. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/cache_hash.py +0 -0
  70. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/cache_manager.py +0 -0
  71. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/cache_storage.py +0 -0
  72. {screenforge-0.6.1 → screenforge-0.10.0}/common/cache/embedding_loader.py +0 -0
  73. {screenforge-0.6.1 → screenforge-0.10.0}/common/capabilities.py +0 -0
  74. {screenforge-0.6.1 → screenforge-0.10.0}/common/error_codes.py +0 -0
  75. {screenforge-0.6.1 → screenforge-0.10.0}/common/exceptions.py +0 -0
  76. {screenforge-0.6.1 → screenforge-0.10.0}/common/history_manager.py +0 -0
  77. {screenforge-0.6.1 → screenforge-0.10.0}/common/logs.py +0 -0
  78. {screenforge-0.6.1 → screenforge-0.10.0}/common/preflight.py +0 -0
  79. {screenforge-0.6.1 → screenforge-0.10.0}/common/progress.py +0 -0
  80. {screenforge-0.6.1 → screenforge-0.10.0}/common/run_reporter.py +0 -0
  81. {screenforge-0.6.1 → screenforge-0.10.0}/common/run_resume.py +0 -0
  82. {screenforge-0.6.1 → screenforge-0.10.0}/common/runtime_modes.py +0 -0
  83. {screenforge-0.6.1 → screenforge-0.10.0}/common/visual_fallback.py +0 -0
  84. {screenforge-0.6.1 → screenforge-0.10.0}/common/workflow_schema.py +0 -0
  85. {screenforge-0.6.1 → screenforge-0.10.0}/config/__init__.py +0 -0
  86. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/dependency_links.txt +0 -0
  87. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/entry_points.txt +0 -0
  88. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/requires.txt +0 -0
  89. {screenforge-0.6.1 → screenforge-0.10.0}/screenforge.egg-info/top_level.txt +0 -0
  90. {screenforge-0.6.1 → screenforge-0.10.0}/setup.cfg +0 -0
  91. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_action_mode_observation_stash.py +0 -0
  92. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_ai_autonomous.py +0 -0
  93. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_ai_brain.py +0 -0
  94. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_ai_heal.py +0 -0
  95. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_android_smoke_live.py +0 -0
  96. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_cache_manager.py +0 -0
  97. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_capabilities.py +0 -0
  98. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_cli_action_json.py +0 -0
  99. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_codegen_quality.py +0 -0
  100. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_dispatch.py +0 -0
  101. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_doctor_orphan_browser.py +0 -0
  102. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_dom_capture.py +0 -0
  103. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_error_codes.py +0 -0
  104. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_executor.py +0 -0
  105. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_failure_diagnosis.py +0 -0
  106. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_interaction_actions.py +0 -0
  107. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_ios_smoke_live.py +0 -0
  108. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_mcp_execute_observation.py +0 -0
  109. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_mcp_execute_parity_live.py +0 -0
  110. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_ml_optional.py +0 -0
  111. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_mode_error_code_threading.py +0 -0
  112. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_parser.py +0 -0
  113. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_playground_app.py +0 -0
  114. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_playground_sink.py +0 -0
  115. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_playground_sink_integration.py +0 -0
  116. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_run_reporter.py +0 -0
  117. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_run_resume.py +0 -0
  118. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_runtime_modes.py +0 -0
  119. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_screenshot_annotator.py +0 -0
  120. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_shared_observation_stash.py +0 -0
  121. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_shorthand.py +0 -0
  122. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  123. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_utils_ios.py +0 -0
  124. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_utils_web.py +0 -0
  125. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_utils_xml.py +0 -0
  126. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_visual_fallback.py +0 -0
  127. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_web_adapter.py +0 -0
  128. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_web_dom_complex_live.py +0 -0
  129. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_web_smoke_live.py +0 -0
  130. {screenforge-0.6.1 → screenforge-0.10.0}/tests/test_workflow_mode_observation_stash.py +0 -0
  131. {screenforge-0.6.1 → screenforge-0.10.0}/utils/__init__.py +0 -0
  132. {screenforge-0.6.1 → screenforge-0.10.0}/utils/screenshot_annotator.py +0 -0
  133. {screenforge-0.6.1 → screenforge-0.10.0}/utils/utils_ios.py +0 -0
  134. {screenforge-0.6.1 → screenforge-0.10.0}/utils/utils_web.py +0 -0
  135. {screenforge-0.6.1 → 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.6.1
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
@@ -119,6 +119,9 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
119
119
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
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
+ - **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).
122
125
 
123
126
  ## Agent Integration (Claude Code / Cursor / Codex)
124
127
 
@@ -74,6 +74,9 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
74
74
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
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
+ - **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).
77
80
 
78
81
  ## Agent Integration (Claude Code / Cursor / Codex)
79
82
 
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -373,10 +373,6 @@ def _build_doctor_summary(checks: list[dict]) -> dict:
373
373
  }
374
374
 
375
375
 
376
- def _build_doctor_remediation_items(checks: list[dict]) -> list[str]:
377
- return _build_doctor_summary(checks).get("top_items", [])
378
-
379
-
380
376
  def _build_doctor_check_failure_message(check: dict) -> str:
381
377
  details = []
382
378
 
@@ -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
 
@@ -18,10 +18,6 @@ def _session_file(session_id: str) -> str:
18
18
  return os.path.join(_SESSION_DIR, f"{session_id}.json")
19
19
 
20
20
 
21
- def session_exists(session_id: str) -> bool:
22
- return os.path.exists(_session_file(session_id))
23
-
24
-
25
21
  def load_session(session_id: str) -> dict | None:
26
22
  path = _session_file(session_id)
27
23
  if not os.path.exists(path):
@@ -125,7 +121,7 @@ def stop_session_recording(session_id: str) -> str:
125
121
  pgid = os.getpgid(pid)
126
122
  os.killpg(pgid, signal.SIGINT)
127
123
  os.waitpid(pid, 0)
128
- except (OSError, ChildProcessError):
124
+ except OSError:
129
125
  try:
130
126
  os.kill(pid, signal.SIGINT)
131
127
  time.sleep(2)
@@ -3,12 +3,6 @@
3
3
  import base64
4
4
  import time
5
5
 
6
- from common.capabilities import (
7
- ACTIONS_REQUIRING_EXTRA_VALUE,
8
- GLOBAL_ACTIONS,
9
- SUPPORTED_ACTIONS,
10
- )
11
-
12
6
 
13
7
  class _LazyProxy:
14
8
  def __init__(self, loader):
@@ -62,9 +56,6 @@ load_workflow_file = None
62
56
  WorkflowLoadError = None
63
57
  parse_workflow_var_overrides = None
64
58
  resolve_workflow_definition = None
65
- SUPPORTED_INLINE_ACTIONS = SUPPORTED_ACTIONS
66
- GLOBAL_INLINE_ACTIONS = GLOBAL_ACTIONS
67
- INLINE_ACTIONS_REQUIRING_EXTRA_VALUE = ACTIONS_REQUIRING_EXTRA_VALUE
68
59
 
69
60
 
70
61
  def _slug_to_test_name(label: str) -> str:
@@ -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:
@@ -17,6 +17,11 @@ class BasePlatformAdapter(ABC):
17
17
  def take_screenshot(self) -> bytes:
18
18
  pass
19
19
 
20
+ # 录像 seam(诚实边界):
21
+ # - android/ios:start_record/stop_record_and_get_path 落真视频文件(scrcpy / simctl)。
22
+ # - web:start_record 是 no-op(CDP attach 无法用 Playwright 录像);web 的"录像"
23
+ # 由 review 层用逐操作截图 ffmpeg 拼胶片(review/render.py make_filmstrip)实现,
24
+ # 不走本 seam。调用方须把返回 "" / None 当作"无视频",非错误。
20
25
  def start_record(self, video_name: str):
21
26
  pass
22
27
 
@@ -9,6 +9,15 @@ from common.logs import log
9
9
  from common.progress import ai_status
10
10
 
11
11
 
12
+ def _strip_json_fences(text: str) -> str:
13
+ """Unwrap markdown code fences the model may add despite being told not to."""
14
+ if "```json" in text:
15
+ return text.split("```json")[1].split("```")[0].strip()
16
+ if "```" in text:
17
+ return text.replace("```", "").strip()
18
+ return text
19
+
20
+
12
21
  class AIBrain:
13
22
  def __init__(self):
14
23
  # 实例化文本专属客户端
@@ -251,6 +260,7 @@ class AIBrain:
251
260
  """
252
261
  封装底层的 LLM 网络调用
253
262
  """
263
+ result_text = ""
254
264
  try:
255
265
  with ai_status(f"Thinking ({model_name})..."):
256
266
  response = client.chat.completions.create(
@@ -262,16 +272,11 @@ class AIBrain:
262
272
  temperature=0.1,
263
273
  )
264
274
 
265
- result_text = response.choices[0].message.content.strip()
266
-
267
- if "```json" in result_text:
268
- result_text = result_text.split("```json")[1].split("```")[0].strip()
269
- elif "```" in result_text:
270
- result_text = result_text.replace("```", "").strip()
271
-
275
+ result_text = _strip_json_fences(response.choices[0].message.content.strip())
272
276
  parsed_json = json.loads(result_text)
273
277
  return parsed_json.get("result", {})
274
278
 
275
279
  except Exception as e:
276
- log.error(f"[Error] Model ({model_name}) request or parse failed: {e}")
280
+ # {} = "retry without cache" per caller contract; log raw text so parse-fail network-fail.
281
+ log.error(f"[Error] Model ({model_name}) request or parse failed: {e}\nRaw response: {result_text}")
277
282
  return {}
@@ -1,7 +1,7 @@
1
1
  import json
2
2
 
3
3
  import config.config as config
4
- from common.ai import AIBrain
4
+ from common.ai import AIBrain, _strip_json_fences
5
5
  from common.logs import log
6
6
  from common.progress import ai_status
7
7
 
@@ -62,14 +62,10 @@ class AutonomousBrain(AIBrain):
62
62
  )
63
63
 
64
64
  if screenshot_base64:
65
- active_client = getattr(self, "vision_client", None) or getattr(
66
- self, "text_client", None
67
- )
65
+ active_client = self.vision_client or self.text_client
68
66
  active_model = config.VISION_MODEL_NAME
69
67
  else:
70
- active_client = getattr(self, "text_client", None) or getattr(
71
- self, "client", None
72
- )
68
+ active_client = self.text_client
73
69
  active_model = config.MODEL_NAME
74
70
 
75
71
  if not active_client:
@@ -92,13 +88,7 @@ class AutonomousBrain(AIBrain):
92
88
  ],
93
89
  temperature=0.1,
94
90
  )
95
- result_text = response.choices[0].message.content.strip()
96
-
97
- if "```json" in result_text:
98
- result_text = result_text.split("```json")[1].split("```")[0].strip()
99
- elif "```" in result_text:
100
- result_text = result_text.replace("```", "").strip()
101
-
91
+ result_text = _strip_json_fences(response.choices[0].message.content.strip())
102
92
  parsed_json = json.loads(result_text)
103
93
  parsed_json.setdefault("current_state_summary", "")
104
94
  parsed_json.setdefault("planned_steps", [])
@@ -216,14 +206,10 @@ class AutonomousBrain(AIBrain):
216
206
  )
217
207
 
218
208
  if screenshot_base64:
219
- active_client = getattr(self, "vision_client", None) or getattr(
220
- self, "text_client", None
221
- ) # 兼容配置
209
+ active_client = self.vision_client or self.text_client # 兼容配置
222
210
  active_model = config.VISION_MODEL_NAME
223
211
  else:
224
- active_client = getattr(self, "text_client", None) or getattr(
225
- self, "client", None
226
- )
212
+ active_client = self.text_client
227
213
  active_model = config.MODEL_NAME
228
214
 
229
215
  if not active_client:
@@ -248,13 +234,7 @@ class AutonomousBrain(AIBrain):
248
234
  temperature=0.1,
249
235
  )
250
236
 
251
- result_text = response.choices[0].message.content.strip()
252
-
253
- if "```json" in result_text:
254
- result_text = result_text.split("```json")[1].split("```")[0].strip()
255
- elif "```" in result_text:
256
- result_text = result_text.replace("```", "").strip()
257
-
237
+ result_text = _strip_json_fences(response.choices[0].message.content.strip())
258
238
  parsed_json = json.loads(result_text)
259
239
 
260
240
  thought = parsed_json.get("thought", "无")
@@ -48,11 +48,6 @@ class CacheStats:
48
48
  def last_cache_date(self) -> Optional[str]:
49
49
  return self._last_cache_date
50
50
 
51
- def increment_query(self) -> None:
52
- self._total_queries += 1
53
- self._update_last_cache_date()
54
- self._save_stats()
55
-
56
51
  def increment_hit(self) -> None:
57
52
  self._total_queries += 1
58
53
  self._cache_hits += 1
@@ -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: