leapfrog-mcp 0.6.2 → 0.6.3
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.
- package/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
3
|
+
const MAX_HISTORY = 50;
|
|
4
|
+
const LOOP_WINDOW = 10;
|
|
5
|
+
const LOOP_THRESHOLD = 3;
|
|
6
|
+
const STUCK_THRESHOLD = 5;
|
|
7
|
+
const BLOCKED_KEYWORDS_STRONG = [
|
|
8
|
+
"captcha",
|
|
9
|
+
"verify you're human",
|
|
10
|
+
"verify you are human",
|
|
11
|
+
"access denied",
|
|
12
|
+
"security check",
|
|
13
|
+
"bot detection",
|
|
14
|
+
"prove you're not a robot",
|
|
15
|
+
"i'm not a robot",
|
|
16
|
+
"unusual traffic",
|
|
17
|
+
];
|
|
18
|
+
const BLOCKED_KEYWORDS_WEAK = [
|
|
19
|
+
"challenge",
|
|
20
|
+
"cloudflare",
|
|
21
|
+
"please wait",
|
|
22
|
+
"checking your browser",
|
|
23
|
+
"just a moment",
|
|
24
|
+
"blocked",
|
|
25
|
+
];
|
|
26
|
+
/** Element count above which only strong challenge signals trigger BLOCKED */
|
|
27
|
+
const BLOCKED_ELEMENT_THRESHOLD = 50;
|
|
28
|
+
const NON_INTERACTIVE_ELEMENTS = new Set([
|
|
29
|
+
"p", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
30
|
+
"div", "span", "section", "article", "header", "footer",
|
|
31
|
+
"main", "nav", "aside", "li", "dt", "dd", "td", "th", "tr",
|
|
32
|
+
]);
|
|
33
|
+
/** ARIA roles that are non-interactive (from snapshot @eN refs) */
|
|
34
|
+
const NON_INTERACTIVE_ROLES = new Set([
|
|
35
|
+
"paragraph", "heading", "generic", "group", "region",
|
|
36
|
+
"contentinfo", "banner", "complementary", "navigation",
|
|
37
|
+
"main", "article", "section", "figure", "list", "listitem",
|
|
38
|
+
"definition", "term", "cell", "row", "rowgroup", "columnheader",
|
|
39
|
+
"rowheader", "table", "img", "separator", "presentation", "none",
|
|
40
|
+
"status", "blockquote", "caption", "code", "deletion", "emphasis",
|
|
41
|
+
"insertion", "strong", "subscript", "superscript", "time",
|
|
42
|
+
]);
|
|
43
|
+
/** Roles that suggest what the agent should do instead */
|
|
44
|
+
const ROLE_SUGGESTIONS = {
|
|
45
|
+
paragraph: "Consider using `extract` to read content, or find a link/button nearby.",
|
|
46
|
+
heading: "Consider using `extract` to read content, or find a link/button nearby.",
|
|
47
|
+
generic: "This is a non-interactive container. Look for a button, link, or input inside it.",
|
|
48
|
+
img: "Images aren't clickable unless they contain a link. Check for a parent link or nearby button.",
|
|
49
|
+
group: "This is a grouping element. Look for interactive elements inside it.",
|
|
50
|
+
region: "This is a region container. Look for interactive elements inside it.",
|
|
51
|
+
list: "Click a specific list item's link or button, not the list container.",
|
|
52
|
+
listitem: "Look for a link or button inside this list item.",
|
|
53
|
+
cell: "Look for a link or button inside this table cell.",
|
|
54
|
+
table: "Click a specific cell, link, or button within the table.",
|
|
55
|
+
navigation: "Look for links or buttons inside this navigation region.",
|
|
56
|
+
};
|
|
57
|
+
const preStates = new Map();
|
|
58
|
+
const histories = new Map();
|
|
59
|
+
// ─── Hashing (djb2) ──────────────────────────────────────────────────────
|
|
60
|
+
function djb2(str) {
|
|
61
|
+
let hash = 5381;
|
|
62
|
+
for (let i = 0; i < str.length; i++) {
|
|
63
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
64
|
+
}
|
|
65
|
+
// Convert to unsigned 32-bit hex for readability
|
|
66
|
+
return (hash >>> 0).toString(16);
|
|
67
|
+
}
|
|
68
|
+
// ─── Blocked detection ────────────────────────────────────────────────────
|
|
69
|
+
function isBlockedPage(snapshotText) {
|
|
70
|
+
const lower = snapshotText.toLowerCase();
|
|
71
|
+
// Count elements in snapshot (lines starting with @e)
|
|
72
|
+
const elementCount = (snapshotText.match(/^[ \t]*@e\d+/gm) ?? []).length;
|
|
73
|
+
// Strong signals always trigger BLOCKED
|
|
74
|
+
const hasStrongSignal = BLOCKED_KEYWORDS_STRONG.some((kw) => lower.includes(kw));
|
|
75
|
+
// Weak signals only trigger on small pages (< threshold elements)
|
|
76
|
+
const hasWeakSignal = BLOCKED_KEYWORDS_WEAK.some((kw) => lower.includes(kw));
|
|
77
|
+
if (hasStrongSignal) {
|
|
78
|
+
// Even with strong signals, a page with many elements is likely real content
|
|
79
|
+
// that happens to mention these words. Require very few elements too.
|
|
80
|
+
if (elementCount > BLOCKED_ELEMENT_THRESHOLD)
|
|
81
|
+
return false;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (hasWeakSignal && elementCount < BLOCKED_ELEMENT_THRESHOLD) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
// ─── Outcome classification ───────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Extract the tag name from a CSS selector target like "p.intro", "div#main", "h1", "span.text".
|
|
92
|
+
* Returns lowercase tag name or undefined if not a simple tag selector.
|
|
93
|
+
*/
|
|
94
|
+
function extractTagName(target) {
|
|
95
|
+
if (!target)
|
|
96
|
+
return undefined;
|
|
97
|
+
// Skip ref-style targets like @e5
|
|
98
|
+
if (target.startsWith("@e"))
|
|
99
|
+
return undefined;
|
|
100
|
+
// Match the leading tag name from a CSS selector
|
|
101
|
+
const match = target.match(/^([a-zA-Z][a-zA-Z0-9]*)/);
|
|
102
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract the ARIA role from the snapshot text for an @eN target.
|
|
106
|
+
* Snapshot lines look like: "@e5 paragraph "Some text content""
|
|
107
|
+
* Returns the role string (e.g. "paragraph") or undefined if not found.
|
|
108
|
+
*/
|
|
109
|
+
function extractRoleFromSnapshot(target, snapshotText) {
|
|
110
|
+
if (!target || !target.startsWith("@e"))
|
|
111
|
+
return undefined;
|
|
112
|
+
// Match the line in the snapshot: "@eN role ..."
|
|
113
|
+
// Account for optional leading whitespace/indentation
|
|
114
|
+
const ref = target.replace("@", ""); // "e5"
|
|
115
|
+
const pattern = new RegExp(`(?:^|\\n)\\s*@${ref}\\s+(\\w[\\w-]*)`, "m");
|
|
116
|
+
const match = snapshotText.match(pattern);
|
|
117
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a helpful SILENT_CLICK message for a non-interactive role.
|
|
121
|
+
*/
|
|
122
|
+
function buildNonInteractiveMessage(role) {
|
|
123
|
+
const suggestion = ROLE_SUGGESTIONS[role] ??
|
|
124
|
+
"Consider using `extract` to read content, or find a link/button nearby.";
|
|
125
|
+
return `Clicked non-interactive element (role: ${role}). ${suggestion}`;
|
|
126
|
+
}
|
|
127
|
+
function classifyOutcome(preState, postUrl, postHash, postSnapshot, error, actionType, target) {
|
|
128
|
+
// Error takes top priority
|
|
129
|
+
if (error) {
|
|
130
|
+
return { outcome: "ERROR", detail: error };
|
|
131
|
+
}
|
|
132
|
+
// No pre-state captured — graceful degradation
|
|
133
|
+
if (!preState) {
|
|
134
|
+
return { outcome: "SUCCESS", detail: "Action completed (no pre-state for comparison)" };
|
|
135
|
+
}
|
|
136
|
+
// URL changed → NAVIGATION
|
|
137
|
+
if (postUrl !== preState.url) {
|
|
138
|
+
return { outcome: "NAVIGATION", detail: `Navigated: ${preState.url} → ${postUrl}` };
|
|
139
|
+
}
|
|
140
|
+
// Check for click on non-interactive CSS selector targets (tag-based detection).
|
|
141
|
+
// These are pre-hash checks because CSS tag selectors are strong signals
|
|
142
|
+
// (the agent explicitly used a tag selector like "p" or "div").
|
|
143
|
+
if (actionType === "click" || actionType === "dblclick") {
|
|
144
|
+
const tagName = extractTagName(target);
|
|
145
|
+
if (tagName && NON_INTERACTIVE_ELEMENTS.has(tagName)) {
|
|
146
|
+
return {
|
|
147
|
+
outcome: "SILENT_CLICK",
|
|
148
|
+
detail: `Warning: Clicked a non-interactive element (<${tagName}>). No action was performed. Did you mean to click a button or link?`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Same snapshot hash → SILENT_CLICK
|
|
153
|
+
// For @eN targets: also parse the role from the snapshot for better messages.
|
|
154
|
+
if (postHash === preState.snapshotHash) {
|
|
155
|
+
if (target && (actionType === "click" || actionType === "dblclick")) {
|
|
156
|
+
const role = target ? extractRoleFromSnapshot(target, postSnapshot) : undefined;
|
|
157
|
+
if (role && NON_INTERACTIVE_ROLES.has(role)) {
|
|
158
|
+
return { outcome: "SILENT_CLICK", detail: buildNonInteractiveMessage(role) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { outcome: "SILENT_CLICK", detail: "No DOM changes detected after action" };
|
|
162
|
+
}
|
|
163
|
+
// Hash changed — check for blocked pages
|
|
164
|
+
if (isBlockedPage(postSnapshot)) {
|
|
165
|
+
return { outcome: "BLOCKED", detail: "Anti-bot or challenge page detected" };
|
|
166
|
+
}
|
|
167
|
+
// Hash changed with normal content
|
|
168
|
+
return { outcome: "SUCCESS", detail: "DOM changed after action" };
|
|
169
|
+
}
|
|
170
|
+
// ─── Loop detection ───────────────────────────────────────────────────────
|
|
171
|
+
function detectLoop(history) {
|
|
172
|
+
if (history.length < LOOP_THRESHOLD)
|
|
173
|
+
return undefined;
|
|
174
|
+
const window = history.slice(-LOOP_WINDOW);
|
|
175
|
+
// 1. Same-element loop: same target clicked 3+ times
|
|
176
|
+
const targetCounts = new Map();
|
|
177
|
+
for (const rec of window) {
|
|
178
|
+
if (rec.target) {
|
|
179
|
+
const key = `${rec.actionType}:${rec.target}`;
|
|
180
|
+
targetCounts.set(key, (targetCounts.get(key) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const [key, count] of targetCounts) {
|
|
184
|
+
if (count >= LOOP_THRESHOLD) {
|
|
185
|
+
const [, target] = key.split(":");
|
|
186
|
+
return {
|
|
187
|
+
type: "same-element",
|
|
188
|
+
message: `You've interacted with ${target} ${count} times in the last ${window.length} actions with no state change.`,
|
|
189
|
+
count,
|
|
190
|
+
suggestion: "Try a different element or scroll to reveal more options.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 2. Same-URL loop: same URL visited 3+ times
|
|
195
|
+
const urlCounts = new Map();
|
|
196
|
+
for (const rec of window) {
|
|
197
|
+
if (rec.outcome === "NAVIGATION") {
|
|
198
|
+
urlCounts.set(rec.url, (urlCounts.get(rec.url) ?? 0) + 1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const [url, count] of urlCounts) {
|
|
202
|
+
if (count >= LOOP_THRESHOLD) {
|
|
203
|
+
return {
|
|
204
|
+
type: "same-url",
|
|
205
|
+
message: `Navigated to ${url} ${count} times.`,
|
|
206
|
+
count,
|
|
207
|
+
suggestion: "You may be stuck in a redirect loop. Try a different approach or URL.",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// 3. Ping-pong: alternating between 2 URLs (A→B→A→B)
|
|
212
|
+
if (window.length >= 4) {
|
|
213
|
+
const urls = window.map((r) => r.url);
|
|
214
|
+
const last4 = urls.slice(-4);
|
|
215
|
+
if (last4[0] === last4[2] &&
|
|
216
|
+
last4[1] === last4[3] &&
|
|
217
|
+
last4[0] !== last4[1]) {
|
|
218
|
+
return {
|
|
219
|
+
type: "ping-pong",
|
|
220
|
+
message: `Alternating between ${last4[0]} and ${last4[1]}.`,
|
|
221
|
+
count: 4,
|
|
222
|
+
suggestion: "Break the cycle — try a completely different page or action.",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Also check ping-pong on targets
|
|
226
|
+
const targets = window.filter((r) => r.target).map((r) => r.target);
|
|
227
|
+
if (targets.length >= 4) {
|
|
228
|
+
const lastTargets = targets.slice(-4);
|
|
229
|
+
if (lastTargets[0] === lastTargets[2] &&
|
|
230
|
+
lastTargets[1] === lastTargets[3] &&
|
|
231
|
+
lastTargets[0] !== lastTargets[1]) {
|
|
232
|
+
return {
|
|
233
|
+
type: "ping-pong",
|
|
234
|
+
message: `Alternating between ${lastTargets[0]} and ${lastTargets[1]}.`,
|
|
235
|
+
count: 4,
|
|
236
|
+
suggestion: "Break the cycle — try a completely different element or approach.",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// 4. Action repetition: same action+target 3+ times with no DOM change
|
|
242
|
+
const noChangeActions = window.filter((r) => r.outcome === "SILENT_CLICK");
|
|
243
|
+
if (noChangeActions.length >= LOOP_THRESHOLD) {
|
|
244
|
+
const repeatCounts = new Map();
|
|
245
|
+
for (const rec of noChangeActions) {
|
|
246
|
+
const key = `${rec.actionType}:${rec.target ?? "none"}`;
|
|
247
|
+
repeatCounts.set(key, (repeatCounts.get(key) ?? 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
for (const [key, count] of repeatCounts) {
|
|
250
|
+
if (count >= LOOP_THRESHOLD) {
|
|
251
|
+
const [actionType, target] = key.split(":");
|
|
252
|
+
return {
|
|
253
|
+
type: "action-repeat",
|
|
254
|
+
message: `${actionType} on ${target} repeated ${count} times with no DOM change.`,
|
|
255
|
+
count,
|
|
256
|
+
suggestion: "This action isn't producing results. Try a different approach.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
// ─── Stuck detection ──────────────────────────────────────────────────────
|
|
264
|
+
function detectStuck(history) {
|
|
265
|
+
if (history.length < STUCK_THRESHOLD)
|
|
266
|
+
return undefined;
|
|
267
|
+
// Count consecutive actions from the end where snapshot hash didn't change
|
|
268
|
+
let consecutiveStatic = 0;
|
|
269
|
+
const last = history[history.length - 1];
|
|
270
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
271
|
+
if (history[i].snapshotHash === last.snapshotHash) {
|
|
272
|
+
consecutiveStatic++;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (consecutiveStatic < STUCK_THRESHOLD)
|
|
279
|
+
return undefined;
|
|
280
|
+
// Build contextual suggestions
|
|
281
|
+
const suggestions = [];
|
|
282
|
+
const recentActions = history.slice(-consecutiveStatic);
|
|
283
|
+
const actionTypes = new Set(recentActions.map((r) => r.actionType));
|
|
284
|
+
if (!actionTypes.has("scroll")) {
|
|
285
|
+
suggestions.push("Try scrolling down — content may be below the fold");
|
|
286
|
+
}
|
|
287
|
+
if (actionTypes.size === 1 && actionTypes.has("click")) {
|
|
288
|
+
suggestions.push("Try a different element or check if the page has iframes");
|
|
289
|
+
}
|
|
290
|
+
const recentUrls = new Set(recentActions.map((r) => r.url));
|
|
291
|
+
if (recentUrls.size === 1 && consecutiveStatic >= 5) {
|
|
292
|
+
suggestions.push("Consider navigating to a different page");
|
|
293
|
+
}
|
|
294
|
+
suggestions.push("The page may need JavaScript to update. Try waiting or using extract with JS evaluation.");
|
|
295
|
+
return {
|
|
296
|
+
stuckActions: consecutiveStatic,
|
|
297
|
+
message: `Page appears static after ${consecutiveStatic} actions`,
|
|
298
|
+
suggestions,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// ─── Format harness output ────────────────────────────────────────────────
|
|
302
|
+
export function formatHarnessOutput(state) {
|
|
303
|
+
const lines = ["--- Harness Intelligence ---"];
|
|
304
|
+
lines.push(`Outcome: ${state.outcome} — ${state.outcomeDetail}`);
|
|
305
|
+
if (state.loopWarning) {
|
|
306
|
+
lines.push(`\u26A0 Loop detected: ${state.loopWarning.message} ${state.loopWarning.suggestion}`);
|
|
307
|
+
}
|
|
308
|
+
if (state.stuckWarning) {
|
|
309
|
+
lines.push(`\u26A0 Stuck: ${state.stuckWarning.message}`);
|
|
310
|
+
for (const s of state.stuckWarning.suggestions) {
|
|
311
|
+
lines.push(` - ${s}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return lines.join("\n");
|
|
315
|
+
}
|
|
316
|
+
// ─── Ring buffer helper ──────────────────────────────────────────────────
|
|
317
|
+
function pushAndCap(sessionId, record) {
|
|
318
|
+
const history = histories.get(sessionId) ?? [];
|
|
319
|
+
history.push(record);
|
|
320
|
+
// Ring buffer: cap at MAX_HISTORY
|
|
321
|
+
if (history.length > MAX_HISTORY) {
|
|
322
|
+
history.splice(0, history.length - MAX_HISTORY);
|
|
323
|
+
// Re-index after splice
|
|
324
|
+
for (let i = 0; i < history.length; i++) {
|
|
325
|
+
history[i].index = i;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
histories.set(sessionId, history);
|
|
329
|
+
return history;
|
|
330
|
+
}
|
|
331
|
+
// ─── Main class ───────────────────────────────────────────────────────────
|
|
332
|
+
export class HarnessIntelligence {
|
|
333
|
+
/** Record pre-action state. Call BEFORE performing an action. */
|
|
334
|
+
static capturePreState(sessionId, url, snapshotText) {
|
|
335
|
+
preStates.set(sessionId, {
|
|
336
|
+
url,
|
|
337
|
+
snapshotHash: djb2(snapshotText),
|
|
338
|
+
});
|
|
339
|
+
logger.debug("harness:pre-state", { sessionId, url });
|
|
340
|
+
}
|
|
341
|
+
/** Analyze post-action state. Call AFTER performing an action. Returns guidance. */
|
|
342
|
+
static analyzePostAction(sessionId, actionType, target, value, url, snapshotText, error) {
|
|
343
|
+
const startTime = Date.now();
|
|
344
|
+
const preState = preStates.get(sessionId);
|
|
345
|
+
const postHash = djb2(snapshotText);
|
|
346
|
+
// 1. Classify outcome
|
|
347
|
+
const { outcome, detail } = classifyOutcome(preState, url, postHash, snapshotText, error, actionType, target);
|
|
348
|
+
// 2. Record in history
|
|
349
|
+
const history = histories.get(sessionId) ?? [];
|
|
350
|
+
const record = {
|
|
351
|
+
index: history.length,
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
actionType,
|
|
354
|
+
target,
|
|
355
|
+
value,
|
|
356
|
+
url,
|
|
357
|
+
snapshotHash: postHash,
|
|
358
|
+
outcome,
|
|
359
|
+
duration: preState ? Date.now() - startTime : 0,
|
|
360
|
+
};
|
|
361
|
+
const updatedHistory = pushAndCap(sessionId, record);
|
|
362
|
+
// Clean up pre-state
|
|
363
|
+
preStates.delete(sessionId);
|
|
364
|
+
// 3. Detect loops
|
|
365
|
+
const loopWarning = detectLoop(updatedHistory);
|
|
366
|
+
// 4. Detect stuck
|
|
367
|
+
const stuckWarning = detectStuck(updatedHistory);
|
|
368
|
+
const state = {
|
|
369
|
+
outcome,
|
|
370
|
+
outcomeDetail: detail,
|
|
371
|
+
loopWarning,
|
|
372
|
+
stuckWarning,
|
|
373
|
+
};
|
|
374
|
+
logger.debug("harness:post-action", {
|
|
375
|
+
sessionId,
|
|
376
|
+
outcome,
|
|
377
|
+
loopDetected: !!loopWarning,
|
|
378
|
+
stuckDetected: !!stuckWarning,
|
|
379
|
+
});
|
|
380
|
+
return state;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Record a tool call (navigate, snapshot, batch_actions, execute, etc.)
|
|
384
|
+
* so that session_memory returns a complete timeline — not just `act` calls.
|
|
385
|
+
*/
|
|
386
|
+
static recordToolCall(sessionId, toolName, params, resultSummary, durationMs) {
|
|
387
|
+
const history = histories.get(sessionId) ?? [];
|
|
388
|
+
const record = {
|
|
389
|
+
index: history.length,
|
|
390
|
+
timestamp: Date.now(),
|
|
391
|
+
actionType: toolName,
|
|
392
|
+
target: undefined,
|
|
393
|
+
value: undefined,
|
|
394
|
+
url: params.url ?? "",
|
|
395
|
+
snapshotHash: "",
|
|
396
|
+
outcome: "SUCCESS",
|
|
397
|
+
duration: durationMs,
|
|
398
|
+
toolCall: {
|
|
399
|
+
toolName,
|
|
400
|
+
params,
|
|
401
|
+
resultSummary,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
pushAndCap(sessionId, record);
|
|
405
|
+
logger.debug("harness:tool-call", { sessionId, toolName, resultSummary });
|
|
406
|
+
}
|
|
407
|
+
/** Get action history for a session */
|
|
408
|
+
static getHistory(sessionId, limit) {
|
|
409
|
+
const history = histories.get(sessionId) ?? [];
|
|
410
|
+
if (limit !== undefined && limit > 0) {
|
|
411
|
+
return history.slice(-limit);
|
|
412
|
+
}
|
|
413
|
+
return [...history];
|
|
414
|
+
}
|
|
415
|
+
/** Clear all state for a session */
|
|
416
|
+
static clearSession(sessionId) {
|
|
417
|
+
preStates.delete(sessionId);
|
|
418
|
+
histories.delete(sessionId);
|
|
419
|
+
logger.debug("harness:clear", { sessionId });
|
|
420
|
+
}
|
|
421
|
+
/** Get loop/stuck status without recording an action (for diagnostics) */
|
|
422
|
+
static diagnose(sessionId) {
|
|
423
|
+
const history = histories.get(sessionId) ?? [];
|
|
424
|
+
return {
|
|
425
|
+
loopWarning: detectLoop(history),
|
|
426
|
+
stuckWarning: detectStuck(history),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// ─── Exported helpers for testing ─────────────────────────────────────────
|
|
431
|
+
export { djb2, extractRoleFromSnapshot };
|
|
432
|
+
export default HarnessIntelligence;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface Fingerprint {
|
|
2
|
+
userAgent: string;
|
|
3
|
+
platform: string;
|
|
4
|
+
viewport: {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
screen: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
devicePixelRatio: number;
|
|
13
|
+
deviceMemory: number;
|
|
14
|
+
hardwareConcurrency: number;
|
|
15
|
+
timezone: string;
|
|
16
|
+
languages: string[];
|
|
17
|
+
webgl: {
|
|
18
|
+
vendor: string;
|
|
19
|
+
renderer: string;
|
|
20
|
+
};
|
|
21
|
+
colorDepth: number;
|
|
22
|
+
maxTouchPoints: number;
|
|
23
|
+
doNotTrack: string | null;
|
|
24
|
+
cookieEnabled: boolean;
|
|
25
|
+
pdfViewerEnabled: boolean;
|
|
26
|
+
}
|
|
27
|
+
export type BrowserFamily = "chrome" | "firefox" | "edge";
|
|
28
|
+
export interface FingerprintOptions {
|
|
29
|
+
browserFamily?: BrowserFamily;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generate a consistent, realistic browser fingerprint profile.
|
|
33
|
+
* The fingerprint is internally coherent:
|
|
34
|
+
* - Platform matches userAgent string
|
|
35
|
+
* - deviceMemory correlates with screen resolution tier
|
|
36
|
+
* - hardwareConcurrency scales with deviceMemory
|
|
37
|
+
* - GPU, timezone, and languages are weighted by real-world distributions
|
|
38
|
+
*
|
|
39
|
+
* @param opts - Configuration options
|
|
40
|
+
* @returns A complete fingerprint profile for Playwright context configuration
|
|
41
|
+
*/
|
|
42
|
+
export declare function generateFingerprint(opts?: FingerprintOptions): Fingerprint;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// ─── Browser Fingerprint Generation ────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Generates internally-coherent browser fingerprint profiles for stealth
|
|
4
|
+
// automation. Platform matches userAgent, deviceMemory correlates with
|
|
5
|
+
// screen tier, GPU and timezone are weighted by real-world distribution.
|
|
6
|
+
// Ported from the validated humanize.js prototype.
|
|
7
|
+
//
|
|
8
|
+
// Integration point: import { generateFingerprint } from "./humanize-fingerprint.js"
|
|
9
|
+
// then pass the returned profile to session_create options or use with
|
|
10
|
+
// stealth.getContextOptions() in src/session-manager.ts.
|
|
11
|
+
//
|
|
12
|
+
// Standalone module — no cross-dependencies on other humanize modules.
|
|
13
|
+
/**
|
|
14
|
+
* Common screen resolutions weighted by real-world usage (StatCounter 2025).
|
|
15
|
+
*/
|
|
16
|
+
const SCREEN_RESOLUTIONS = [
|
|
17
|
+
{ width: 1920, height: 1080, weight: 0.30 },
|
|
18
|
+
{ width: 1366, height: 768, weight: 0.15 },
|
|
19
|
+
{ width: 1536, height: 864, weight: 0.10 },
|
|
20
|
+
{ width: 2560, height: 1440, weight: 0.12 },
|
|
21
|
+
{ width: 1440, height: 900, weight: 0.08 },
|
|
22
|
+
{ width: 1680, height: 1050, weight: 0.05 },
|
|
23
|
+
{ width: 3840, height: 2160, weight: 0.07 },
|
|
24
|
+
{ width: 1280, height: 720, weight: 0.06 },
|
|
25
|
+
{ width: 1600, height: 900, weight: 0.04 },
|
|
26
|
+
{ width: 2560, height: 1600, weight: 0.03 },
|
|
27
|
+
];
|
|
28
|
+
const PLATFORMS = [
|
|
29
|
+
{ name: "Win32", os: "Windows NT 10.0; Win64; x64", weight: 0.60 },
|
|
30
|
+
{ name: "MacIntel", os: "Macintosh; Intel Mac OS X 10_15_7", weight: 0.25 },
|
|
31
|
+
{ name: "Linux x86_64", os: "X11; Linux x86_64", weight: 0.15 },
|
|
32
|
+
];
|
|
33
|
+
const TIMEZONES = [
|
|
34
|
+
{ tz: "America/New_York", weight: 0.20 },
|
|
35
|
+
{ tz: "America/Chicago", weight: 0.12 },
|
|
36
|
+
{ tz: "America/Denver", weight: 0.06 },
|
|
37
|
+
{ tz: "America/Los_Angeles", weight: 0.15 },
|
|
38
|
+
{ tz: "Europe/London", weight: 0.10 },
|
|
39
|
+
{ tz: "Europe/Berlin", weight: 0.08 },
|
|
40
|
+
{ tz: "Europe/Paris", weight: 0.06 },
|
|
41
|
+
{ tz: "Asia/Tokyo", weight: 0.05 },
|
|
42
|
+
{ tz: "Asia/Shanghai", weight: 0.05 },
|
|
43
|
+
{ tz: "Australia/Sydney", weight: 0.04 },
|
|
44
|
+
{ tz: "America/Sao_Paulo", weight: 0.04 },
|
|
45
|
+
{ tz: "Asia/Kolkata", weight: 0.05 },
|
|
46
|
+
];
|
|
47
|
+
const LANGUAGES = [
|
|
48
|
+
{ langs: ["en-US", "en"], weight: 0.55 },
|
|
49
|
+
{ langs: ["en-GB", "en"], weight: 0.10 },
|
|
50
|
+
{ langs: ["de-DE", "de", "en"], weight: 0.08 },
|
|
51
|
+
{ langs: ["fr-FR", "fr", "en"], weight: 0.06 },
|
|
52
|
+
{ langs: ["es-ES", "es", "en"], weight: 0.06 },
|
|
53
|
+
{ langs: ["ja-JP", "ja"], weight: 0.05 },
|
|
54
|
+
{ langs: ["pt-BR", "pt", "en"], weight: 0.05 },
|
|
55
|
+
{ langs: ["zh-CN", "zh"], weight: 0.05 },
|
|
56
|
+
];
|
|
57
|
+
const GPUS = [
|
|
58
|
+
{ vendor: "Google Inc. (NVIDIA)", renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)", weight: 0.12 },
|
|
59
|
+
{ vendor: "Google Inc. (NVIDIA)", renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Direct3D11 vs_5_0 ps_5_0)", weight: 0.10 },
|
|
60
|
+
{ vendor: "Google Inc. (Intel)", renderer: "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)", weight: 0.15 },
|
|
61
|
+
{ vendor: "Google Inc. (Intel)", renderer: "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0)", weight: 0.12 },
|
|
62
|
+
{ vendor: "Google Inc. (AMD)", renderer: "ANGLE (AMD, AMD Radeon RX 6700 XT Direct3D11 vs_5_0 ps_5_0)", weight: 0.08 },
|
|
63
|
+
{ vendor: "Apple", renderer: "Apple M1", weight: 0.15 },
|
|
64
|
+
{ vendor: "Apple", renderer: "Apple M2", weight: 0.10 },
|
|
65
|
+
{ vendor: "Mesa", renderer: "Mesa Intel(R) UHD Graphics 630 (CFL GT2)", weight: 0.08 },
|
|
66
|
+
{ vendor: "Mesa", renderer: "llvmpipe (LLVM 15.0.7, 256 bits)", weight: 0.10 },
|
|
67
|
+
];
|
|
68
|
+
// ─── Weighted Selection ────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Weighted random selection from an array of objects with a `weight` property.
|
|
71
|
+
*/
|
|
72
|
+
function weightedPick(items) {
|
|
73
|
+
const total = items.reduce((s, i) => s + i.weight, 0);
|
|
74
|
+
let r = Math.random() * total;
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
r -= item.weight;
|
|
77
|
+
if (r <= 0)
|
|
78
|
+
return item;
|
|
79
|
+
}
|
|
80
|
+
return items[items.length - 1];
|
|
81
|
+
}
|
|
82
|
+
// ─── Generator ─────────────────────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Generate a consistent, realistic browser fingerprint profile.
|
|
85
|
+
* The fingerprint is internally coherent:
|
|
86
|
+
* - Platform matches userAgent string
|
|
87
|
+
* - deviceMemory correlates with screen resolution tier
|
|
88
|
+
* - hardwareConcurrency scales with deviceMemory
|
|
89
|
+
* - GPU, timezone, and languages are weighted by real-world distributions
|
|
90
|
+
*
|
|
91
|
+
* @param opts - Configuration options
|
|
92
|
+
* @returns A complete fingerprint profile for Playwright context configuration
|
|
93
|
+
*/
|
|
94
|
+
export function generateFingerprint(opts = {}) {
|
|
95
|
+
const family = opts.browserFamily ?? "chrome";
|
|
96
|
+
// Pick screen resolution
|
|
97
|
+
const screen = weightedPick(SCREEN_RESOLUTIONS);
|
|
98
|
+
// Browser version ranges (realistic as of early 2026)
|
|
99
|
+
const versions = {
|
|
100
|
+
chrome: { min: 124, max: 136 },
|
|
101
|
+
firefox: { min: 124, max: 132 },
|
|
102
|
+
edge: { min: 124, max: 136 },
|
|
103
|
+
};
|
|
104
|
+
const vRange = versions[family] ?? versions.chrome;
|
|
105
|
+
const majorVersion = vRange.min + Math.floor(Math.random() * (vRange.max - vRange.min + 1));
|
|
106
|
+
// Platform
|
|
107
|
+
const platform = weightedPick(PLATFORMS);
|
|
108
|
+
// Build userAgent
|
|
109
|
+
let userAgent;
|
|
110
|
+
const chromeBuild = `${majorVersion}.0.${3000 + Math.floor(Math.random() * 3000)}.${Math.floor(Math.random() * 200)}`;
|
|
111
|
+
if (family === "chrome") {
|
|
112
|
+
userAgent = `Mozilla/5.0 (${platform.os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeBuild} Safari/537.36`;
|
|
113
|
+
}
|
|
114
|
+
else if (family === "firefox") {
|
|
115
|
+
userAgent = `Mozilla/5.0 (${platform.os}; rv:${majorVersion}.0) Gecko/20100101 Firefox/${majorVersion}.0`;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
userAgent = `Mozilla/5.0 (${platform.os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeBuild} Safari/537.36 Edg/${chromeBuild}`;
|
|
119
|
+
}
|
|
120
|
+
// Viewport (slightly smaller than screen to simulate browser chrome)
|
|
121
|
+
const chromeHeights = [80, 90, 100, 110];
|
|
122
|
+
const chromeHeight = chromeHeights[Math.floor(Math.random() * chromeHeights.length)];
|
|
123
|
+
const viewport = {
|
|
124
|
+
width: screen.width,
|
|
125
|
+
height: screen.height - chromeHeight,
|
|
126
|
+
};
|
|
127
|
+
// Device memory (correlated with screen resolution tier)
|
|
128
|
+
const isHighEnd = screen.width >= 2560;
|
|
129
|
+
const highEndMemory = [8, 16, 32];
|
|
130
|
+
const normalMemory = [4, 8, 16];
|
|
131
|
+
const deviceMemory = isHighEnd
|
|
132
|
+
? highEndMemory[Math.floor(Math.random() * highEndMemory.length)]
|
|
133
|
+
: normalMemory[Math.floor(Math.random() * normalMemory.length)];
|
|
134
|
+
// Hardware concurrency (correlated similarly)
|
|
135
|
+
const highEndCores = [8, 12, 16];
|
|
136
|
+
const normalCores = [4, 6, 8];
|
|
137
|
+
const hardwareConcurrency = isHighEnd
|
|
138
|
+
? highEndCores[Math.floor(Math.random() * highEndCores.length)]
|
|
139
|
+
: normalCores[Math.floor(Math.random() * normalCores.length)];
|
|
140
|
+
// Timezone, languages, GPU
|
|
141
|
+
const tz = weightedPick(TIMEZONES);
|
|
142
|
+
const lang = weightedPick(LANGUAGES);
|
|
143
|
+
const gpu = weightedPick(GPUS);
|
|
144
|
+
return {
|
|
145
|
+
userAgent,
|
|
146
|
+
platform: platform.name,
|
|
147
|
+
viewport,
|
|
148
|
+
screen: { width: screen.width, height: screen.height },
|
|
149
|
+
devicePixelRatio: screen.width >= 2560 ? 2 : 1,
|
|
150
|
+
deviceMemory,
|
|
151
|
+
hardwareConcurrency,
|
|
152
|
+
timezone: tz.tz,
|
|
153
|
+
languages: lang.langs,
|
|
154
|
+
webgl: { vendor: gpu.vendor, renderer: gpu.renderer },
|
|
155
|
+
colorDepth: 24,
|
|
156
|
+
maxTouchPoints: platform.name === "Win32" ? (Math.random() < 0.3 ? 10 : 0) : 0,
|
|
157
|
+
doNotTrack: Math.random() < 0.15 ? "1" : null,
|
|
158
|
+
cookieEnabled: true,
|
|
159
|
+
pdfViewerEnabled: true,
|
|
160
|
+
};
|
|
161
|
+
}
|