@yuzc-001/grasp 0.6.6

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/README.zh-CN.md +324 -0
  4. package/examples/README.md +31 -0
  5. package/examples/claude-desktop.json +8 -0
  6. package/examples/codex-config.toml +4 -0
  7. package/grasp.skill +0 -0
  8. package/index.js +87 -0
  9. package/package.json +48 -0
  10. package/scripts/grasp_openclaw_ctl.sh +122 -0
  11. package/scripts/run-search-benchmark.mjs +287 -0
  12. package/scripts/update-star-history.mjs +274 -0
  13. package/skill/SKILL.md +61 -0
  14. package/skill/references/tools.md +306 -0
  15. package/src/cli/auto-configure.js +116 -0
  16. package/src/cli/cmd-connect.js +148 -0
  17. package/src/cli/cmd-explain.js +42 -0
  18. package/src/cli/cmd-logs.js +55 -0
  19. package/src/cli/cmd-status.js +119 -0
  20. package/src/cli/config.js +27 -0
  21. package/src/cli/detect-chrome.js +58 -0
  22. package/src/grasp/handoff/events.js +67 -0
  23. package/src/grasp/handoff/persist.js +48 -0
  24. package/src/grasp/handoff/state.js +28 -0
  25. package/src/grasp/page/capture.js +34 -0
  26. package/src/grasp/page/state.js +273 -0
  27. package/src/grasp/verify/evidence.js +40 -0
  28. package/src/grasp/verify/pipeline.js +52 -0
  29. package/src/layer1-bridge/chrome.js +416 -0
  30. package/src/layer1-bridge/webmcp.js +143 -0
  31. package/src/layer2-perception/hints.js +284 -0
  32. package/src/layer3-action/actions.js +400 -0
  33. package/src/runtime/browser-instance.js +65 -0
  34. package/src/runtime/truth/model.js +94 -0
  35. package/src/runtime/truth/snapshot.js +51 -0
  36. package/src/server/affordances.js +47 -0
  37. package/src/server/audit.js +122 -0
  38. package/src/server/boss-fast-path.js +164 -0
  39. package/src/server/boundary-guard.js +53 -0
  40. package/src/server/content.js +97 -0
  41. package/src/server/continuity.js +256 -0
  42. package/src/server/engine-selection.js +29 -0
  43. package/src/server/entry-orchestrator.js +115 -0
  44. package/src/server/error-codes.js +7 -0
  45. package/src/server/explain-share-card.js +113 -0
  46. package/src/server/fast-path-router.js +134 -0
  47. package/src/server/form-runtime.js +602 -0
  48. package/src/server/form-tasks.js +254 -0
  49. package/src/server/gateway-response.js +62 -0
  50. package/src/server/index.js +22 -0
  51. package/src/server/observe.js +52 -0
  52. package/src/server/page-projection.js +31 -0
  53. package/src/server/page-state.js +27 -0
  54. package/src/server/postconditions.js +128 -0
  55. package/src/server/prompt-assembly.js +148 -0
  56. package/src/server/responses.js +44 -0
  57. package/src/server/route-boundary.js +174 -0
  58. package/src/server/route-policy.js +168 -0
  59. package/src/server/runtime-confirmation.js +87 -0
  60. package/src/server/runtime-status.js +7 -0
  61. package/src/server/share-artifacts.js +284 -0
  62. package/src/server/state.js +132 -0
  63. package/src/server/structured-extraction.js +131 -0
  64. package/src/server/surface-prompts.js +166 -0
  65. package/src/server/task-frame.js +11 -0
  66. package/src/server/tasks/search-task.js +321 -0
  67. package/src/server/tools.actions.js +1361 -0
  68. package/src/server/tools.form.js +526 -0
  69. package/src/server/tools.gateway.js +757 -0
  70. package/src/server/tools.handoff.js +210 -0
  71. package/src/server/tools.js +20 -0
  72. package/src/server/tools.legacy.js +983 -0
  73. package/src/server/tools.strategy.js +250 -0
  74. package/src/server/tools.task-surface.js +66 -0
  75. package/src/server/tools.workspace.js +873 -0
  76. package/src/server/workspace-runtime.js +1138 -0
  77. package/src/server/workspace-tasks.js +735 -0
  78. package/start-chrome.bat +84 -0
