@yusufffararatt/dombridge-mcp 2.7.5

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 (49) hide show
  1. package/README.md +559 -0
  2. package/bin/cli.js +88 -0
  3. package/package.json +54 -0
  4. package/src/bridge/http-server.js +290 -0
  5. package/src/bridge/middleware.js +56 -0
  6. package/src/bridge/routes.js +1003 -0
  7. package/src/bridge-daemon.js +172 -0
  8. package/src/cli/auto-config.js +120 -0
  9. package/src/constants.js +13 -0
  10. package/src/index.js +279 -0
  11. package/src/mcp-bridge.js +136 -0
  12. package/src/metrics/error-codes.js +44 -0
  13. package/src/metrics/index.js +3 -0
  14. package/src/metrics/metrics-db.js +269 -0
  15. package/src/metrics/metrics-recorder.js +240 -0
  16. package/src/metrics/metrics-report.js +146 -0
  17. package/src/profiles/profile-db.js +159 -0
  18. package/src/profiles/profile-enricher.js +333 -0
  19. package/src/profiles/profile-manager.js +563 -0
  20. package/src/profiles/profile-repo.js +183 -0
  21. package/src/state/bridge-client.js +272 -0
  22. package/src/state/bridge-persistence.js +205 -0
  23. package/src/state/cache.js +38 -0
  24. package/src/state/extension-state.js +321 -0
  25. package/src/tools/action_tools.js +218 -0
  26. package/src/tools/analyze-page.js +247 -0
  27. package/src/tools/debug-mcp-state.js +172 -0
  28. package/src/tools/discover-apis.js +186 -0
  29. package/src/tools/execute-js.js +284 -0
  30. package/src/tools/export-session.js +171 -0
  31. package/src/tools/extract-data.js +395 -0
  32. package/src/tools/get-element.js +281 -0
  33. package/src/tools/get-network-trace.js +471 -0
  34. package/src/tools/index.js +110 -0
  35. package/src/tools/manage-site-profile.js +153 -0
  36. package/src/tools/paginate.js +444 -0
  37. package/src/tools/quick-scan.js +418 -0
  38. package/src/tools/screenshot_tools.js +117 -0
  39. package/src/utils/circuit-breaker.js +112 -0
  40. package/src/utils/extract-density.js +21 -0
  41. package/src/utils/logger.js +31 -0
  42. package/src/utils/paginate-detector.js +24 -0
  43. package/src/utils/rate-limiter.js +244 -0
  44. package/src/utils/run-script.js +37 -0
  45. package/src/utils/selector-validator.js +95 -0
  46. package/src/utils/state-validator.js +354 -0
  47. package/src/utils/tab-resolver.js +70 -0
  48. package/src/utils/workflow-helper.js +292 -0
  49. package/src/utils/workflow-state.js +177 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * State Validator
