@capagents/forgeqe 0.0.0-stage → 1.0.0

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,1004 @@
1
+ """
2
+ Page memory: what ForgeQE learned on earlier runs, used to act faster and call the LLM less.
3
+
4
+ Layout under the memory directory:
5
+
6
+ pages/<host>/<page>.json every interactive element seen on that page, each with ranked locators
7
+ flows/<goal>-<hash>.json the actions of the last successful run of a goal, replayable without the LLM
8
+
9
+ A locator's confidence (1-99 %) multiplies four factors: how stable its kind is (test id > id >
10
+ role+name > ... > xpath), how unique it was on the page, how consistently it reappeared on later
11
+ visits, and how often acting through it worked during replay.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ from collections import Counter
20
+ from contextlib import contextmanager
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple
24
+ from urllib.parse import urlparse
25
+
26
+ from .logins import secret_ref, text_envs
27
+
28
+ VERSION = 1
29
+ MAX_ELEMENTS_PER_PAGE = 400
30
+ MAX_LOCATORS_PER_ELEMENT = 10
31
+ MAX_SAMPLE_URLS = 5
32
+ MAX_FLOW_STEPS = 80
33
+ FLOW_STEP_LOCATORS = 6
34
+
35
+ ROLES = frozenset(
36
+ {
37
+ "button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "option",
38
+ "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "listbox", "slider", "spinbutton",
39
+ "heading", "img", "gridcell", "cell", "row", "columnheader", "treeitem", "dialog",
40
+ }
41
+ )
42
+ TEST_ID_ATTRS = ("data-testid", "data-test-id", "data-test", "data-qa", "data-cy")
43
+ KEPT_ATTRS = ("id", "name", "type", "placeholder", "aria-label", "title", "alt", "href", "role", *TEST_ID_ATTRS)
44
+ FORM_TAGS = frozenset({"input", "textarea", "select"})
45
+ TEXT_TAGS = frozenset({"a", "button", "label", "li", "span", "div", "td", "th", "summary", "option", "p"})
46
+
47
+ # Stability of each locator kind before page evidence is applied.
48
+ BASE = {
49
+ "test_id": 0.96,
50
+ "id": 0.90,
51
+ "role": 0.88,
52
+ "name": 0.86,
53
+ "label": 0.84,
54
+ "placeholder": 0.82,
55
+ "alt": 0.78,
56
+ "href": 0.74,
57
+ "title": 0.72,
58
+ "text": 0.70,
59
+ "css": 0.50,
60
+ "xpath": 0.30,
61
+ }
62
+ # Kinds precise enough to recognise the same element on a later visit; xpath is the last resort.
63
+ STRONG = ("test_id", "id", "name", "role", "label", "placeholder", "href")
64
+ INDEXED = (*STRONG, "xpath")
65
+ GENERATED_FACTOR = 0.4
66
+
67
+ _GENERATED = (
68
+ re.compile(r"\d{4,}"),
69
+ re.compile(r"(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{8,}", re.I),
70
+ re.compile(r":[rR][0-9a-zA-Z]*:"),
71
+ re.compile(r"^(ember|ext-gen|mui-|react-select-|headlessui-|radix-|yui_|j_id|__)", re.I),
72
+ # CSS-in-JS: emotion (css-1q2w3e), styled-components (Button-sc-1x2y3z-0, sc-aXZVg), jsx-123
73
+ re.compile(r"^(css|jsx|sc)-[a-z0-9]{5,}$", re.I),
74
+ re.compile(r"-sc-[a-z0-9]{5,}", re.I),
75
+ )
76
+
77
+ # Actions that only read the page; replay skips them and flows do not record them.
78
+ READ_ONLY_ACTIONS = frozenset(
79
+ {"extract", "find_text", "search_page", "find_elements", "dropdown_options", "screenshot", "read_file", "write_file", "replace_file", "done", "record_check"}
80
+ )
81
+ ELEMENT_ACTIONS = frozenset({"click", "input", "select_dropdown", "scroll", "upload_file"})
82
+ REPLAYABLE_ACTIONS = frozenset({"navigate", "search", "go_back", "wait", "click", "input", "select_dropdown", "scroll", "send_keys"})
83
+ SECRET_ENV_VARS = ("BROWSER_USE_DEFAULT_LOGIN_PASSWORD", "BROWSER_USE_DEFAULT_LOGIN_USERNAME")
84
+
85
+ LocKey = Tuple[str, str]
86
+
87
+
88
+ def now_iso() -> str:
89
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
90
+
91
+
92
+ def _q(value: str) -> str:
93
+ return json.dumps(value, ensure_ascii=False)
94
+
95
+
96
+ def _clean(text: Any, limit: int = 120) -> str:
97
+ return re.sub(r"\s+", " ", str(text or "")).strip()[:limit]
98
+
99
+
100
+ def _sha(text: str, n: int) -> str:
101
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()[:n]
102
+
103
+
104
+ def looks_generated(value: str) -> bool:
105
+ """Framework-generated ids and hashes change between builds or page loads."""
106
+ return any(p.search(value or "") for p in _GENERATED)
107
+
108
+
109
+ def _css_ident(value: str) -> bool:
110
+ return bool(re.fullmatch(r"[A-Za-z_][\w-]*", value or ""))
111
+
112
+
113
+ # ---------------------------------------------------------------------------------------------
114
+ # Pages
115
+ # ---------------------------------------------------------------------------------------------
116
+
117
+
118
+ def _template_segment(seg: str) -> str:
119
+ if seg.isdigit() or (looks_generated(seg) and not re.search(r"\.[a-z]{2,5}$", seg)):
120
+ return "*"
121
+ if len(seg) >= 24 and re.search(r"\d", seg):
122
+ return "*"
123
+ return seg
124
+
125
+
126
+ def page_ref(url: str) -> Optional[Tuple[str, str]]:
127
+ """(host, path template) for a page URL. Ids in the path become `*`, the query string is dropped."""
128
+ try:
129
+ p = urlparse(url or "")
130
+ except Exception:
131
+ return None
132
+ if p.scheme not in ("http", "https") or not p.hostname:
133
+ return None
134
+ host = p.hostname.lower()
135
+ if p.port and p.port not in (80, 443):
136
+ host = f"{host}:{p.port}"
137
+ path = "/" + "/".join(_template_segment(s) for s in p.path.split("/") if s)
138
+ frag = p.fragment or ""
139
+ if frag.startswith("/") or frag.startswith("!/"):
140
+ path += "#" + "/".join(_template_segment(s) for s in frag.split("?")[0].split("/"))
141
+ return host, path
142
+
143
+
144
+ def page_id(url: str) -> Optional[str]:
145
+ ref = page_ref(url)
146
+ return f"{ref[0]}{ref[1]}" if ref else None
147
+
148
+
149
+ def _page_slug(path: str) -> str:
150
+ if path == "/":
151
+ return "index"
152
+ return f"{re.sub(r'[^A-Za-z0-9]+', '_', path).strip('_')[:60] or 'page'}-{_sha(path, 8)}"
153
+
154
+
155
+ def _host_dir(host: str) -> str:
156
+ return re.sub(r"[^A-Za-z0-9.-]+", "_", host)
157
+
158
+
159
+ # ---------------------------------------------------------------------------------------------
160
+ # Elements and locators
161
+ # ---------------------------------------------------------------------------------------------
162
+
163
+
164
+ def element_facts(node: Any) -> Dict[str, Any]:
165
+ """Identity-relevant facts of a library DOM node (EnhancedDOMTreeNode)."""
166
+ raw_attrs = getattr(node, "attributes", None) or {}
167
+ attrs = {k: str(v) for k, v in raw_attrs.items() if k in KEPT_ATTRS and v not in (None, "")}
168
+ ax = getattr(node, "ax_node", None)
169
+ tag = str(getattr(node, "node_name", "") or "").lower()
170
+ role = _clean(attrs.get("role") or getattr(ax, "role", None) or "", 40)
171
+ name = _clean(getattr(ax, "name", None) or "")
172
+ text = ""
173
+ if not name and tag in TEXT_TAGS and hasattr(node, "get_all_children_text"):
174
+ try:
175
+ text = _clean(node.get_all_children_text(max_depth=4))
176
+ except Exception:
177
+ text = ""
178
+ try:
179
+ xpath = str(node.xpath or "")
180
+ except Exception:
181
+ xpath = ""
182
+ try:
183
+ stable_hash = node.compute_stable_hash()
184
+ except Exception:
185
+ stable_hash = None
186
+ classes: List[str] = []
187
+ cls = str(raw_attrs.get("class") or "")
188
+ if cls:
189
+ try:
190
+ from browser_use.dom.views import filter_dynamic_classes
191
+
192
+ cls = filter_dynamic_classes(cls)
193
+ except Exception:
194
+ pass
195
+ classes = [c for c in cls.split() if _css_ident(c) and not looks_generated(c)]
196
+ return {
197
+ "tag": tag,
198
+ "role": role,
199
+ "name": name,
200
+ "text": text,
201
+ "attributes": attrs,
202
+ "classes": classes[:4],
203
+ "xpath": xpath,
204
+ "stable_hash": stable_hash,
205
+ }
206
+
207
+
208
+ def candidate_locators(f: Dict[str, Any]) -> List[Dict[str, Any]]:
209
+ """Every locator we can build for one element: semantic first, css and xpath last."""
210
+ out: List[Dict[str, Any]] = []
211
+ a: Dict[str, str] = f.get("attributes") or {}
212
+ tag = f.get("tag") or "*"
213
+ role, name = f.get("role") or "", f.get("name") or ""
214
+
215
+ def add(kind: str, value: str, playwright: str, **extra: Any) -> None:
216
+ if not value:
217
+ return
218
+ base = BASE[kind]
219
+ generated = kind in ("test_id", "id", "name", "href", "css") and looks_generated(value)
220
+ if generated:
221
+ base *= GENERATED_FACTOR
222
+ loc = {"type": kind, "value": value, "playwright": playwright, "base": round(base, 3), **extra}
223
+ if generated:
224
+ loc["generated"] = True
225
+ out.append(loc)
226
+
227
+ tid_attr = next((k for k in TEST_ID_ATTRS if a.get(k)), None)
228
+ if tid_attr:
229
+ v = a[tid_attr]
230
+ # An attribute selector, not getByTestId: a repository can point getByTestId at another attribute (data-test, data-qa).
231
+ css = f"[{tid_attr}={_q(v)}]"
232
+ add("test_id", v, f"page.locator({_q(css)})", attr=tid_attr, css=css)
233
+ if a.get("id"):
234
+ v = a["id"]
235
+ css = f"#{v}" if _css_ident(v) else f"[id={_q(v)}]"
236
+ add("id", v, f"page.locator({_q(css)})", css=css)
237
+ if role in ROLES and name and len(name) <= 80:
238
+ add("role", f"{role}|{name}", f"page.getByRole({_q(role)}, {{ name: {_q(name)}, exact: true }})", role=role, name=name)
239
+ if a.get("name"):
240
+ css = f"{tag}[name={_q(a['name'])}]"
241
+ add("name", a["name"], f"page.locator({_q(css)})", css=css)
242
+ if tag in FORM_TAGS:
243
+ label = a.get("aria-label") or (name if name and name not in (a.get("placeholder"), a.get("title")) else "")
244
+ if label and len(label) <= 80:
245
+ add("label", label, f"page.getByLabel({_q(label)}, {{ exact: true }})")
246
+ if a.get("placeholder"):
247
+ add("placeholder", a["placeholder"], f"page.getByPlaceholder({_q(a['placeholder'])}, {{ exact: true }})")
248
+ elif a.get("aria-label") and len(a["aria-label"]) <= 80 and not (role in ROLES and name == a["aria-label"]):
249
+ add("label", a["aria-label"], f"page.getByLabel({_q(a['aria-label'])}, {{ exact: true }})")
250
+ if a.get("alt"):
251
+ add("alt", a["alt"], f"page.getByAltText({_q(a['alt'])}, {{ exact: true }})")
252
+ if a.get("title"):
253
+ add("title", a["title"], f"page.getByTitle({_q(a['title'])}, {{ exact: true }})")
254
+ href = a.get("href") or ""
255
+ if tag == "a" and href and not href.startswith(("javascript:", "#")) and len(href) <= 200:
256
+ css = f"a[href={_q(href)}]"
257
+ add("href", href, f"page.locator({_q(css)})", css=css)
258
+ text = f.get("text") or (name if tag not in FORM_TAGS else "")
259
+ if text and len(text) <= 60 and tag not in FORM_TAGS:
260
+ add("text", text, f"page.getByText({_q(text)}, {{ exact: true }})")
261
+ classes = f.get("classes") or []
262
+ if classes:
263
+ css = tag + "".join(f".{c}" for c in classes[:2])
264
+ add("css", css, f"page.locator({_q(css)})", css=css)
265
+ if f.get("xpath"):
266
+ xp = "/" + str(f["xpath"]).lstrip("/")
267
+ add("xpath", xp, f"page.locator({_q('xpath=' + xp)})")
268
+ return out
269
+
270
+
271
+ def confidence(loc: Dict[str, Any]) -> int:
272
+ """1-99: kind stability × uniqueness on the page × consistency across visits × replay success."""
273
+ matches = int(loc.get("matches") or 1)
274
+ unique = 1.0 if matches <= 1 else 0.5 / (matches - 1)
275
+ seen = max(1, int(loc.get("seen") or 1))
276
+ missed = int(loc.get("missed") or 0)
277
+ stability = (0.75 + 0.25 * min(seen, 4) / 4) * (seen / (seen + missed))
278
+ ok, bad = int(loc.get("success") or 0), int(loc.get("failure") or 0)
279
+ reliability = (ok + 1) / (ok + bad + 1)
280
+ return max(1, min(99, round(100 * float(loc.get("base") or 0.3) * unique * stability * reliability)))
281
+
282
+
283
+ def rank(locs: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
284
+ ranked = []
285
+ for loc in locs:
286
+ loc["confidence"] = confidence(loc)
287
+ ranked.append(loc)
288
+ ranked.sort(key=lambda l: (-l["confidence"], -BASE.get(l["type"], 0)))
289
+ return ranked[:MAX_LOCATORS_PER_ELEMENT]
290
+
291
+
292
+ def _public_locator(loc: Dict[str, Any]) -> Dict[str, Any]:
293
+ keep = ("type", "value", "playwright", "confidence", "verified", "role", "name", "css", "attr")
294
+ return {k: loc[k] for k in keep if k in loc}
295
+
296
+
297
+ def element_label(facts: Dict[str, Any]) -> str:
298
+ a = facts.get("attributes") or {}
299
+ return facts.get("name") or facts.get("text") or a.get("placeholder") or a.get("aria-label") or facts.get("tag") or ""
300
+
301
+
302
+ # ---------------------------------------------------------------------------------------------
303
+ # Store
304
+ # ---------------------------------------------------------------------------------------------
305
+
306
+
307
+ def goal_key(goal: str, start_url: Optional[str]) -> str:
308
+ norm = re.sub(r"\s+", " ", (goal or "").strip().lower()).rstrip(" .!")
309
+ return f"{norm}|{(start_url or '').strip().rstrip('/')}"
310
+
311
+
312
+ class MemoryStore:
313
+ """Loads page files lazily, updates them in memory during a run, and writes them on flush()."""
314
+
315
+ def __init__(self, root: str | Path, run_id: Optional[str] = None) -> None:
316
+ self.root = Path(root).expanduser()
317
+ self.run_id = run_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")
318
+ self._pages: Dict[str, Dict[str, Any]] = {}
319
+ self._files: Dict[str, Path] = {}
320
+ self._index: Dict[str, Dict[LocKey, str]] = {}
321
+ self._loaded: Dict[str, Any] = {}
322
+ self._base: Dict[str, Dict[str, Dict[str, Any]]] = {}
323
+ self._base_visits: Dict[str, int] = {}
324
+ self._dirty: Set[str] = set()
325
+ self.touched_pages: Set[str] = set()
326
+ self.touched_elements: Set[Tuple[str, str]] = set()
327
+ self.new_elements = 0
328
+
329
+ # -- pages ------------------------------------------------------------------------------
330
+
331
+ def _page(self, host: str, path: str) -> Tuple[str, Dict[str, Any]]:
332
+ pid = f"{host}{path}"
333
+ if pid in self._pages:
334
+ return pid, self._pages[pid]
335
+ file = self.root / "pages" / _host_dir(host) / f"{_page_slug(path)}.json"
336
+ page: Optional[Dict[str, Any]] = None
337
+ if file.is_file():
338
+ try:
339
+ page = json.loads(file.read_text(encoding="utf-8"))
340
+ except Exception:
341
+ page = None
342
+ if not isinstance(page, dict) or not isinstance(page.get("elements"), dict):
343
+ now = now_iso()
344
+ page = {
345
+ "version": VERSION, "host": host, "path": path, "title": "", "first_seen": now, "last_seen": now,
346
+ "visits": 0, "sample_urls": [], "elements": {},
347
+ }
348
+ self._pages[pid] = page
349
+ self._files[pid] = file
350
+ self._loaded[pid] = int(page.get("revision") or 0)
351
+ self._base[pid] = {key: _counts(entry) for key, entry in page["elements"].items()}
352
+ self._base_visits[pid] = int(page.get("visits") or 0)
353
+ index: Dict[LocKey, str] = {}
354
+ for key, entry in page["elements"].items():
355
+ for loc in entry.get("locators") or []:
356
+ if loc.get("type") in INDEXED:
357
+ index.setdefault((loc["type"], loc["value"]), key)
358
+ self._index[pid] = index
359
+ return pid, page
360
+
361
+ def page_for_url(self, url: str) -> Optional[Dict[str, Any]]:
362
+ ref = page_ref(url)
363
+ return self._page(*ref)[1] if ref else None
364
+
365
+ def observe(self, url: str, title: str, selector_map: Dict[int, Any]) -> Dict[int, Dict[str, Any]]:
366
+ """Record every element in a library selector map. Returns index → {key, page, facts, locators, current}."""
367
+ ref = page_ref(url)
368
+ if not ref or not selector_map:
369
+ return {}
370
+ pid, page = self._page(*ref)
371
+ now = now_iso()
372
+ if page.get("last_run") != self.run_id:
373
+ page["visits"] = int(page.get("visits") or 0) + 1
374
+ page["last_run"] = self.run_id
375
+ page["last_seen"] = now
376
+ if title and title != ref[0] and not title.startswith(("http:", "https:")):
377
+ page["title"] = _clean(title, 200)
378
+ clean_url = (url or "").split("#")[0] if "#/" not in (url or "") else url
379
+ samples = [u for u in page.get("sample_urls") or [] if u != clean_url]
380
+ page["sample_urls"] = (samples + [clean_url])[-MAX_SAMPLE_URLS:]
381
+
382
+ snapshot: List[Tuple[int, Dict[str, Any], List[Dict[str, Any]]]] = []
383
+ for idx, node in selector_map.items():
384
+ try:
385
+ facts = element_facts(node)
386
+ except Exception:
387
+ continue
388
+ snapshot.append((int(idx), facts, candidate_locators(facts)))
389
+ counts = Counter((l["type"], l["value"]) for _, _, locs in snapshot for l in locs)
390
+
391
+ out: Dict[int, Dict[str, Any]] = {}
392
+ used: Set[str] = set()
393
+ for idx, facts, locs in snapshot:
394
+ for loc in locs:
395
+ loc["matches"] = counts[(loc["type"], loc["value"])]
396
+ key = self._identify(pid, page, facts, locs, used)
397
+ used.add(key)
398
+ entry = self._merge(pid, page, key, facts, locs, now)
399
+ out[idx] = {
400
+ "key": key,
401
+ "page": pid,
402
+ "facts": facts,
403
+ "locators": entry["locators"],
404
+ "current": {(l["type"], l["value"]): l["matches"] for l in locs},
405
+ }
406
+ self.touched_elements.add((pid, key))
407
+ self._prune(page)
408
+ self._dirty.add(pid)
409
+ self.touched_pages.add(pid)
410
+ return out
411
+
412
+ def _identify(self, pid: str, page: Dict[str, Any], facts: Dict[str, Any], locs: List[Dict[str, Any]], used: Set[str]) -> str:
413
+ elements = page["elements"]
414
+ strong = [l for l in locs if l["type"] in STRONG and l["matches"] == 1 and not l.get("generated")]
415
+ index = self._index[pid]
416
+ for loc in strong:
417
+ key = index.get((loc["type"], loc["value"]))
418
+ if key and key not in used and key in elements and elements[key].get("tag") == facts["tag"]:
419
+ return key
420
+ # Sites reuse the same popup container for different widgets, so a matching xpath only
421
+ # identifies the element when its role and accessible name or text agree too.
422
+ label = facts["name"] or facts["text"] or ""
423
+ for loc in (l for l in locs if l["type"] == "xpath"):
424
+ key = index.get((loc["type"], loc["value"]))
425
+ entry = elements.get(key) if key and key not in used else None
426
+ if (
427
+ entry
428
+ and entry.get("tag") == facts["tag"]
429
+ and (entry.get("role") or "") == (facts["role"] or "")
430
+ and (entry.get("name") or entry.get("text") or "") == label
431
+ ):
432
+ return key
433
+ if strong:
434
+ basis = f"{facts['tag']}|{strong[0]['type']}={strong[0]['value']}"
435
+ else:
436
+ basis = f"{facts['tag']}|xpath={facts['xpath']}|{facts['name'] or facts['text']}"
437
+ key = _sha(basis, 12)
438
+ if key in used:
439
+ key = _sha(f"{basis}|xpath={facts['xpath']}", 12)
440
+ return key
441
+
442
+ def _merge(self, pid: str, page: Dict[str, Any], key: str, facts: Dict[str, Any], locs: List[Dict[str, Any]], now: str) -> Dict[str, Any]:
443
+ elements = page["elements"]
444
+ entry = elements.get(key)
445
+ if entry is None:
446
+ entry = {"key": key, "first_seen": now, "visits": 0, "interactions": 0, "success": 0, "failure": 0, "locators": []}
447
+ elements[key] = entry
448
+ self.new_elements += 1
449
+ first_this_run = entry.get("last_run") != self.run_id
450
+ stored: Dict[LocKey, Dict[str, Any]] = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
451
+ changed = False
452
+ current: Set[LocKey] = set()
453
+ for loc in locs:
454
+ k = (loc["type"], loc["value"])
455
+ current.add(k)
456
+ s = stored.get(k)
457
+ if s is None:
458
+ s = {kk: vv for kk, vv in loc.items()}
459
+ s.update(seen=1, missed=0, success=0, failure=0, first_seen=now)
460
+ stored[k] = s
461
+ changed = True
462
+ else:
463
+ if first_this_run:
464
+ s["seen"] = int(s.get("seen") or 0) + 1
465
+ s.update(matches=loc["matches"], base=loc["base"], playwright=loc["playwright"])
466
+ s["last_seen"] = now
467
+ if first_this_run:
468
+ for k, s in stored.items():
469
+ if k not in current:
470
+ s["missed"] = int(s.get("missed") or 0) + 1
471
+ changed = True
472
+ entry["visits"] = int(entry.get("visits") or 0) + 1
473
+ entry.update(
474
+ tag=facts["tag"],
475
+ role=facts["role"] or None,
476
+ name=facts["name"] or None,
477
+ text=facts["text"] or None,
478
+ attributes=facts["attributes"],
479
+ xpath=facts["xpath"],
480
+ stable_hash=facts["stable_hash"],
481
+ last_seen=now,
482
+ last_run=self.run_id,
483
+ )
484
+ entry["locators"] = list(stored.values())
485
+ self._rerank(entry, now if changed or "last_updated" not in entry else None)
486
+ index = self._index[pid]
487
+ for loc in entry["locators"]:
488
+ if loc["type"] in INDEXED:
489
+ index.setdefault((loc["type"], loc["value"]), key)
490
+ return entry
491
+
492
+ @staticmethod
493
+ def _rerank(entry: Dict[str, Any], updated: Optional[str]) -> None:
494
+ entry["locators"] = rank(entry.get("locators") or [])
495
+ top = entry["locators"][0] if entry["locators"] else None
496
+ entry["best"] = top["playwright"] if top else None
497
+ entry["confidence"] = top["confidence"] if top else 0
498
+ if updated:
499
+ entry["last_updated"] = updated
500
+
501
+ @staticmethod
502
+ def _prune(page: Dict[str, Any]) -> None:
503
+ elements = page["elements"]
504
+ if len(elements) <= MAX_ELEMENTS_PER_PAGE:
505
+ return
506
+ order = sorted(elements.values(), key=lambda e: (e.get("interactions", 0) > 0, e.get("last_seen", "")))
507
+ for entry in order[: len(elements) - MAX_ELEMENTS_PER_PAGE]:
508
+ elements.pop(entry["key"], None)
509
+
510
+ def record_outcome(self, pid: str, key: str, ok: bool, locator: Optional[LocKey] = None, stale: Iterable[LocKey] = ()) -> None:
511
+ """An action on this element worked or failed; `locator` is the one replay resolved it with."""
512
+ page = self._pages.get(pid)
513
+ entry = (page or {}).get("elements", {}).get(key)
514
+ if not entry:
515
+ return
516
+ now = now_iso()
517
+ entry["interactions"] = int(entry.get("interactions") or 0) + 1
518
+ entry["success" if ok else "failure"] = int(entry.get("success" if ok else "failure") or 0) + 1
519
+ entry["last_used"] = now
520
+ self.touched_elements.add((pid, key))
521
+ by_key = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
522
+ if locator and locator in by_key:
523
+ field = "success" if ok else "failure"
524
+ by_key[locator][field] = int(by_key[locator].get(field) or 0) + 1
525
+ for k in stale:
526
+ if k in by_key:
527
+ by_key[k]["failure"] = int(by_key[k].get("failure") or 0) + 1
528
+ self._rerank(entry, now)
529
+ self._dirty.add(pid)
530
+
531
+ def record_stale(self, pid: str, key: str, stale: Iterable[LocKey]) -> None:
532
+ """Replay found this recorded element's action on a different element; its locators in `stale` no longer find anything."""
533
+ entry = (self._pages.get(pid) or {}).get("elements", {}).get(key)
534
+ if not entry:
535
+ return
536
+ gone = set(stale)
537
+ changed = False
538
+ for loc in entry.get("locators") or []:
539
+ if (loc["type"], loc["value"]) in gone:
540
+ loc["failure"] = int(loc.get("failure") or 0) + 1
541
+ loc["missed"] = int(loc.get("missed") or 0) + 1
542
+ changed = True
543
+ if changed:
544
+ self.touched_elements.add((pid, key))
545
+ self._rerank(entry, now_iso())
546
+ self._dirty.add(pid)
547
+
548
+ def element(self, pid: str, key: str) -> Optional[Dict[str, Any]]:
549
+ if pid not in self._pages:
550
+ host_end = pid.find("/")
551
+ if host_end < 0:
552
+ return None
553
+ self._page(pid[:host_end], pid[host_end:])
554
+ return self._pages[pid]["elements"].get(key)
555
+
556
+ def flush(self) -> None:
557
+ """Write changed pages. When another run saved the same page since this one loaded it (parallel suites), the
558
+ saved copy is merged in first, so neither run's work is lost."""
559
+ if not self._dirty:
560
+ return
561
+ with _locked(self.root):
562
+ for pid in list(self._dirty):
563
+ page = self._pages[pid]
564
+ disk = _read_json(self._files[pid])
565
+ if disk and isinstance(disk.get("elements"), dict) and int(disk.get("revision") or 0) != self._loaded.get(pid):
566
+ self._merge_saved(pid, page, disk)
567
+ page["element_count"] = len(page["elements"])
568
+ page["last_updated"] = now_iso()
569
+ page["revision"] = max(int(page.get("revision") or 0), int((disk or {}).get("revision") or 0)) + 1
570
+ _write_json(self._files[pid], page)
571
+ self._loaded[pid] = page["revision"]
572
+ self._base[pid] = {key: _counts(entry) for key, entry in page["elements"].items()}
573
+ self._base_visits[pid] = int(page.get("visits") or 0)
574
+ self._dirty.clear()
575
+
576
+ def _merge_saved(self, pid: str, page: Dict[str, Any], disk: Dict[str, Any]) -> None:
577
+ """Fold a copy saved by another run into this run's page: counters become saved + this run's increase."""
578
+ mine = {key for p, key in self.touched_elements if p == pid}
579
+ base = self._base.get(pid, {})
580
+ for key, saved in disk["elements"].items():
581
+ if key not in mine or key not in page["elements"]:
582
+ page["elements"][key] = saved
583
+ continue
584
+ entry = page["elements"][key]
585
+ start = base.get(key) or _counts({})
586
+ for f in _ELEMENT_COUNTS:
587
+ entry[f] = int(saved.get(f) or 0) + int(entry.get(f) or 0) - start["e"][f]
588
+ ours = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
589
+ for sl in saved.get("locators") or []:
590
+ k = (sl["type"], sl["value"])
591
+ loc = ours.get(k)
592
+ if loc is None:
593
+ ours[k] = sl
594
+ continue
595
+ was = start["l"].get(k) or {}
596
+ for f in _LOCATOR_COUNTS:
597
+ loc[f] = int(sl.get(f) or 0) + int(loc.get(f) or 0) - int(was.get(f) or 0)
598
+ entry["locators"] = list(ours.values())
599
+ self._rerank(entry, None)
600
+ page["visits"] = int(disk.get("visits") or 0) + int(page.get("visits") or 0) - self._base_visits.get(pid, 0)
601
+ self._prune(page)
602
+
603
+ def stats(self) -> Dict[str, int]:
604
+ return {"pages": len(self.touched_pages), "elements": len(self.touched_elements), "new_elements": self.new_elements}
605
+
606
+ # -- flows ------------------------------------------------------------------------------
607
+
608
+ def flow_file(self, goal: str, start_url: Optional[str]) -> Path:
609
+ key = goal_key(goal, start_url)
610
+ slug = re.sub(r"[^a-z0-9]+", "-", key.split("|", 1)[0])[:50].strip("-") or "goal"
611
+ return self.root / "flows" / f"{slug}-{_sha(key, 10)}.json"
612
+
613
+ def load_flow(self, goal: str, start_url: Optional[str]) -> Optional[Dict[str, Any]]:
614
+ file = self.flow_file(goal, start_url)
615
+ if not file.is_file():
616
+ return None
617
+ try:
618
+ flow = json.loads(file.read_text(encoding="utf-8"))
619
+ except Exception:
620
+ return None
621
+ return flow if isinstance(flow, dict) and isinstance(flow.get("steps"), list) else None
622
+
623
+ def checkpoint_file(self, goal: str, start_url: Optional[str]) -> Path:
624
+ return self.root / "checkpoints" / self.flow_file(goal, start_url).name
625
+
626
+ def save_checkpoint(self, goal: str, start_url: Optional[str], data: Dict[str, Any]) -> None:
627
+ """The steps that worked so far in a run still going, so a run cut off before it finished can be picked up."""
628
+ _write_json(self.checkpoint_file(goal, start_url), {"goal": goal, "start_url": start_url or "", **data, "at": now_iso()})
629
+
630
+ def load_checkpoint(self, goal: str, start_url: Optional[str]) -> Optional[Dict[str, Any]]:
631
+ data = _read_json(self.checkpoint_file(goal, start_url))
632
+ return data if data and isinstance(data.get("steps"), list) and data["steps"] else None
633
+
634
+ def clear_checkpoint(self, goal: str, start_url: Optional[str], run_ids: Iterable[str]) -> None:
635
+ """Remove the checkpoint when one of these runs wrote it: a run that finishes leaves its own result instead."""
636
+ file = self.checkpoint_file(goal, start_url)
637
+ data = _read_json(file)
638
+ if data is not None and str(data.get("run_id") or "") in {r for r in run_ids if r}:
639
+ file.unlink(missing_ok=True)
640
+
641
+ def save_flow_result(
642
+ self,
643
+ goal: str,
644
+ start_url: Optional[str],
645
+ *,
646
+ status: str,
647
+ steps: List[Dict[str, Any]],
648
+ summary: str = "",
649
+ final_url: str = "",
650
+ replayed: int = 0,
651
+ llm_steps: int = 0,
652
+ resume: Optional[Dict[str, Any]] = None,
653
+ ) -> Optional[str]:
654
+ """
655
+ Update the goal's flow after a run. Only successful runs replace its steps. Returns "saved" or "updated".
656
+
657
+ A failed run with `resume` (the steps that worked before it went wrong) keeps them under "resume", so the
658
+ next run of this task replays them and the agent picks up where the failed run went wrong.
659
+ """
660
+ if status == "stopped":
661
+ return None
662
+ with _locked(self.root):
663
+ return self._save_flow_result(goal, start_url, status=status, steps=steps, summary=summary,
664
+ final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume)
665
+
666
+ def _save_flow_result(self, goal: str, start_url: Optional[str], *, status: str, steps: List[Dict[str, Any]],
667
+ summary: str, final_url: str, replayed: int, llm_steps: int,
668
+ resume: Optional[Dict[str, Any]] = None) -> Optional[str]:
669
+ file = self.flow_file(goal, start_url)
670
+ flow = self.load_flow(goal, start_url)
671
+ created = flow is None
672
+ keep_resume = status == "fail" and bool(resume and resume.get("steps"))
673
+ if created and not keep_resume and (status != "complete" or not steps):
674
+ return None
675
+ now = now_iso()
676
+ if flow is None:
677
+ flow = {"version": VERSION, "goal": goal, "start_url": start_url or "", "created": now,
678
+ "runs": 0, "successes": 0, "failures": 0, "replays": 0, "steps": []}
679
+ flow["runs"] = int(flow.get("runs") or 0) + 1
680
+ if replayed:
681
+ flow["replays"] = int(flow.get("replays") or 0) + 1
682
+ if status == "complete" and steps:
683
+ flow["successes"] = int(flow.get("successes") or 0) + 1
684
+ flow["steps"] = steps[:MAX_FLOW_STEPS]
685
+ flow["host"] = _flow_host(steps, start_url)
686
+ flow["last_success"] = now
687
+ flow["summary"] = (summary or "")[:2000]
688
+ flow["final_url"] = final_url or ""
689
+ flow["llm_steps"] = llm_steps
690
+ flow["replayed_steps"] = replayed
691
+ flow.pop("resume", None)
692
+ elif status != "complete":
693
+ flow["failures"] = int(flow.get("failures") or 0) + 1
694
+ if keep_resume and resume:
695
+ flow["resume"] = {**resume, "steps": list(resume["steps"])[:MAX_FLOW_STEPS], "at": now}
696
+ flow.setdefault("host", _flow_host(resume["steps"], start_url))
697
+ flow["last_status"] = status
698
+ flow["last_run"] = now
699
+ flow["updated"] = now
700
+ _write_json(file, flow)
701
+ if created and status != "complete":
702
+ return None
703
+ return "saved" if created else "updated"
704
+
705
+ def recent_flows(self, host: str, *, exclude: Optional[Path] = None, limit: int = 2) -> List[Dict[str, Any]]:
706
+ folder = self.root / "flows"
707
+ if not folder.is_dir():
708
+ return []
709
+ found = []
710
+ for file in folder.glob("*.json"):
711
+ if exclude is not None and file.resolve() == exclude.resolve():
712
+ continue
713
+ try:
714
+ flow = json.loads(file.read_text(encoding="utf-8"))
715
+ except Exception:
716
+ continue
717
+ if isinstance(flow, dict) and flow.get("host") == host and flow.get("successes") and flow.get("steps"):
718
+ found.append(flow)
719
+ found.sort(key=lambda f: str(f.get("last_success") or ""), reverse=True)
720
+ return found[:limit]
721
+
722
+ def pages_for_host(self, host: str) -> List[Dict[str, Any]]:
723
+ folder = self.root / "pages" / _host_dir(host)
724
+ pages = []
725
+ for file in folder.glob("*.json") if folder.is_dir() else []:
726
+ try:
727
+ page = json.loads(file.read_text(encoding="utf-8"))
728
+ except Exception:
729
+ continue
730
+ if isinstance(page, dict):
731
+ pages.append(page)
732
+ pages.sort(key=lambda p: -int(p.get("visits") or 0))
733
+ return pages
734
+
735
+ def prompt_hints(self, start_url: Optional[str], goal: str, *, max_chars: int = 2500) -> str:
736
+ """Short system-prompt section: earlier successful paths on this site and the pages seen there."""
737
+ ref = page_ref(start_url or "")
738
+ if not ref:
739
+ return ""
740
+ host = ref[0]
741
+ lines: List[str] = []
742
+ flows = self.recent_flows(host, exclude=self.flow_file(goal, start_url))
743
+ if flows:
744
+ lines.append("Earlier successful runs of other tasks on this site. Reuse the steps that also apply to this task (a banner, a sign-in, a search form), skip the rest; the live page wins when it differs:")
745
+ for flow in flows:
746
+ lines.append(f'- "{_clean(flow.get("goal"), 140)}"')
747
+ for step in flow["steps"][:15]:
748
+ lines.append(f" {_clean(step.get('line') or step.get('action'), 140)}")
749
+ pages = self.pages_for_host(host)[:6]
750
+ if pages:
751
+ lines.append("Pages seen on this site before, with the controls that already worked:")
752
+ for page in pages:
753
+ sample = (page.get("sample_urls") or [""])[-1]
754
+ lines.append(f"- {page.get('path')} — {_clean(page.get('title'), 60)} ({_clean(sample, 160)})")
755
+ controls = _worked_controls(page)
756
+ if controls:
757
+ lines.append(f" {controls}")
758
+ if not lines:
759
+ return ""
760
+ text = (
761
+ "## Site memory from earlier ForgeQE runs\n"
762
+ "Do these steps in one turn where the page already shows the controls. "
763
+ "Do not spend a turn exploring a page listed here.\n"
764
+ + "\n".join(lines)
765
+ )
766
+ return text if len(text) <= max_chars else text[: max_chars - 20] + "\n…(memory truncated)"
767
+
768
+
769
+ def _worked_controls(page: Dict[str, Any], limit: int = 6) -> str:
770
+ """Controls a previous run actually used, so the model can act on them instead of searching the page."""
771
+ elements = [e for e in (page.get("elements") or {}).values() if isinstance(e, dict) and int(e.get("success") or 0) > 0]
772
+ elements.sort(key=lambda e: (-int(e.get("success") or 0), -int(e.get("confidence") or 0)))
773
+ bits = []
774
+ for entry in elements[:limit]:
775
+ attrs = entry.get("attributes") or {}
776
+ label = entry.get("name") or entry.get("text") or attrs.get("placeholder") or attrs.get("aria-label") or entry.get("tag")
777
+ loc = (entry.get("locators") or [{}])[0]
778
+ kind = loc.get("type") or "locator"
779
+ conf = loc.get("confidence") or entry.get("confidence") or 0
780
+ bits.append(f"{_clean(label, 40)} ({kind} {conf}%)")
781
+ return "use these, do not look for them: " + ", ".join(bits) if bits else ""
782
+
783
+
784
+ def _flow_host(steps: List[Dict[str, Any]], start_url: Optional[str]) -> str:
785
+ ref = page_ref(start_url or "")
786
+ if ref:
787
+ return ref[0]
788
+ for step in steps:
789
+ url = (step.get("params") or {}).get("url") if step.get("action") == "navigate" else None
790
+ ref = page_ref(url or "") if url else None
791
+ if ref:
792
+ return ref[0]
793
+ if step.get("page"):
794
+ return str(step["page"]).split("/", 1)[0]
795
+ return ""
796
+
797
+
798
+ _ELEMENT_COUNTS = ("visits", "interactions", "success", "failure")
799
+ _LOCATOR_COUNTS = ("seen", "missed", "success", "failure")
800
+
801
+
802
+ def _counts(entry: Dict[str, Any]) -> Dict[str, Any]:
803
+ return {
804
+ "e": {f: int(entry.get(f) or 0) for f in _ELEMENT_COUNTS},
805
+ "l": {(l["type"], l["value"]): {f: int(l.get(f) or 0) for f in _LOCATOR_COUNTS} for l in entry.get("locators") or []},
806
+ }
807
+
808
+
809
+ @contextmanager
810
+ def _locked(root: Path) -> Iterator[None]:
811
+ """Exclusive lock on the memory folder for the read-merge-write at the end of a run."""
812
+ root.mkdir(parents=True, exist_ok=True)
813
+ with open(root / ".lock", "a+") as handle:
814
+ try:
815
+ import fcntl
816
+ except ImportError:
817
+ import msvcrt
818
+
819
+ handle.seek(0)
820
+ while True:
821
+ try:
822
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
823
+ break
824
+ except OSError:
825
+ continue
826
+ try:
827
+ yield
828
+ finally:
829
+ handle.seek(0)
830
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
831
+ return
832
+ fcntl.flock(handle, fcntl.LOCK_EX)
833
+ try:
834
+ yield
835
+ finally:
836
+ fcntl.flock(handle, fcntl.LOCK_UN)
837
+
838
+
839
+ def _read_json(file: Path) -> Optional[Dict[str, Any]]:
840
+ if not file.is_file():
841
+ return None
842
+ try:
843
+ data = json.loads(file.read_text(encoding="utf-8"))
844
+ except Exception:
845
+ return None
846
+ return data if isinstance(data, dict) else None
847
+
848
+
849
+ def _write_json(file: Path, data: Dict[str, Any]) -> None:
850
+ file.parent.mkdir(parents=True, exist_ok=True)
851
+ tmp = file.with_suffix(f".{os.getpid()}.tmp")
852
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
853
+ os.replace(tmp, file)
854
+
855
+
856
+ # ---------------------------------------------------------------------------------------------
857
+ # Flow steps
858
+ # ---------------------------------------------------------------------------------------------
859
+
860
+
861
+ def _secret_env(text: Any) -> Optional[str]:
862
+ if not text:
863
+ return None
864
+ for name in (*SECRET_ENV_VARS, *text_envs()):
865
+ if os.environ.get(name) and str(text) == os.environ[name]:
866
+ return name
867
+ return None
868
+
869
+
870
+ def _present_first(target: Dict[str, Any]) -> List[Dict[str, Any]]:
871
+ """The element's ranked locators, those that found it on the page just now ahead of ones only seen on earlier visits."""
872
+ current = target.get("current") or {}
873
+ locs = list(target.get("locators") or [])
874
+ return [l for l in locs if (l["type"], l["value"]) in current] + [l for l in locs if (l["type"], l["value"]) not in current]
875
+
876
+
877
+ def flow_step(action: str, params: Dict[str, Any], target: Optional[Dict[str, Any]], line: str) -> Dict[str, Any]:
878
+ """One replayable action. Element actions keep the element's ranked locators and fingerprint, never its index."""
879
+ step: Dict[str, Any] = {"action": action, "params": {k: v for k, v in (params or {}).items() if k != "index"}, "line": line}
880
+ if action == "input" and not step["params"].get("totp_env"):
881
+ text = step["params"].get("text")
882
+ ref = secret_ref(text)
883
+ env = ref[1] if ref and ref[0] == "text" else _secret_env(text)
884
+ if ref and ref[0] == "totp":
885
+ step["params"]["text"] = None
886
+ step["params"]["totp_env"] = ref[1]
887
+ elif env:
888
+ step["params"]["text"] = None
889
+ step["params"]["text_env"] = env
890
+ elif target and (target["facts"].get("attributes") or {}).get("type") == "password":
891
+ step["params"]["text"] = None
892
+ step["secret"] = True
893
+ if (env or ref or step.get("secret")) and text:
894
+ step["line"] = line.replace(str(text), "***")
895
+ if target:
896
+ facts = target["facts"]
897
+ step.update(
898
+ page=target["page"],
899
+ element=target["key"],
900
+ label=element_label(facts),
901
+ locators=[_public_locator(l) for l in _present_first(target)[:FLOW_STEP_LOCATORS]],
902
+ fingerprint={
903
+ "tag": facts["tag"],
904
+ "role": facts["role"],
905
+ "name": facts["name"],
906
+ "stable_hash": facts["stable_hash"],
907
+ "xpath": facts["xpath"],
908
+ },
909
+ )
910
+ return step
911
+
912
+
913
+ def resolve_step(
914
+ step: Dict[str, Any], snapshot: Dict[int, Dict[str, Any]], store: Optional[MemoryStore] = None
915
+ ) -> Tuple[Optional[int], Optional[LocKey], List[LocKey]]:
916
+ """
917
+ Find a recorded step's element in the current page snapshot (from MemoryStore.observe).
918
+ Tries locators from most to least confident; returns (index, locator used, locators that matched nothing).
919
+ """
920
+ locs = list(step.get("locators") or [])
921
+ if store and step.get("page") and step.get("element"):
922
+ entry = store.element(step["page"], step["element"])
923
+ if entry and entry.get("locators"):
924
+ fresh = {(l["type"], l["value"]) for l in entry["locators"]}
925
+ locs = entry["locators"] + [l for l in locs if (l["type"], l["value"]) not in fresh]
926
+ fp = step.get("fingerprint") or {}
927
+ stale: List[LocKey] = []
928
+ for loc in locs:
929
+ k = (loc["type"], loc["value"])
930
+ hits = [idx for idx, e in snapshot.items() if k in e["current"]]
931
+ if len(hits) == 1:
932
+ return hits[0], k, stale
933
+ if len(hits) > 1:
934
+ narrowed = [
935
+ i for i in hits
936
+ if snapshot[i]["facts"]["tag"] == fp.get("tag") and (not fp.get("name") or snapshot[i]["facts"]["name"] == fp.get("name"))
937
+ ]
938
+ if len(narrowed) == 1:
939
+ return narrowed[0], k, stale
940
+ continue
941
+ stale.append(k)
942
+ key = step.get("element")
943
+ hits = [idx for idx, e in snapshot.items() if key and e["key"] == key]
944
+ if len(hits) == 1:
945
+ return hits[0], None, stale
946
+ if fp.get("stable_hash") is not None:
947
+ hits = [idx for idx, e in snapshot.items() if e["facts"].get("stable_hash") == fp["stable_hash"]]
948
+ if len(hits) == 1:
949
+ return hits[0], None, stale
950
+ return None, None, stale
951
+
952
+
953
+ def compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
954
+ """Flow step → workflow.yaml step, carrying the best-ranked locator and the browser agent's note for the step."""
955
+ out = _compact_step(step)
956
+ if step.get("scripted"):
957
+ out["scripted"] = True
958
+ if step.get("overlay"):
959
+ out["overlay"] = step["overlay"]
960
+ if step.get("note"):
961
+ out["note"] = step["note"]
962
+ return out
963
+
964
+
965
+ def _compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
966
+ action = step.get("action") or ""
967
+ p = step.get("params") or {}
968
+ if action == "navigate":
969
+ return {"op": "navigate", "url": p.get("url") or ""}
970
+ if action in ("click", "input", "select_dropdown"):
971
+ locs = step.get("locators") or []
972
+ fp = step.get("fingerprint") or {}
973
+ out: Dict[str, Any] = {
974
+ "op": {"click": "click", "input": "input", "select_dropdown": "select"}[action],
975
+ "page": step.get("page") or "",
976
+ "label": step.get("label") or "",
977
+ "best_locator": locs[0]["playwright"] if locs else None,
978
+ "locators": [
979
+ {"type": l["type"], "value": l["value"], "confidence": l.get("confidence"), **{k: l[k] for k in ("css", "attr", "verified") if l.get(k)}}
980
+ for l in locs
981
+ ],
982
+ "locator": {k: v for k, v in {"tag": (fp.get("tag") or "").upper(), "role": fp.get("role"), "name": fp.get("name")}.items() if v},
983
+ }
984
+ if action == "input":
985
+ out["text"] = p.get("text") if p.get("text") is not None else ""
986
+ out["clear"] = bool(p.get("clear", False))
987
+ if p.get("text_env"):
988
+ out["text_env"] = p["text_env"]
989
+ if p.get("totp_env"):
990
+ out["totp_env"] = p["totp_env"]
991
+ if action == "select_dropdown":
992
+ out["text"] = p.get("text") or ""
993
+ return out
994
+ if action == "send_keys":
995
+ return {"op": "press", "keys": p.get("keys") or ""}
996
+ if action == "wait":
997
+ return {"op": "wait", "seconds": p.get("seconds") or 1}
998
+ if action == "scroll":
999
+ return {"op": "scroll", "down": p.get("down", True), "pages": p.get("pages") or 1}
1000
+ if action == "go_back":
1001
+ return {"op": "back"}
1002
+ if action == "evaluate":
1003
+ return {"op": "evaluate", "code": str(p.get("code") or "")[:2000]}
1004
+ return {"op": "unknown", "action_keys": [action]}