@@ -0,0 +1,254 @@
1
+ function compactText(value) {
2
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
3
+ }
4
+
5
+ function includesAny(text, terms) {
6
+ return terms.some((term) => text.includes(term));
7
+ }
8
+
9
+ export function getFieldLabel(field) {
10
+ return compactText(
11
+ field?.ariaLabelledByText
12
+ || field?.ariaLabel
13
+ || field?.labelForText
14
+ || field?.placeholder
15
+ || field?.name
16
+ || field?.tag
17
+ || 'unknown'
18
+ );
19
+ }
20
+
21
+ function deriveRiskLevel(label, field) {
22
+ const text = compactText(label).toLowerCase();
23
+ const tag = compactText(field?.tag).toLowerCase();
24
+ const type = compactText(field?.type).toLowerCase();
25
+
26
+ if (
27
+ includesAny(text, ['证件', '身份证', '手机号', '电话', '邮箱', '账号', '账户', '密码', '上传', '提交', '确认', '验证码', '姓名'])
28
+ || includesAny(tag, ['file'])
29
+ || includesAny(type, ['file', 'password'])
30
+ ) {
31
+ return 'sensitive';
32
+ }
33
+
34
+ if (
35
+ includesAny(text, ['城市', '部门', '日期', '时间', '时长', '天数', '学校', '专业', '学历', '年级', '地区', '岗位', '语言', '接受', '调配'])
36
+ || tag === 'select'
37
+ || type === 'select'
38
+ ) {
39
+ return 'review';
40
+ }
41
+
42
+ if (
43
+ includesAny(text, ['研究', '项目', '技能', '经历', '简介', '自我介绍', '主页', 'description', 'bio', 'homepage', 'project', 'research', 'skill'])
44
+ || tag === 'textarea'
45
+ || type === 'textarea'
46
+ ) {
47
+ return 'safe';
48
+ }
49
+
50
+ return 'safe';
51
+ }
52
+
53
+ function deriveCurrentState(field) {
54
+ if (field?.current_state) return field.current_state;
55
+ const type = compactText(field?.type).toLowerCase();
56
+ const tag = compactText(field?.tag).toLowerCase();
57
+ const isCheckable = type === 'checkbox' || type === 'radio' || tag === 'checkbox' || tag === 'radio';
58
+ if (field?.checked === true) return 'filled';
59
+ if (field?.checked === false && isCheckable) return 'missing';
60
+ if (isCheckable) return 'missing';
61
+ if (Array.isArray(field?.value)) return field.value.length > 0 ? 'filled' : 'missing';
62
+ if (field?.value === null || field?.value === undefined) return 'missing';
63
+ return compactText(field.value) ? 'filled' : 'missing';
64
+ }
65
+
66
+ export function normalizeFormField(field) {
67
+ const label = compactText(field?.label) || getFieldLabel(field);
68
+ const normalized_label = compactText(label);
69
+
70
+ return {
71
+ ...field,
72
+ label,
73
+ normalized_label,
74
+ disabled: field?.disabled === true,
75
+ readOnly: field?.readOnly === true || field?.readonly === true,
76
+ current_state: deriveCurrentState(field),
77
+ risk_level: field?.risk_level ?? deriveRiskLevel(label, field),
78
+ };
79
+ }
80
+
81
+ export function summarizeFormFields(fields) {
82
+ const normalizedFields = fields.map((field) => normalizeFormField(field));
83
+ const counts = normalizedFields.reduce((acc, field) => {
84
+ acc[field.risk_level] += 1;
85
+ return acc;
86
+ }, { safe: 0, review: 0, sensitive: 0 });
87
+
88
+ return {
89
+ total: normalizedFields.length,
90
+ safe: counts.safe,
91
+ review: counts.review,
92
+ sensitive: counts.sensitive,
93
+ labels: normalizedFields.map((field) => field.label),
94
+ fields: normalizedFields,
95
+ lines: [
96
+ `Total fields: ${normalizedFields.length}`,
97
+ `Safe: ${counts.safe}`,
98
+ `Review: ${counts.review}`,
99
+ `Sensitive: ${counts.sensitive}`,
100
+ `Labels: ${normalizedFields.map((field) => field.label).join(', ') || 'none'}`,
101
+ ],
102
+ };
103
+ }
104
+
105
+ export function buildFormVerification(fields) {
106
+ const normalizedFields = fields.map((field) => normalizeFormField(field));
107
+ const missingRequired = normalizedFields.filter((field) => field.required && field.current_state !== 'filled').length;
108
+ const riskyPending = normalizedFields.filter((field) => field.risk_level !== 'safe' && field.current_state !== 'filled').length;
109
+ const unresolved = normalizedFields.filter((field) => field.current_state === 'unresolved').length;
110
+
111
+ return {
112
+ completion_status: missingRequired > 0 || riskyPending > 0 || unresolved > 0 ? 'review_required' : 'ready',
113
+ summary: {
114
+ missing_required: missingRequired,
115
+ risky_pending: riskyPending,
116
+ unresolved,
117
+ },
118
+ fields: normalizedFields,
119
+ };
120
+ }
121
+
122
+ export function finalizeFormSnapshot({ fields = [], sections = [], submit_controls = [] } = {}) {
123
+ const normalizedFields = fields.map((field) => normalizeFormField(field));
124
+ const summary = summarizeFormFields(normalizedFields);
125
+ const verification = buildFormVerification(normalizedFields);
126
+ const counts = normalizedFields.reduce((acc, field) => {
127
+ acc[field.normalized_label] = (acc[field.normalized_label] ?? 0) + 1;
128
+ return acc;
129
+ }, {});
130
+
131
+ return {
132
+ sections,
133
+ fields: normalizedFields,
134
+ submit_controls: submit_controls.map((control) => ({
135
+ ...control,
136
+ label: compactText(control?.label) || 'submit',
137
+ risk_level: control?.risk_level ?? 'sensitive',
138
+ })),
139
+ ambiguous_labels: Object.entries(counts)
140
+ .filter(([, count]) => count > 1)
141
+ .map(([label]) => label),
142
+ summary,
143
+ completion_status: verification.completion_status,
144
+ verification: verification.summary,
145
+ };
146
+ }
147
+
148
+ export async function collectVisibleFormSnapshot(page) {
149
+ const rawSnapshot = await page.evaluate(() => {
150
+ function compactText(value) {
151
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
152
+ }
153
+
154
+ function getHintId(el) {
155
+ return el.getAttribute('data-grasp-id') || null;
156
+ }
157
+
158
+ function getLabelForText(el) {
159
+ const id = el.getAttribute('id');
160
+ if (!id) return '';
161
+ const label = document.querySelector(`label[for="${id}"]`);
162
+ return label?.textContent?.trim() ?? '';
163
+ }
164
+
165
+ function getAriaLabelledByText(el) {
166
+ const labelledBy = el.getAttribute('aria-labelledby');
167
+ if (!labelledBy) return '';
168
+ return labelledBy.trim().split(/\s+/)
169
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
170
+ .filter(Boolean)
171
+ .join(' ');
172
+ }
173
+
174
+ function isVisible(el) {
175
+ const rect = el.getBoundingClientRect();
176
+ return rect.width > 0 && rect.height > 0;
177
+ }
178
+
179
+ function getSectionName(el) {
180
+ const fieldset = el.closest('fieldset');
181
+ const legend = fieldset?.querySelector('legend');
182
+ const legendText = compactText(legend?.textContent);
183
+ if (legendText) return legendText;
184
+
185
+ const group = el.closest('section, form, [role="group"], .form-section, .resume-section');
186
+ const heading = group?.querySelector('h1, h2, h3, h4, h5, h6, .section-title, [data-section-title]');
187
+ const headingText = compactText(heading?.textContent);
188
+ return headingText || 'General';
189
+ }
190
+
191
+ function describeField(el) {
192
+ const tag = el.tagName.toLowerCase();
193
+ const type = el.getAttribute('type') || (tag === 'select' ? 'select' : tag === 'textarea' ? 'textarea' : tag);
194
+ return {
195
+ hint_id: getHintId(el),
196
+ tag,
197
+ type,
198
+ id: el.getAttribute('id')?.trim() ?? '',
199
+ name: el.getAttribute('name')?.trim() ?? '',
200
+ required: el.required || el.getAttribute('required') !== null,
201
+ disabled: el.disabled === true,
202
+ readOnly: 'readOnly' in el ? el.readOnly === true : false,
203
+ value: 'value' in el ? el.value : null,
204
+ checked: 'checked' in el ? el.checked : null,
205
+ ariaLabelledByText: getAriaLabelledByText(el),
206
+ ariaLabel: el.getAttribute('aria-label')?.trim() ?? '',
207
+ labelForText: getLabelForText(el),
208
+ placeholder: el.getAttribute('placeholder')?.trim() ?? '',
209
+ section_name: getSectionName(el),
210
+ };
211
+ }
212
+
213
+ const fieldElements = [...document.querySelectorAll('input, textarea, select')]
214
+ .filter((el) => {
215
+ const type = el.getAttribute('type') || '';
216
+ return type !== 'hidden' && isVisible(el);
217
+ });
218
+
219
+ const fields = fieldElements.map(describeField);
220
+ const sections = [...new Set(fields.map((field) => field.section_name))].map((name) => ({
221
+ name,
222
+ field_labels: fields
223
+ .filter((field) => field.section_name === name)
224
+ .map((field) => compactText(
225
+ field.ariaLabelledByText
226
+ || field.ariaLabel
227
+ || field.labelForText
228
+ || field.placeholder
229
+ || field.name
230
+ || field.tag
231
+ || 'unknown'
232
+ )),
233
+ }));
234
+
235
+ const submit_controls = [...document.querySelectorAll('button, input[type="submit"], input[type="button"], input[type="image"]')]
236
+ .filter((el) => isVisible(el))
237
+ .map((el) => ({
238
+ hint_id: getHintId(el),
239
+ tag: el.tagName.toLowerCase(),
240
+ type: el.getAttribute('type') || el.tagName.toLowerCase(),
241
+ label: compactText(
242
+ el.getAttribute('aria-label')
243
+ || el.textContent
244
+ || el.getAttribute('value')
245
+ || 'submit'
246
+ ),
247
+ risk_level: 'sensitive',
248
+ }));
249
+
250
+ return { fields, sections, submit_controls };
251
+ });
252
+
253
+ return finalizeFormSnapshot(rawSnapshot);
254
+ }
@@ -0,0 +1,62 @@
1
+ import { textResponse } from './responses.js';
2
+ import { buildAgentBoundary, buildAgentBoundaryLines } from './route-boundary.js';
3
+ import { buildAgentPrompt } from './prompt-assembly.js';
4
+
5
+ function normalizeLines(value) {
6
+ return Array.isArray(value) ? value : [value];
7
+ }
8
+
9
+ export function buildGatewayResponse({
10
+ status,
11
+ page,
12
+ result = {},
13
+ continuation = {},
14
+ evidence = {},
15
+ runtime = {},
16
+ route = null,
17
+ error_code = null,
18
+ message,
19
+ }) {
20
+ const agentBoundary = buildAgentBoundary({
21
+ status,
22
+ page,
23
+ result,
24
+ continuation,
25
+ route,
26
+ });
27
+ const agentPrompt = buildAgentPrompt({
28
+ status,
29
+ page,
30
+ result,
31
+ continuation,
32
+ route,
33
+ agentBoundary,
34
+ });
35
+ const boundaryLines = buildAgentBoundaryLines(agentBoundary);
36
+ const lines = message
37
+ ? [...normalizeLines(message), ...boundaryLines].filter(Boolean)
38
+ : [
39
+ `Status: ${status}`,
40
+ `Page: ${page?.title ?? 'unknown'}`,
41
+ `URL: ${page?.url ?? 'unknown'}`,
42
+ runtime?.instance?.display ? `Instance: ${runtime.instance.display}` : null,
43
+ runtime?.instance?.warning ? `Instance warning: ${runtime.instance.warning}` : null,
44
+ route?.selected_mode ? `Route: ${route.selected_mode}` : null,
45
+ ...boundaryLines,
46
+ result.summary ? `Summary: ${result.summary}` : null,
47
+ continuation.suggested_next_action ? `Next: ${continuation.suggested_next_action}` : null,
48
+ ].filter(Boolean);
49
+
50
+ return textResponse(lines, {
51
+ status,
52
+ page,
53
+ result,
54
+ continuation,
55
+ evidence,
56
+ runtime,
57
+ ...(agentBoundary ? { agent_boundary: agentBoundary } : {}),
58
+ ...(agentPrompt ? { agent_prompt: agentPrompt } : {}),
59
+ ...(error_code ? { error_code } : {}),
60
+ ...(route ? { route } : {}),
61
+ });
62
+ }
@@ -0,0 +1,22 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+
3
+ import { createServerState } from './state.js';
4
+ import { registerTools } from './tools.js';
5
+ import { createRequire } from 'node:module';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const { version } = require('../../package.json');
9
+
10
+ export const SERVER_INFO = {
11
+ name: 'grasp',
12
+ version,
13
+ };
14
+
15
+ export function createGraspServer() {
16
+ const server = new McpServer(SERVER_INFO);
17
+ const state = createServerState();
18
+
19
+ registerTools(server, state);
20
+
21
+ return { server, state };
22
+ }
@@ -0,0 +1,52 @@
1
+ import { extractMainContent, summarizeExtractedText, toMarkdownDocument, waitUntilStable } from './content.js';
2
+ import { rankAffordances } from './affordances.js';
3
+ import { syncPageState } from './state.js';
4
+
5
+ export async function extractObservedContent({ page, deps = {}, include_markdown = false } = {}) {
6
+ const waitStable = deps.waitStable ?? waitUntilStable;
7
+ const extractContent = deps.extractContent ?? extractMainContent;
8
+ await waitStable(page, { stableChecks: 3, interval: 200, timeout: 5000 });
9
+ const content = await extractContent(page);
10
+ const result = {
11
+ summary: summarizeExtractedText(content.text),
12
+ main_text: content.text,
13
+ };
14
+
15
+ if (include_markdown) {
16
+ result.markdown = toMarkdownDocument(content);
17
+ }
18
+
19
+ return result;
20
+ }
21
+
22
+ export async function observeSearchSnapshot({ page, state, query, frame, deps = {} }) {
23
+ const waitStable = deps.waitStable ?? waitUntilStable;
24
+ const extractContent = deps.extractContent ?? extractMainContent;
25
+ await waitStable(page, { stableChecks: 2, interval: 150, timeout: 2500 });
26
+ await syncPageState(page, state, { force: true });
27
+ const hints = state.hintMap.map((hint) => ({ ...hint }));
28
+ const ranking = rankAffordances({ hints });
29
+ const searchIds = new Set(ranking.search_input.map((hint) => hint.id));
30
+ const annotatedHints = hints.map((hint) => ({
31
+ ...hint,
32
+ semantic: searchIds.has(hint.id)
33
+ ? 'search_input'
34
+ : hint.type === 'button'
35
+ ? 'submit_control'
36
+ : 'candidate',
37
+ }));
38
+ const content = await extractContent(page);
39
+ const domRevision = state.pageState?.domRevision ?? 0;
40
+ const submitCandidate = ranking.command_button?.[0] ?? null;
41
+ return {
42
+ query,
43
+ title: await page.title(),
44
+ url: page.url(),
45
+ hints: annotatedHints,
46
+ ranking,
47
+ content,
48
+ domRevision,
49
+ submitCandidate,
50
+ frameId: frame?.taskId ?? null,
51
+ };
52
+ }
@@ -0,0 +1,31 @@
1
+ import { summarizeExtractedText, toMarkdownDocument } from './content.js';
2
+
3
+ export function buildPageProjection({
4
+ engine,
5
+ surface,
6
+ title,
7
+ url,
8
+ mainText,
9
+ markdown,
10
+ includeMarkdown = false,
11
+ } = {}) {
12
+ const normalizedTitle = String(title ?? '').trim() || 'Untitled';
13
+ const normalizedUrl = String(url ?? '').trim() || 'unknown';
14
+ const normalizedMainText = String(mainText ?? '').trim();
15
+ const result = {
16
+ engine: String(engine ?? 'runtime'),
17
+ surface: String(surface ?? 'content'),
18
+ title: normalizedTitle,
19
+ url: normalizedUrl,
20
+ summary: summarizeExtractedText(normalizedMainText),
21
+ main_text: normalizedMainText,
22
+ };
23
+
24
+ if (markdown !== undefined) {
25
+ result.markdown = markdown;
26
+ } else if (includeMarkdown) {
27
+ result.markdown = toMarkdownDocument({ title: normalizedTitle, text: normalizedMainText });
28
+ }
29
+
30
+ return result;
31
+ }
@@ -0,0 +1,27 @@
1
+ export function createPageState() {
2
+ return {
3
+ lastUrl: null,
4
+ domRevision: 0,
5
+ lastSnapshotHash: null,
6
+ };
7
+ }
8
+
9
+ export function applySnapshotToPageState(state, { url, snapshotHash }) {
10
+ const next = {
11
+ ...state,
12
+ lastUrl: url,
13
+ lastSnapshotHash: snapshotHash,
14
+ };
15
+
16
+ const sameUrl = state.lastUrl === url;
17
+
18
+ if (!sameUrl) {
19
+ next.domRevision = 0;
20
+ } else if (state.lastSnapshotHash && state.lastSnapshotHash !== snapshotHash) {
21
+ next.domRevision = state.domRevision + 1;
22
+ } else {
23
+ next.domRevision = state.domRevision;
24
+ }
25
+
26
+ return next;
27
+ }
@@ -0,0 +1,128 @@
1
+ import { ACTION_NOT_VERIFIED } from './error-codes.js';
2
+
3
+ const EXECUTION_CONTEXT_DESTROYED = 'Execution context was destroyed';
4
+
5
+ function isExecutionContextDestroyed(error) {
6
+ return typeof error?.message === 'string' && error.message.includes(EXECUTION_CONTEXT_DESTROYED);
7
+ }
8
+
9
+ export async function verifyTypeResult({
10
+ page,
11
+ expectedText,
12
+ allowPageChange = false,
13
+ prevUrl = null,
14
+ prevDomRevision = null,
15
+ newDomRevision = null,
16
+ }) {
17
+ const currentUrl = page.url();
18
+ const hasPrevDom = typeof prevDomRevision === 'number';
19
+ const hasNewDom = typeof newDomRevision === 'number';
20
+ const domRevisionValue = hasNewDom ? newDomRevision : hasPrevDom ? prevDomRevision : null;
21
+ const domChanged = hasPrevDom && hasNewDom && newDomRevision !== prevDomRevision;
22
+ const urlChanged = prevUrl != null && currentUrl !== prevUrl;
23
+ const navigationObserved = allowPageChange && (domChanged || urlChanged);
24
+ const baseEvidence = {
25
+ url: currentUrl,
26
+ domRevision: domRevisionValue,
27
+ navigationObserved,
28
+ };
29
+
30
+ try {
31
+ const evidence = await page.evaluate(() => {
32
+ const active = document.activeElement;
33
+ const tag = active?.tagName?.toLowerCase() ?? '';
34
+ const value = active?.value ?? '';
35
+ const text = active?.innerText?.trim?.() ?? active?.textContent?.trim?.() ?? '';
36
+ const isFormField = ['input', 'textarea'].includes(tag) || active?.isContentEditable;
37
+ return { value, text, tag, isFormField };
38
+ });
39
+
40
+ const wroteExpectedText = evidence.value === expectedText || evidence.text === expectedText;
41
+ if (wroteExpectedText && evidence.isFormField) {
42
+ return { ok: true, evidence };
43
+ }
44
+
45
+ if (navigationObserved) {
46
+ return { ok: true, evidence: baseEvidence };
47
+ }
48
+
49
+ return {
50
+ ok: false,
51
+ error_code: ACTION_NOT_VERIFIED,
52
+ retryable: true,
53
+ suggested_next_step: 'reverify',
54
+ evidence,
55
+ };
56
+ } catch (error) {
57
+ if (navigationObserved && isExecutionContextDestroyed(error)) {
58
+ return { ok: true, evidence: baseEvidence };
59
+ }
60
+
61
+ return {
62
+ ok: false,
63
+ error_code: ACTION_NOT_VERIFIED,
64
+ retryable: true,
65
+ suggested_next_step: 'reverify',
66
+ evidence: { ...baseEvidence, error: error?.message ?? null },
67
+ };
68
+ }
69
+ }
70
+
71
+ export async function verifyGenericAction({ page, hintId, prevDomRevision, prevUrl, prevActiveId, newDomRevision }) {
72
+ const currentUrl = page.url();
73
+ const domRevisionValue = typeof newDomRevision === 'number' ? newDomRevision : null;
74
+ const baseEvidence = {
75
+ url: currentUrl,
76
+ domRevision: domRevisionValue,
77
+ };
78
+
79
+ let snapshot;
80
+ let evaluateError = null;
81
+ try {
82
+ snapshot = await page.evaluate((targetId) => {
83
+ const el = targetId ? document.querySelector(`[data-grasp-id="${targetId}"]`) : null;
84
+ const activeId = document.activeElement?.getAttribute('data-grasp-id') ?? null;
85
+ return {
86
+ elementVisible: !!el,
87
+ activeId,
88
+ };
89
+ }, hintId);
90
+ } catch (error) {
91
+ evaluateError = error;
92
+ snapshot = {
93
+ elementVisible: false,
94
+ activeId: null,
95
+ };
96
+ }
97
+
98
+ const domChanged = typeof newDomRevision === 'number' && typeof prevDomRevision === 'number' && newDomRevision !== prevDomRevision;
99
+ const urlChanged = prevUrl != null && currentUrl !== prevUrl;
100
+ const activeChanged = snapshot.activeId !== prevActiveId;
101
+ const navigationObserved = domChanged || urlChanged;
102
+
103
+ if (navigationObserved || activeChanged) {
104
+ return {
105
+ ok: true,
106
+ evidence: {
107
+ ...baseEvidence,
108
+ elementVisible: snapshot.elementVisible,
109
+ activeId: snapshot.activeId,
110
+ navigationObserved,
111
+ },
112
+ };
113
+ }
114
+
115
+ return {
116
+ ok: false,
117
+ error_code: ACTION_NOT_VERIFIED,
118
+ retryable: true,
119
+ suggested_next_step: 'reverify',
120
+ evidence: {
121
+ ...baseEvidence,
122
+ elementVisible: snapshot.elementVisible,
123
+ activeId: snapshot.activeId,
124
+ navigationObserved,
125
+ error: evaluateError?.message ?? null,
126
+ },
127
+ };
128
+ }