3
+ * Validates extension state for freshness, completeness, and consistency.
4
+ *
5
+ * Phase 2.4: Accepts either extensionData (in-process) or bridgeClient (thin client).
6
+ * Both provide the same property getters, so internal logic is unchanged.
7
+ *
8
+ * Uses workflow-state.js for per-tab timestamps where available.
9
+ */
10
+
11
+ import { getTabState } from './workflow-state.js';
12
+
13
+ /**
14
+ * Default max age for data freshness (15 minutes)
15
+ */
16
+ const DEFAULT_MAX_AGE = 15 * 60 * 1000;
17
+
18
+ /**
19
+ * State Validator Class
20
+ */
21
+ export class StateValidator {
22
+ /**
23
+ * Validate selected element state
24
+ * @param {object} state - Extension state (extensionData or bridgeClient)
25
+ * @param {number} maxAge - Maximum age in ms
26
+ * @returns {object} - { valid, error, data }
27
+ */
28
+ static validateSelectedElement(state, maxAge = DEFAULT_MAX_AGE) {
29
+ if (!state.selectedElement) {
30
+ return {
31
+ valid: false,
32
+ error: 'No element selected',
33
+ suggestion: 'Use the Chrome extension or call get_element with a CSS/XPath selector',
34
+ nextStep: 'get_element({ selectorInfo: { css: "..." } })'
35
+ };
36
+ }
37
+
38
+ const el = state.selectedElement;
39
+
40
+ // Check required fields
41
+ if (!el.cssSelector || !el.xpath) {
42
+ return {
43
+ valid: false,
44
+ error: 'Selected element data incomplete',
45
+ suggestion: 'Re-select the element in Chrome extension'
46
+ };
47
+ }
48
+
49
+ // Check freshness
50
+ if (el.timestamp && !this.isDataFresh(el.timestamp, maxAge)) {
51
+ return {
52
+ valid: false,
53
+ error: `Selected element data is stale (${this.getAgeString(el.timestamp)})`,
54
+ suggestion: 'Re-select the element to get fresh data',
55
+ stale: true
56
+ };
57
+ }
58
+
59
+ return {
60
+ valid: true,
61
+ data: el
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Validate network trace state
67
+ * @param {object} state - Extension state (extensionData or bridgeClient)
68
+ * @param {number} maxAge - Maximum age in ms
69
+ * @returns {object} - { valid, error, data }
70
+ */
71
+ static validateNetworkTrace(state, maxAge = DEFAULT_MAX_AGE) {
72
+ if (!state.networkTrace || state.networkTrace.totalMatches === 0) {
73
+ return {
74
+ valid: false,
75
+ error: 'No network trace available',
76
+ suggestion: 'Select a DOM element first to capture network trace',
77
+ nextStep: 'get_element → get_network_trace'
78
+ };
79
+ }
80
+
81
+ const trace = state.networkTrace;
82
+
83
+ // Check freshness
84
+ if (trace.timestamp && !this.isDataFresh(trace.timestamp, maxAge)) {
85
+ return {
86
+ valid: false,
87
+ error: `Network trace data is stale (${this.getAgeString(trace.timestamp)})`,
88
+ suggestion: 'Re-select the element to get fresh network trace',
89
+ stale: true
90
+ };
91
+ }
92
+
93
+ // Check data quality
94
+ if (!trace.matches || trace.matches.length === 0) {
95
+ return {
96
+ valid: false,
97
+ error: 'Network trace has no matches',
98
+ suggestion: 'The selected element may not be populated by API calls. Check Data Source Analysis in get_selected_element output.'
99
+ };
100
+ }
101
+
102
+ return {
103
+ valid: true,
104
+ data: trace
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Validate WebSocket trace state
110
+ * @param {object} state - Extension state (extensionData or bridgeClient)
111
+ * @param {number} maxAge - Maximum age in ms
112
+ * @returns {object} - { valid, error, data }
113
+ */
114
+ static validateWebSocketTrace(state, maxAge = DEFAULT_MAX_AGE) {
115
+ if (!state.websocketTrace || state.websocketTrace.totalMatches === 0) {
116
+ return {
117
+ valid: false,
118
+ error: 'No WebSocket trace available',
119
+ suggestion: 'This page may not use WebSockets, or no matching messages found',
120
+ nextStep: 'Try get_network_trace for REST API calls instead'
121
+ };
122
+ }
123
+
124
+ const trace = state.websocketTrace;
125
+
126
+ // Check freshness
127
+ if (trace.timestamp && !this.isDataFresh(trace.timestamp, maxAge)) {
128
+ return {
129
+ valid: false,
130
+ error: `WebSocket trace data is stale (${this.getAgeString(trace.timestamp)})`,
131
+ suggestion: 'Re-select the element to get fresh WebSocket trace',
132
+ stale: true
133
+ };
134
+ }
135
+
136
+ return {
137
+ valid: true,
138
+ data: trace
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Validate connection status.
144
+ * @param {object} state - Extension state (extensionData or bridgeClient)
145
+ * @param {string|number} [tabId] - Optional tab ID for per-tab navigation checks
146
+ * @returns {object} - { valid, error }
147
+ */
148
+ static validateConnection(state, tabId) {
149
+ if (!state.isConnected) {
150
+ return {
151
+ valid: false,
152
+ error: 'Extension not connected',
153
+ suggestion: 'Make sure the Chrome extension is installed and a tab is open',
154
+ nextStep: '1. Install Chrome extension\n2. Open any webpage\n3. Click extension icon'
155
+ };
156
+ }
157
+
158
+ // Check last update time
159
+ if (state.lastUpdateTime) {
160
+ const age = Date.now() - state.lastUpdateTime;
161
+ if (age > 25 * 60 * 1000) { // 25 minutes
162
+ return {
163
+ valid: false,
164
+ error: 'Extension connection seems inactive',
165
+ suggestion: 'Interact with the extension or refresh the page',
166
+ stale: true
167
+ };
168
+ }
169
+ }
170
+
171
+ // Attach tab-level context if available (informational, not blocking)
172
+ if (tabId) {
173
+ const tabState = getTabState(tabId);
174
+ return { valid: true, tabState };
175
+ }
176
+
177
+ return { valid: true };
178
+ }
179
+
180
+ /**
181
+ * Validate JS execution request
182
+ * @param {object} state - Extension state (extensionData or bridgeClient)
183
+ * @returns {object} - { valid, error, data }
184
+ */
185
+ static validateJsExecutionRequest(state) {
186
+ if (!state.jsExecutionRequest) {
187
+ return {
188
+ valid: false,
189
+ error: 'No pending JavaScript execution request',
190
+ suggestion: 'Call execute_js to execute code',
191
+ nextStep: 'execute_js'
192
+ };
193
+ }
194
+
195
+ const request = state.jsExecutionRequest;
196
+
197
+ // Check if request is too old (30 seconds timeout)
198
+ if (request.timestamp) {
199
+ const age = Date.now() - new Date(request.timestamp).getTime();
200
+ if (age > 30000) {
201
+ return {
202
+ valid: false,
203
+ error: 'Execution request timed out',
204
+ suggestion: 'Execute the code again with execute_js',
205
+ stale: true
206
+ };
207
+ }
208
+ }
209
+
210
+ return {
211
+ valid: true,
212
+ data: request
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Validate that there's NO pending execution (prevents consecutive execute_js/query_dom calls)
218
+ * CRITICAL: This prevents LLMs from calling execute_js/query_dom multiple times without reading results
219
+ * @param {object} state - Extension state (extensionData or bridgeClient)
220
+ * @returns {object} - { valid, error }
221
+ */
222
+ static validateNoPendingExecution(state) {
223
+ // Check JavaScript execution
224
+ const jsRequest = state.jsExecutionRequest;
225
+ const jsResult = state.jsExecutionResult;
226
+
227
+ if (jsRequest && jsRequest.timestamp) {
228
+ const requestTime = new Date(jsRequest.timestamp).getTime();
229
+ const resultTime = jsResult?.timestamp ? new Date(jsResult.timestamp).getTime() : 0;
230
+
231
+ // If request is newer than result (or no result), there's a pending execution
232
+ if (requestTime > resultTime) {
233
+ return {
234
+ valid: false,
235
+ error: 'Previous execute_js call is still pending',
236
+ suggestion: '⚠️ CRITICAL: The previous code is still executing. Please wait a moment.',
237
+ nextStep: '1. Wait for the result to return automatically',
238
+ pendingType: 'execute_js',
239
+ requestId: jsRequest.id
240
+ };
241
+ }
242
+ }
243
+
244
+ return {
245
+ valid: true
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Check if data is fresh
251
+ * @param {number} timestamp - Data timestamp
252
+ * @param {number} maxAge - Maximum age in ms
253
+ * @returns {boolean}
254
+ */
255
+ static isDataFresh(timestamp, maxAge = DEFAULT_MAX_AGE) {
256
+ if (!timestamp) return false;
257
+
258
+ const age = Date.now() - timestamp;
259
+ return age <= maxAge;
260
+ }
261
+
262
+ /**
263
+ * Get human-readable age string
264
+ * @param {number} timestamp - Data timestamp
265
+ * @returns {string}
266
+ */
267
+ static getAgeString(timestamp) {
268
+ if (!timestamp) return 'unknown age';
269
+
270
+ const age = Date.now() - timestamp;
271
+ const seconds = Math.floor(age / 1000);
272
+ const minutes = Math.floor(seconds / 60);
273
+
274
+ if (minutes > 0) {
275
+ return `${minutes} minute${minutes !== 1 ? 's' : ''} old`;
276
+ }
277
+
278
+ return `${seconds} second${seconds !== 1 ? 's' : ''} old`;
279
+ }
280
+
281
+ /**
282
+ * Validate generic state requirements
283
+ * @param {object} requirements - { required: string[], maxAge: number }
284
+ * @param {object} state - Extension state (extensionData or bridgeClient)
285
+ * @returns {object} - { valid, error, missing }
286
+ */
287
+ static validateGenericState(requirements, state) {
288
+ const missing = [];
289
+ const stale = [];
290
+
291
+ for (const field of requirements.required || []) {
292
+ // Check if field exists
293
+ if (!state[field]) {
294
+ missing.push(field);
295
+ continue;
296
+ }
297
+
298
+ // Check freshness if timestamp available
299
+ const data = state[field];
300
+ if (data.timestamp && requirements.maxAge) {
301
+ if (!this.isDataFresh(data.timestamp, requirements.maxAge)) {
302
+ stale.push({
303
+ field,
304
+ age: this.getAgeString(data.timestamp)
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ if (missing.length > 0) {
311
+ return {
312
+ valid: false,
313
+ error: `Missing required data: ${missing.join(', ')}`,
314
+ missing
315
+ };
316
+ }
317
+
318
+ if (stale.length > 0) {
319
+ return {
320
+ valid: false,
321
+ error: `Stale data detected: ${stale.map(s => `${s.field} (${s.age})`).join(', ')}`,
322
+ stale
323
+ };
324
+ }
325
+
326
+ return {
327
+ valid: true
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Format validation error as MCP response
333
+ * @param {object} validation - Validation result
334
+ * @returns {object} - MCP response format
335
+ */
336
+ static formatValidationError(validation) {
337
+ let text = `❌ ${validation.error}\n`;
338
+
339
+ if (validation.suggestion) {
340
+ text += `\n💡 ${validation.suggestion}\n`;
341
+ }
342
+
343
+ if (validation.nextStep) {
344
+ text += `\n📋 NEXT STEPS:\n${validation.nextStep}\n`;
345
+ }
346
+
347
+ return {
348
+ content: [{
349
+ type: 'text',
350
+ text
351
+ }]
352
+ };
353
+ }
354
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tab Resolver
3
+ * Resolves tabId to URL by querying the extension for open tabs.
4
+ * Used by tools that need domain resolution for profile operations.
5
+ *
6
+ * Phase 2.4: Refactored from (extensionData, httpPort) to (bridgeClient).
7
+ * All tab resolution now goes through BridgeClient HTTP calls.
8
+ */
9
+
10
+ import { getTabState, extractDomain } from './workflow-state.js';
11
+
12
+ /**
13
+ * Fetch open tabs from extension via bridge daemon.
14
+ * @param {BridgeClient} bridgeClient
15
+ * @returns {Promise<Array<{id: number, url: string, title: string, active: boolean}>>}
16
+ */
17
+ async function fetchTabs(bridgeClient) {
18
+ const requestId = `tabs-resolve-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
19
+
20
+ try {
21
+ await bridgeClient.queueRequest('tabs', { id: requestId });
22
+ const result = await bridgeClient.waitForResult('tabs', requestId, 2000);
23
+ if (result && result.tabs) {
24
+ return result.tabs;
25
+ }
26
+ } catch {
27
+ // Non-critical — tab resolution is best-effort
28
+ }
29
+ return [];
30
+ }
31
+
32
+ /**
33
+ * Resolve a tabId to its page URL. Tries workflow-state cache first, then live query.
34
+ * @param {BridgeClient} bridgeClient
35
+ * @param {number} tabId
36
+ * @returns {Promise<string>} URL or empty string
37
+ */
38
+ export async function resolveTabUrl(bridgeClient, tabId) {
39
+ // 1. Check workflow-state cache
40
+ const cached = getTabState(tabId);
41
+ if (cached?.url) return cached.url;
42
+
43
+ // 2. Live query to extension
44
+ const tabs = await fetchTabs(bridgeClient);
45
+ const match = tabs.find((t) => t.id === tabId);
46
+ return match?.url || '';
47
+ }
48
+
49
+ /**
50
+ * Get the correct domain for a given tool call, accounting for tabId.
51
+ * Falls back to activeTabUrl → selectedElement URLs if no tabId specified.
52
+ * @param {BridgeClient} bridgeClient
53
+ * @param {number|undefined} tabId
54
+ * @returns {Promise<string>} hostname or empty string
55
+ */
56
+ export async function resolveActiveDomain(bridgeClient, tabId) {
57
+ // If tabId specified, resolve that tab's URL — do NOT fall back to active tab
58
+ // (falling back would silently save to wrong domain if tab resolution fails)
59
+ if (tabId !== undefined) {
60
+ const url = await resolveTabUrl(bridgeClient, tabId);
61
+ return url ? extractDomain(url) : '';
62
+ }
63
+
64
+ // Fallback: activeTabUrl → selectedElement URLs
65
+ const candidate = bridgeClient.activeTabUrl
66
+ || bridgeClient.selectedElement?.sessionInfo?.url
67
+ || bridgeClient.selectedElement?.pageUrl
68
+ || '';
69
+ return candidate ? extractDomain(candidate) : '';
70
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Workflow Helper
3
+ * Manages tool dependencies, prerequisites, and workflow guidance for LLM agents.
4
+ *
5
+ * This module reads from workflow-state.js but does NOT modify it.
6
+ * State mutations happen inside tool handlers via the workflow-state API.
7
+ *
8
+ * References:
9
+ * - https://www.anthropic.com/engineering/code-execution-with-mcp
10
+ * - https://docs.claude.com/en/docs/build-with-claude/prompt-engineering/claude-4-best-practices
11
+ */
12
+
13
+ import { getTabState, isProfileFresh } from './workflow-state.js';
14
+
15
+ /**
16
+ * Tool dependency graph
17
+ * Defines which tools must be called before others.
18
+ */
19
+ const ToolDependencies = {
20
+ // get_network_trace requires an element to have been selected first
21
+ 'get_network_trace': {
22
+ description: 'Retrieves network API calls that match the selected DOM element',
23
+ requires: ['get_element']
24
+ },
25
+ // JavaScript execution workflow
26
+ 'execute_js': {
27
+ description: 'Executes custom JavaScript code'
28
+ },
29
+ };
30
+
31
+
32
+ /**
33
+ * Validate tool prerequisites.
34
+ * @param {string} toolName - Name of the tool to validate
35
+ * @param {object} state - Current state (extensionData or bridgeClient)
36
+ * @param {object} [context] - Optional context: { tabId, domain }
37
+ * @returns {object} - { valid: boolean, missing: string[], suggestions: string[] }
38
+ */
39
+ export function validatePrerequisites(toolName, state, context = {}) {
40
+ const dependency = ToolDependencies[toolName];
41
+
42
+ // No dependencies = always valid
43
+ if (!dependency) {
44
+ return { valid: true, missing: [], suggestions: [] };
45
+ }
46
+
47
+ const missing = [];
48
+ const suggestions = [];
49
+
50
+ // Check required dependencies
51
+ for (const requiredTool of dependency.requires || []) {
52
+ const isAvailable = checkToolDataAvailable(requiredTool, state);
53
+
54
+ if (!isAvailable) {
55
+ missing.push(requiredTool);
56
+ suggestions.push(`Call ${requiredTool} first`);
57
+ }
58
+ }
59
+
60
+ // Attach workflow state hints when context is available
61
+ const hints = buildWorkflowHints(toolName, context);
62
+
63
+ return {
64
+ valid: missing.length === 0,
65
+ missing,
66
+ suggestions,
67
+ reason: dependency.description,
68
+ hints
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Build soft hints based on workflow state (no blocking — informational only).
74
+ * @param {string} toolName
75
+ * @param {{ tabId?: string|number, domain?: string }} context
76
+ * @returns {string[]}
77
+ */
78
+ function buildWorkflowHints(toolName, context) {
79
+ const hints = [];
80
+ const { tabId, domain } = context;
81
+
82
+ if (domain && isProfileFresh(domain)) {
83
+ if (toolName === 'discover_apis') {
84
+ hints.push(`A fresh profile exists for ${domain}. Consider load_site_profile to skip re-discovery.`);
85
+ }
86
+ }
87
+
88
+ if (tabId) {
89
+ const tabState = getTabState(tabId);
90
+ if (tabState.lastAnalyzeAt && toolName === 'analyze_page') {
91
+ const ageS = Math.round((Date.now() - tabState.lastAnalyzeAt) / 1000);
92
+ if (ageS < 60) {
93
+ hints.push(`analyze_page was called ${ageS}s ago on this tab — data is likely still fresh.`);
94
+ }
95
+ }
96
+ }
97
+
98
+ return hints;
99
+ }
100
+
101
+ /**
102
+ * Check if required data for a tool is available
103
+ * @param {string} toolName - Tool name
104
+ * @param {object} state - Extension state (extensionData or bridgeClient)
105
+ * @returns {boolean}
106
+ */
107
+ function checkToolDataAvailable(toolName, state) {
108
+ switch (toolName) {
109
+ case 'get_element':
110
+ return state.selectedElement &&
111
+ state.selectedElement.cssSelector;
112
+
113
+ case 'get_network_trace':
114
+ return state.networkTrace &&
115
+ state.networkTrace.totalMatches > 0;
116
+
117
+ case 'execute_js':
118
+ // Pending request OR result present means execution has been issued
119
+ return state.jsExecutionRequest !== null ||
120
+ state.jsExecutionResult !== null;
121
+
122
+ default:
123
+ return true; // Unknown tool, assume available
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Suggest next tools in workflow
129
+ * @param {string} currentTool - Current tool name
130
+ * @returns {Array} - Array of { tool, reason, priority }
131
+ */
132
+ export function suggestNextTools(currentTool) {
133
+ const suggestions = [];
134
+
135
+ // After element selection - find which API populates it
136
+ if (currentTool === 'get_element') {
137
+ suggestions.push({
138
+ tool: 'get_network_trace',
139
+ reason: 'Find matching API calls and inspect details',
140
+ priority: 'high'
141
+ });
142
+ }
143
+
144
+
145
+ // After JS execution
146
+ if (currentTool === 'execute_js') {
147
+ // We don't suggest get_execution_results anymore as it's blocking
148
+ // But we might suggest analyzing the result or another JS execution
149
+ }
150
+
151
+ return suggestions;
152
+ }
153
+
154
+
155
+ /**
156
+ * Format a hard guard (missing prerequisites) as an MCP response object.
157
+ * Use this when a tool MUST be blocked.
158
+ * @param {string} toolName
159
+ * @param {object} validation - { missing: string[], suggestions: string[] }
160
+ * @returns {object} MCP content response
161
+ */
162
+ export function formatHardGuard(toolName, validation) {
163
+ const next = validation.missing[0] || 'unknown';
164
+ const text =
165
+ `❌ Cannot run \`${toolName}\`: missing prerequisites.\n\n` +
166
+ `Required first: \`${validation.missing.join(' → ')}\`\n` +
167
+ `Next step: \`${next}\``;
168
+
169
+ return { content: [{ type: 'text', text }] };
170
+ }
171
+
172
+ /**
173
+ * Format a soft guard (stale-but-usable hint) as an MCP response object.
174
+ * Appends to existing output — does NOT block execution.
175
+ * @param {string[]} hints
176
+ * @returns {string} Text to append (empty string if no hints)
177
+ */
178
+ export function formatSoftGuard(hints) {
179
+ if (!hints || hints.length === 0) return '';
180
+ return '\n\n' + hints.map(h => `💡 ${h}`).join('\n');
181
+ }
182
+
183
+ /**
184
+ * Evaluate soft guard for discover_apis.
185
+ * Returns hints array (empty = no soft guard triggered).
186
+ * @param {object} state - Extension state (extensionData or bridgeClient)
187
+ * @param {{ tabId?: string|number, domain?: string, force?: boolean }} context
188
+ * @returns {string[]}
189
+ */
190
+ export function softGuardDiscoverApis(state, context = {}) {
191
+ const { domain, force } = context;
192
+ if (force) return [];
193
+ if (domain && isProfileFresh(domain)) {
194
+ return [
195
+ `A fresh profile exists for \`${domain}\`. ` +
196
+ `Consider \`load_site_profile({ domain: "${domain}" })\` to skip re-discovery. ` +
197
+ `Pass \`force: true\` to run anyway.`
198
+ ];
199
+ }
200
+ return [];
201
+ }
202
+
203
+ /**
204
+ * Evaluate soft guard for analyze_page.
205
+ * Returns hints array (empty = no soft guard triggered).
206
+ * @param {{ tabId?: string|number }} context
207
+ * @returns {string[]}
208
+ */
209
+ export function softGuardAnalyzePage(context = {}) {
210
+ const { tabId } = context;
211
+ if (!tabId) return [];
212
+ const tabState = getTabState(tabId);
213
+ if (!tabState.lastAnalyzeAt) return [];
214
+ const ageS = Math.round((Date.now() - tabState.lastAnalyzeAt) / 1000);
215
+ if (ageS < 60) {
216
+ return [`analyze_page was called ${ageS}s ago on this tab — data is likely still fresh.`];
217
+ }
218
+ return [];
219
+ }
220
+
221
+ /**
222
+ * Evaluate soft guard for load_site_profile.
223
+ * Returns hints array (empty = no soft guard triggered).
224
+ * @param {string} domain
225
+ * @returns {string[]}
226
+ */
227
+ export function softGuardLoadSiteProfile(domain) {
228
+ // No saved profile for this domain — suggest quick_scan
229
+ // Caller must check loadProfile result first; this just provides the hint text.
230
+ if (!domain) return [];
231
+ return [
232
+ `No saved profile for \`${domain}\`. ` +
233
+ `Run \`quick_scan\` to discover the site and build a profile automatically.`
234
+ ];
235
+ }
236
+
237
+ /**
238
+ * @deprecated Use formatHardGuard instead.
239
+ * Format prerequisite error message (kept for call sites not yet migrated).
240
+ */
241
+ export function formatPrerequisiteError(toolName, validation) {
242
+ if (validation.valid) return null;
243
+ let message = `Cannot execute ${toolName}: Missing prerequisites\n\n`;
244
+ message += `Required: ${validation.missing.join(' -> ')}\n`;
245
+ message += `Next: ${validation.missing[0]}`;
246
+ return message;
247
+ }
248
+
249
+ /**
250
+ * Format workflow suggestion message
251
+ * @param {Array} suggestions - Next tool suggestions
252
+ * @returns {string} - Formatted suggestion message
253
+ */
254
+ export function formatWorkflowSuggestions(suggestions) {
255
+ if (!suggestions || suggestions.length === 0) {
256
+ return '';
257
+ }
258
+
259
+ // Get the highest priority suggestion
260
+ const nextTool = suggestions.find(s => s.priority === 'high') || suggestions[0];
261
+ return `\nNext: ${nextTool.tool}`;
262
+ }
263
+ /**
264
+ * Wait for a result to appear in extensionData
265
+ * @deprecated Use bridgeClient.waitForResult(type, requestId, timeoutMs) instead.
266
+ * This function is kept for backward compatibility but should not be used in new code.
267
+ * @param {object} state - Extension state (extensionData or bridgeClient)
268
+ * @param {string} resultKey - Key to check (e.g., 'jsExecutionResult')
269
+ * @param {number} requestId - Request ID to match
270
+ * @param {number} timeoutMs - Max wait time
271
+ * @returns {Promise<object|null>} - Found result or null
272
+ */
273
+ export async function waitForResult(state, resultKey, requestId, timeoutMs = 10000) {
274
+ const startTime = Date.now();
275
+ const pollInterval = 500; // 500ms
276
+
277
+ while (Date.now() - startTime < timeoutMs) {
278
+ const result = state[resultKey];
279
+
280
+ // Check if we have a result that matches our request
281
+ // Note: requestId might not be present in results from older extensions,
282
+ // so we also check timestamp if available.
283
+ if (result && (!requestId || result.id === requestId || result.requestId === requestId)) {
284
+ return result;
285
+ }
286
+
287
+ // Wait for next poll
288
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
289
+ }
290
+
291
+ return null;
292
+ }