leapfrog-mcp 0.6.1 → 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/README.md +29 -16
- package/dist/index.js +8 -5
- package/dist/session-manager.js +19 -4
- package/dist/tile-manager.d.ts +2 -2
- package/dist/tile-manager.js +3 -27
- package/package.json +1 -1
- package/dist/interaction-tracker.d.ts +0 -44
- package/dist/interaction-tracker.js +0 -148
- package/dist/notify.d.ts +0 -5
- package/dist/notify.js +0 -50
- package/dist/sidecar.d.ts +0 -25
- package/dist/sidecar.js +0 -140
- package/dist/stealth-bandit.d.ts +0 -64
- package/dist/stealth-bandit.js +0 -163
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<p align="center"><strong>Multi-session browser MCP for AI agents.</strong><br/>36 tools. 15 parallel sessions. Stealth. HUD. Self-improvement. Up to 10x fewer tokens.</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<code>npm i leapfrog</code> | Works with Claude Code, Cursor, Windsurf
|
|
9
|
+
<code>npm i leapfrog-mcp</code> | Works with Claude Code, Cursor, Windsurf
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -33,9 +33,9 @@ Savings range from 2-10x depending on page complexity. Content-heavy pages see t
|
|
|
33
33
|
## Quick Start
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
npx leapfrog --doctor # verify everything works
|
|
37
|
-
npx leapfrog --stealth-audit # test all 19 stealth patches
|
|
38
|
-
npx leapfrog --config # print MCP config to paste
|
|
36
|
+
npx leapfrog-mcp --doctor # verify everything works
|
|
37
|
+
npx leapfrog-mcp --stealth-audit # test all 19 stealth patches
|
|
38
|
+
npx leapfrog-mcp --config # print MCP config to paste
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Add to `~/.mcp.json` (Claude Code) or your editor's MCP config:
|
|
@@ -44,12 +44,11 @@ Add to `~/.mcp.json` (Claude Code) or your editor's MCP config:
|
|
|
44
44
|
{
|
|
45
45
|
"leapfrog": {
|
|
46
46
|
"command": "npx",
|
|
47
|
-
"args": ["-y", "leapfrog"],
|
|
47
|
+
"args": ["-y", "leapfrog-mcp"],
|
|
48
48
|
"env": {
|
|
49
49
|
"LEAP_MAX_SESSIONS": "15",
|
|
50
50
|
"LEAP_TILE": "true",
|
|
51
51
|
"LEAP_HUD": "true",
|
|
52
|
-
"LEAP_SOUND": "true",
|
|
53
52
|
"LEAP_AUTO_CONSENT": "true"
|
|
54
53
|
}
|
|
55
54
|
}
|
|
@@ -57,8 +56,9 @@ Add to `~/.mcp.json` (Claude Code) or your editor's MCP config:
|
|
|
57
56
|
```
|
|
58
57
|
|
|
59
58
|
Leapfrog uses `playwright-core` (15MB) instead of `playwright` (1.6GB) and does **not** bundle a browser. Either:
|
|
60
|
-
- Set `LEAP_CHANNEL=chrome` to use your installed Chrome/Chromium
|
|
59
|
+
- Set `LEAP_CHANNEL=chrome` to use your installed Chrome/Chromium (recommended)
|
|
61
60
|
- Or run `npx playwright-core install chromium` to install the bundled Chromium binary
|
|
61
|
+
- Or set `LEAP_CDP_ENDPOINT` to connect to an already-running Chrome instance
|
|
62
62
|
|
|
63
63
|
## Feature Matrix
|
|
64
64
|
|
|
@@ -94,7 +94,14 @@ Leapfrog uses `playwright-core` (15MB) instead of `playwright` (1.6GB) and does
|
|
|
94
94
|
|
|
95
95
|
## Stealth
|
|
96
96
|
|
|
97
|
-
Leapfrog ships 19 anti-detection patches enabled by default (`LEAP_STEALTH=true`).
|
|
97
|
+
Leapfrog ships 19 anti-detection patches enabled by default (`LEAP_STEALTH=true`). Four modes:
|
|
98
|
+
|
|
99
|
+
- **`true`** (default) — all 19 patches active
|
|
100
|
+
- **`passive`** — removes automation signals only (webdriver, HeadlessChrome). Does NOT fake identity (WebGL, fonts, audio). Better for sites where trust matters more than evasion.
|
|
101
|
+
- **`auto`** — per-domain EXP3 bandit selects the optimal stealth configuration based on what's worked before
|
|
102
|
+
- **`false`** — no stealth patches
|
|
103
|
+
|
|
104
|
+
These cover the vectors that fingerprint services like CreepJS and fingerprint-pro actually check:
|
|
98
105
|
|
|
99
106
|
- Client Hints brands (strips HeadlessChrome)
|
|
100
107
|
- `navigator.webdriver` forced to `undefined`
|
|
@@ -231,7 +238,7 @@ Leapfrog learns from every visit. Per-domain knowledge persists at `~/.leapfrog/
|
|
|
231
238
|
| 6 | **Selector healing** | Remembers element fingerprints → selectors, heals broken refs across visits |
|
|
232
239
|
| 7 | **API endpoint caching** | Discovered API endpoints persist across sessions |
|
|
233
240
|
| 8 | **Interaction heat maps** | Tracks which elements agents actually use, suppresses untouched elements _(coming)_ |
|
|
234
|
-
| 9 | **Strategy selection** | Adversarial bandit (EXP3) for stealth config optimization
|
|
241
|
+
| 9 | **Strategy selection** | Adversarial bandit (EXP3) for stealth config optimization. Use `LEAP_STEALTH=auto` to enable. |
|
|
235
242
|
|
|
236
243
|
LRU eviction at 500 domains. Inspect with the `domain_knowledge` tool.
|
|
237
244
|
|
|
@@ -323,23 +330,29 @@ Leapfrog uses pond metaphors to keep things memorable. Your agent is the frog.
|
|
|
323
330
|
| `LEAP_MAX_SESSIONS` | `15` | Max concurrent sessions |
|
|
324
331
|
| `LEAP_IDLE_TIMEOUT` | `1800000` | Session idle timeout in ms (30 min). Set `0` to disable. |
|
|
325
332
|
| `LEAP_HEADLESS` | `true` | Set `false` to watch the browser |
|
|
333
|
+
| `LEAP_HEADED` | `false` | Set `true` to watch the browser (positive alternative to `LEAP_HEADLESS`) |
|
|
326
334
|
| `LEAP_CHANNEL` | _(bundled chromium)_ | Set `chrome` to use your installed Chrome |
|
|
335
|
+
| `LEAP_CDP_ENDPOINT` | _(none)_ | Connect to a running Chrome instance (e.g. `http://localhost:9222`) |
|
|
336
|
+
| `LEAP_EXTENSIONS` | _(none)_ | Comma-separated paths to browser extensions to load |
|
|
327
337
|
| `LEAP_ALLOW_JS` | `true` | Allow JS evaluation in `extract` and `wait_for` |
|
|
328
|
-
| `LEAP_STEALTH` | `true` | Stealth mode
|
|
338
|
+
| `LEAP_STEALTH` | `true` | Stealth mode: `true` \| `passive` \| `auto` \| `false`. See Stealth section. |
|
|
339
|
+
| `LEAP_STEALTH_PROFILES` | `false` | Enable stealth patches on profile (auth'd) sessions |
|
|
340
|
+
| `LEAP_CDP_STEALTH` | `true` | CDP detection evasion (`Runtime.enable` filtering) |
|
|
329
341
|
| `LEAP_HUMANIZE` | `false` | Experimental. Human-like mouse movement, typing cadence, and scroll behavior. |
|
|
330
342
|
| `LEAP_ALLOW_EXECUTE` | `true` | Allow the `execute` tool (sandboxed Playwright scripts) |
|
|
331
343
|
| `LEAP_BLOCK_LOCALHOST` | `false` | Block localhost/127.x.x.x (allowed by default for local dev) |
|
|
332
344
|
| `LEAP_PROFILES_DIR` | `~/.leapfrog/chrome-profiles` | Directory for persistent browser profiles |
|
|
333
345
|
| `LEAP_TILE` | `false` | Tile sessions in a grid (`true` \| `master` \| `false`) |
|
|
334
346
|
| `LEAP_TILE_PADDING` | `8` | Padding between tiled windows (px) |
|
|
347
|
+
| `LEAP_MULTI_TILE` | `false` | Multi-terminal tiling coordination across Leapfrog instances |
|
|
348
|
+
| `LEAP_SCREEN_WIDTH` | _(auto)_ | Explicit screen width for tiling calculations |
|
|
349
|
+
| `LEAP_SCREEN_HEIGHT` | _(auto)_ | Explicit screen height for tiling calculations |
|
|
335
350
|
| `LEAP_HUD` | `false` | Click ripple, zoom-to-target, scroll-to-target on agent actions |
|
|
336
|
-
| `LEAP_SOUND` | `false` | Marimba chime on intervention detection (macOS) |
|
|
337
|
-
| `LEAP_NOTIFY` | `false` | macOS notification center alerts on intervention detection |
|
|
338
351
|
| `LEAP_AUTO_CONSENT` | `true` | Auto-dismiss cookie consent banners (10 frameworks + fallback) |
|
|
339
352
|
| `LEAP_TRACE` | `false` | Per-session Playwright tracing (screenshots + DOM snapshots) |
|
|
340
353
|
| `LEAP_RECORD` | `false` | Session recording (action history export) |
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
354
|
+
| `LEAP_REBROWSER` | `false` | Enable Rebrowser integration |
|
|
355
|
+
| `LEAP_AUTO_WARM` | `false` | Auto-warm profiles by loading key URLs on session create |
|
|
343
356
|
| `LEAP_CAPTCHA_PROVIDER` | _(none)_ | External CAPTCHA solver: `capsolver` \| `2captcha` \| `nopecha` |
|
|
344
357
|
| `LEAP_CAPTCHA_API_KEY` | _(none)_ | API key for the configured CAPTCHA provider |
|
|
345
358
|
| `LEAP_MAX_SESSIONS_PER_CLIENT` | _(none)_ | Per-client session pool limit |
|
|
@@ -348,10 +361,10 @@ Leapfrog uses pond metaphors to keep things memorable. Your agent is the frog.
|
|
|
348
361
|
## Tests
|
|
349
362
|
|
|
350
363
|
```
|
|
351
|
-
|
|
364
|
+
769 passing across 31 suites
|
|
352
365
|
```
|
|
353
366
|
|
|
354
|
-
Session management, snapshot engine, network intelligence, tab management, security, SSRF protection, stealth patches (19), humanization (mouse, typing, scroll), page classification, harness intelligence, API intelligence, script executor, extended actions, HUD overlays, human intervention, cookie consent, domain knowledge,
|
|
367
|
+
Session management, snapshot engine, snapshot differ, network intelligence, tab management, security, SSRF protection, stealth patches (19), stealth enhanced, humanization (mouse, typing, scroll), page classification, harness intelligence, API intelligence, script executor, extended actions, HUD overlays, human intervention, cookie consent, domain knowledge, selector healing, stable elements, tile manager, bug regression, integration smoke, stress tests, benchmarks.
|
|
355
368
|
|
|
356
369
|
```bash
|
|
357
370
|
npm test
|
package/dist/index.js
CHANGED
|
@@ -49,7 +49,9 @@ const MAX_SNAPSHOT_CHARS = 10000;
|
|
|
49
49
|
const ALLOW_JS = process.env.LEAP_ALLOW_JS !== "false";
|
|
50
50
|
const ALLOW_EXECUTE = process.env.LEAP_ALLOW_EXECUTE !== "false";
|
|
51
51
|
const LEAP_PROFILES_DIR = process.env.LEAP_PROFILES_DIR ?? path.join(os.homedir(), ".leapfrog", "chrome-profiles");
|
|
52
|
-
|
|
52
|
+
// WORKAROUND: Claude Code on Windows does not pass mcp.json env vars to child process.
|
|
53
|
+
// Default to "grid" so tiling works out of the box on all platforms.
|
|
54
|
+
const LEAP_TILE = process.env.LEAP_TILE || "grid";
|
|
53
55
|
const LEAP_TILE_PADDING = Number(process.env.LEAP_TILE_PADDING ?? 8);
|
|
54
56
|
const LEAP_SCREEN_WIDTH = Number(process.env.LEAP_SCREEN_WIDTH || 0);
|
|
55
57
|
const LEAP_SCREEN_HEIGHT = Number(process.env.LEAP_SCREEN_HEIGHT || 0);
|
|
@@ -79,9 +81,10 @@ if (LEAP_TILE && LEAP_TILE !== "false") {
|
|
|
79
81
|
// Zero cost for single-terminal (just tracks one instance). No extra env var needed.
|
|
80
82
|
let tilesCoord = null;
|
|
81
83
|
if (LEAP_TILE && LEAP_TILE !== "false") {
|
|
82
|
-
// Screen size: prefer env vars, fall back to 1920x1080.
|
|
83
|
-
const
|
|
84
|
-
const
|
|
84
|
+
// Screen size: prefer env vars, then auto-detected screen, fall back to 1920x1080.
|
|
85
|
+
const detectedScreen = tileManager.getScreenSize();
|
|
86
|
+
const defaultW = LEAP_SCREEN_WIDTH > 0 ? LEAP_SCREEN_WIDTH : detectedScreen?.width ?? 1920;
|
|
87
|
+
const defaultH = LEAP_SCREEN_HEIGHT > 0 ? LEAP_SCREEN_HEIGHT : detectedScreen?.height ?? 1080;
|
|
85
88
|
tilesCoord = new TilesCoordinator(defaultW, defaultH);
|
|
86
89
|
// File watcher only needed for multi-terminal mode (multiple Leapfrog instances).
|
|
87
90
|
// In single-instance mode, the watcher causes spurious reflows that fight
|
|
@@ -3002,7 +3005,7 @@ function printConfig() {
|
|
|
3002
3005
|
const config = {
|
|
3003
3006
|
leapfrog: {
|
|
3004
3007
|
command: "npx",
|
|
3005
|
-
args: ["-y", "leapfrog"],
|
|
3008
|
+
args: ["-y", "leapfrog-mcp"],
|
|
3006
3009
|
env: {
|
|
3007
3010
|
LEAP_MAX_SESSIONS: "15",
|
|
3008
3011
|
},
|
package/dist/session-manager.js
CHANGED
|
@@ -288,10 +288,9 @@ export class SessionManager {
|
|
|
288
288
|
if (stealth.isEnabled()) {
|
|
289
289
|
launchArgs.push(...stealth.getLaunchArgs());
|
|
290
290
|
}
|
|
291
|
-
// Windows DPI:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
291
|
+
// Windows DPI: --force-device-scale-factor=1 REMOVED
|
|
292
|
+
// On high-DPI Windows (250%), it renders content at 1x = unreadable tiny text.
|
|
293
|
+
// Let Chrome auto-detect DPI; CDP handles window positioning correctly without it.
|
|
295
294
|
// ── Window position args ───────────────────────────────────────
|
|
296
295
|
// When tiling: grid position. Otherwise: just place on the detected screen.
|
|
297
296
|
const screen = tileManager.getScreenSize();
|
|
@@ -578,6 +577,22 @@ export class SessionManager {
|
|
|
578
577
|
logger.warn("tile.position_failed", { error: e.message });
|
|
579
578
|
}
|
|
580
579
|
}
|
|
580
|
+
// ── Debounced reflow after creation (Windows only) ─────────────
|
|
581
|
+
// On Windows, launch args position each window for a grid that includes
|
|
582
|
+
// only the sessions created so far. Earlier windows end up at stale positions.
|
|
583
|
+
// Reflow all after a 500ms debounce so rapid batch creates settle into one reflow.
|
|
584
|
+
// Skipped on macOS where CDP reflow can move windows to the wrong screen.
|
|
585
|
+
if (process.platform === "win32" && tileManager.isEnabled() && this.sessions.size > 1) {
|
|
586
|
+
clearTimeout(globalThis.__leapReflowTimer);
|
|
587
|
+
globalThis.__leapReflowTimer = setTimeout(async () => {
|
|
588
|
+
try {
|
|
589
|
+
await tileManager.reflowAll(this.sessions);
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
logger.warn("tile.reflow_after_create_failed", { error: e.message });
|
|
593
|
+
}
|
|
594
|
+
}, 500);
|
|
595
|
+
}
|
|
581
596
|
// ── Auto-reflow on external close ──────────────────────────────
|
|
582
597
|
// When the user manually closes a browser window (X button), clean up
|
|
583
598
|
// the session and reflow remaining tiled windows. Without this, closing
|
package/dist/tile-manager.d.ts
CHANGED
|
@@ -48,8 +48,8 @@ declare class TileManager {
|
|
|
48
48
|
*/
|
|
49
49
|
static detectTerminalScreen(): ScreenWorkArea | null;
|
|
50
50
|
/**
|
|
51
|
-
* Windows: detect
|
|
52
|
-
* Uses
|
|
51
|
+
* Windows: detect primary screen working area via PowerShell.
|
|
52
|
+
* Uses System.Windows.Forms.Screen — no DllImport, no escaping issues.
|
|
53
53
|
*/
|
|
54
54
|
static detectScreenViaPowershell(): ScreenWorkArea | null;
|
|
55
55
|
detectScreen(page: Page): Promise<ScreenWorkArea>;
|
package/dist/tile-manager.js
CHANGED
|
@@ -76,36 +76,12 @@ class TileManager {
|
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* Windows: detect
|
|
80
|
-
* Uses
|
|
79
|
+
* Windows: detect primary screen working area via PowerShell.
|
|
80
|
+
* Uses System.Windows.Forms.Screen — no DllImport, no escaping issues.
|
|
81
81
|
*/
|
|
82
82
|
static detectScreenViaPowershell() {
|
|
83
83
|
try {
|
|
84
|
-
|
|
85
|
-
// 1. Get foreground window handle via user32.dll
|
|
86
|
-
// 2. Get window RECT via user32.dll
|
|
87
|
-
// 3. Find which Screen contains that RECT
|
|
88
|
-
// 4. Output WorkingArea (excludes taskbar) as "x y width height"
|
|
89
|
-
const script = `powershell -NoProfile -NonInteractive -Command "
|
|
90
|
-
Add-Type -AssemblyName System.Windows.Forms
|
|
91
|
-
Add-Type -TypeDefinition '
|
|
92
|
-
using System;
|
|
93
|
-
using System.Runtime.InteropServices;
|
|
94
|
-
public class WinAPI {
|
|
95
|
-
[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();
|
|
96
|
-
[DllImport(\\\"user32.dll\\\")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
|
97
|
-
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
98
|
-
}
|
|
99
|
-
'
|
|
100
|
-
$hwnd = [WinAPI]::GetForegroundWindow()
|
|
101
|
-
$rect = New-Object WinAPI+RECT
|
|
102
|
-
[void][WinAPI]::GetWindowRect($hwnd, [ref]$rect)
|
|
103
|
-
$pt = New-Object System.Drawing.Point($rect.Left, $rect.Top)
|
|
104
|
-
$scr = [System.Windows.Forms.Screen]::FromPoint($pt)
|
|
105
|
-
$wa = $scr.WorkingArea
|
|
106
|
-
Write-Output ('{0} {1} {2} {3}' -f $wa.X, $wa.Y, $wa.Width, $wa.Height)
|
|
107
|
-
"`;
|
|
108
|
-
const result = execSync(script, { timeout: 10000, encoding: "utf-8" }).trim();
|
|
84
|
+
const result = execSync('powershell -NoProfile -NonInteractive -Command "Add-Type -AssemblyName System.Windows.Forms; $wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea; Write-Output ($wa.X.ToString() + \' \' + $wa.Y.ToString() + \' \' + $wa.Width.ToString() + \' \' + $wa.Height.ToString())"', { timeout: 10000, encoding: "utf-8" }).trim();
|
|
109
85
|
const parts = result.split(/\s+/).map(Number);
|
|
110
86
|
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
|
111
87
|
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leapfrog-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Multi-session browser MCP for AI agents — 36 tools, stealth, persistent auth, code-first scripts, API sniffer, agent intelligence",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export interface InteractionRecord {
|
|
2
|
-
fingerprint: string;
|
|
3
|
-
clicks: number;
|
|
4
|
-
fills: number;
|
|
5
|
-
extracts: number;
|
|
6
|
-
lastUsed: number;
|
|
7
|
-
}
|
|
8
|
-
type InteractionType = 'click' | 'fill' | 'extract';
|
|
9
|
-
export declare class InteractionTracker {
|
|
10
|
-
private cache;
|
|
11
|
-
/**
|
|
12
|
-
* Record an agent interaction with an element on a domain.
|
|
13
|
-
* Upserts the record, incrementing the relevant counter.
|
|
14
|
-
*/
|
|
15
|
-
recordInteraction(domain: string, fingerprint: string, type: InteractionType): void;
|
|
16
|
-
/**
|
|
17
|
-
* Compute relevance scores (0.0–1.0) for all tracked fingerprints on a domain.
|
|
18
|
-
* Only activates after MIN_VISIT_THRESHOLD visits (safety threshold).
|
|
19
|
-
*
|
|
20
|
-
* Score = (totalInteractions / visitCount) * recencyBoost
|
|
21
|
-
* Capped at 1.0.
|
|
22
|
-
*/
|
|
23
|
-
getRelevanceScores(domain: string, visitCount: number): Map<string, number>;
|
|
24
|
-
/**
|
|
25
|
-
* Get fingerprints eligible for suppression — elements never interacted with
|
|
26
|
-
* after enough visits. Excludes form input roles which must never be suppressed.
|
|
27
|
-
*
|
|
28
|
-
* NOTE: This returns fingerprints with relevance 0.0 (no interaction records).
|
|
29
|
-
* The caller must combine this with the full set of known fingerprints from
|
|
30
|
-
* the snapshot to identify which zero-interaction elements to suppress.
|
|
31
|
-
* Elements that appear in getRelevanceScores have score > 0 and should NOT
|
|
32
|
-
* be suppressed. This method returns the set of tracked fingerprints whose
|
|
33
|
-
* total interactions are zero — which in practice means fingerprints that
|
|
34
|
-
* were recorded via other means but never acted upon.
|
|
35
|
-
*/
|
|
36
|
-
getSuppressSet(domain: string, visitCount: number): Set<string>;
|
|
37
|
-
/** Serialize interaction records for a domain (for persistence in DomainRecord). */
|
|
38
|
-
toJSON(domain: string): InteractionRecord[];
|
|
39
|
-
/** Restore interaction records for a domain from persisted data. */
|
|
40
|
-
fromJSON(domain: string, data: InteractionRecord[]): void;
|
|
41
|
-
private computeRecencyBoost;
|
|
42
|
-
}
|
|
43
|
-
export declare const interactionTracker: InteractionTracker;
|
|
44
|
-
export {};
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
// ─── Interaction Heat Maps ────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Tracks which elements agents actually interact with (click, fill, extract)
|
|
4
|
-
// on each domain. After enough visits, elements that were never touched can
|
|
5
|
-
// be suppressed from snapshots for additional token savings on top of the
|
|
6
|
-
// existing stable-element suppression.
|
|
7
|
-
//
|
|
8
|
-
// Storage: piggybacks on DomainRecord persistence via toJSON/fromJSON.
|
|
9
|
-
import { logger } from './logger.js';
|
|
10
|
-
import { normalizeDomain } from './domain-knowledge.js';
|
|
11
|
-
// ─── Constants ────────────────────────────────────────────────────────────
|
|
12
|
-
/** Maximum interaction records per domain (LRU by lastUsed). */
|
|
13
|
-
const MAX_RECORDS_PER_DOMAIN = 200;
|
|
14
|
-
/** Minimum visits before relevance scoring activates. */
|
|
15
|
-
const MIN_VISIT_THRESHOLD = 10;
|
|
16
|
-
/** Recency boost boundaries in milliseconds. */
|
|
17
|
-
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
18
|
-
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
19
|
-
/** Form input roles that must NEVER be suppressed. */
|
|
20
|
-
const FORM_INPUT_ROLES = new Set([
|
|
21
|
-
'textbox',
|
|
22
|
-
'checkbox',
|
|
23
|
-
'radio',
|
|
24
|
-
'combobox',
|
|
25
|
-
'searchbox',
|
|
26
|
-
'spinbutton',
|
|
27
|
-
'slider',
|
|
28
|
-
'switch',
|
|
29
|
-
'listbox',
|
|
30
|
-
]);
|
|
31
|
-
// ─── Class ────────────────────────────────────────────────────────────────
|
|
32
|
-
export class InteractionTracker {
|
|
33
|
-
cache = new Map();
|
|
34
|
-
// ── Recording ─────────────────────────────────────────────────────────
|
|
35
|
-
/**
|
|
36
|
-
* Record an agent interaction with an element on a domain.
|
|
37
|
-
* Upserts the record, incrementing the relevant counter.
|
|
38
|
-
*/
|
|
39
|
-
recordInteraction(domain, fingerprint, type) {
|
|
40
|
-
const key = normalizeDomain(domain);
|
|
41
|
-
let records = this.cache.get(key);
|
|
42
|
-
if (!records) {
|
|
43
|
-
records = [];
|
|
44
|
-
this.cache.set(key, records);
|
|
45
|
-
}
|
|
46
|
-
const now = Date.now();
|
|
47
|
-
const existing = records.find(r => r.fingerprint === fingerprint);
|
|
48
|
-
if (existing) {
|
|
49
|
-
existing[type === 'click' ? 'clicks' : type === 'fill' ? 'fills' : 'extracts']++;
|
|
50
|
-
existing.lastUsed = now;
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
const record = {
|
|
54
|
-
fingerprint,
|
|
55
|
-
clicks: type === 'click' ? 1 : 0,
|
|
56
|
-
fills: type === 'fill' ? 1 : 0,
|
|
57
|
-
extracts: type === 'extract' ? 1 : 0,
|
|
58
|
-
lastUsed: now,
|
|
59
|
-
};
|
|
60
|
-
records.push(record);
|
|
61
|
-
}
|
|
62
|
-
// LRU cap: evict least-recently-used if over limit
|
|
63
|
-
if (records.length > MAX_RECORDS_PER_DOMAIN) {
|
|
64
|
-
records.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
65
|
-
records.length = MAX_RECORDS_PER_DOMAIN;
|
|
66
|
-
}
|
|
67
|
-
logger.debug('interaction-tracker:recorded', { domain: key, fingerprint, type });
|
|
68
|
-
}
|
|
69
|
-
// ── Scoring ───────────────────────────────────────────────────────────
|
|
70
|
-
/**
|
|
71
|
-
* Compute relevance scores (0.0–1.0) for all tracked fingerprints on a domain.
|
|
72
|
-
* Only activates after MIN_VISIT_THRESHOLD visits (safety threshold).
|
|
73
|
-
*
|
|
74
|
-
* Score = (totalInteractions / visitCount) * recencyBoost
|
|
75
|
-
* Capped at 1.0.
|
|
76
|
-
*/
|
|
77
|
-
getRelevanceScores(domain, visitCount) {
|
|
78
|
-
const result = new Map();
|
|
79
|
-
if (visitCount < MIN_VISIT_THRESHOLD)
|
|
80
|
-
return result;
|
|
81
|
-
const key = normalizeDomain(domain);
|
|
82
|
-
const records = this.cache.get(key);
|
|
83
|
-
if (!records)
|
|
84
|
-
return result;
|
|
85
|
-
const now = Date.now();
|
|
86
|
-
for (const rec of records) {
|
|
87
|
-
const total = rec.clicks + rec.fills + rec.extracts;
|
|
88
|
-
const recencyBoost = this.computeRecencyBoost(now, rec.lastUsed);
|
|
89
|
-
const score = Math.min((total / visitCount) * recencyBoost, 1.0);
|
|
90
|
-
result.set(rec.fingerprint, score);
|
|
91
|
-
}
|
|
92
|
-
return result;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Get fingerprints eligible for suppression — elements never interacted with
|
|
96
|
-
* after enough visits. Excludes form input roles which must never be suppressed.
|
|
97
|
-
*
|
|
98
|
-
* NOTE: This returns fingerprints with relevance 0.0 (no interaction records).
|
|
99
|
-
* The caller must combine this with the full set of known fingerprints from
|
|
100
|
-
* the snapshot to identify which zero-interaction elements to suppress.
|
|
101
|
-
* Elements that appear in getRelevanceScores have score > 0 and should NOT
|
|
102
|
-
* be suppressed. This method returns the set of tracked fingerprints whose
|
|
103
|
-
* total interactions are zero — which in practice means fingerprints that
|
|
104
|
-
* were recorded via other means but never acted upon.
|
|
105
|
-
*/
|
|
106
|
-
getSuppressSet(domain, visitCount) {
|
|
107
|
-
const suppressSet = new Set();
|
|
108
|
-
if (visitCount < MIN_VISIT_THRESHOLD)
|
|
109
|
-
return suppressSet;
|
|
110
|
-
const key = normalizeDomain(domain);
|
|
111
|
-
const records = this.cache.get(key);
|
|
112
|
-
if (!records)
|
|
113
|
-
return suppressSet;
|
|
114
|
-
for (const rec of records) {
|
|
115
|
-
const total = rec.clicks + rec.fills + rec.extracts;
|
|
116
|
-
if (total > 0)
|
|
117
|
-
continue;
|
|
118
|
-
// Never suppress form input roles
|
|
119
|
-
const role = rec.fingerprint.split(':')[0];
|
|
120
|
-
if (FORM_INPUT_ROLES.has(role))
|
|
121
|
-
continue;
|
|
122
|
-
suppressSet.add(rec.fingerprint);
|
|
123
|
-
}
|
|
124
|
-
return suppressSet;
|
|
125
|
-
}
|
|
126
|
-
// ── Serialization ─────────────────────────────────────────────────────
|
|
127
|
-
/** Serialize interaction records for a domain (for persistence in DomainRecord). */
|
|
128
|
-
toJSON(domain) {
|
|
129
|
-
const key = normalizeDomain(domain);
|
|
130
|
-
return this.cache.get(key) ?? [];
|
|
131
|
-
}
|
|
132
|
-
/** Restore interaction records for a domain from persisted data. */
|
|
133
|
-
fromJSON(domain, data) {
|
|
134
|
-
const key = normalizeDomain(domain);
|
|
135
|
-
this.cache.set(key, data);
|
|
136
|
-
}
|
|
137
|
-
// ── Internal ──────────────────────────────────────────────────────────
|
|
138
|
-
computeRecencyBoost(now, lastUsed) {
|
|
139
|
-
const age = now - lastUsed;
|
|
140
|
-
if (age <= SEVEN_DAYS_MS)
|
|
141
|
-
return 1.0;
|
|
142
|
-
if (age <= THIRTY_DAYS_MS)
|
|
143
|
-
return 0.7;
|
|
144
|
-
return 0.4;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
// ─── Singleton ────────────────────────────────────────────────────────────
|
|
148
|
-
export const interactionTracker = new InteractionTracker();
|
package/dist/notify.d.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export declare function isSoundEnabled(): boolean;
|
|
2
|
-
export declare function isNotifyEnabled(): boolean;
|
|
3
|
-
export declare function playSound(soundPath: string, volume?: number): void;
|
|
4
|
-
export declare function chime(volume?: number): void;
|
|
5
|
-
export declare function alert(title: string, message: string): void;
|
package/dist/notify.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// ─── Sound & Notification Module ──────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Fire-and-forget macOS notifications and sound effects.
|
|
4
|
-
// Silent no-op on non-macOS. Zero npm dependencies.
|
|
5
|
-
//
|
|
6
|
-
// Env: LEAP_SOUND=true, LEAP_SOUND_VOLUME=0.5, LEAP_NOTIFY=true
|
|
7
|
-
import { execFile } from "child_process";
|
|
8
|
-
import * as path from "path";
|
|
9
|
-
import { fileURLToPath } from "url";
|
|
10
|
-
import { logger } from "./logger.js";
|
|
11
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const CHIME_PATH = path.join(__dirname, "..", "assets", "chime.mp3");
|
|
13
|
-
const IS_MAC = process.platform === "darwin";
|
|
14
|
-
export function isSoundEnabled() {
|
|
15
|
-
return IS_MAC && process.env.LEAP_SOUND === "true";
|
|
16
|
-
}
|
|
17
|
-
export function isNotifyEnabled() {
|
|
18
|
-
return IS_MAC && process.env.LEAP_NOTIFY === "true";
|
|
19
|
-
}
|
|
20
|
-
export function playSound(soundPath, volume) {
|
|
21
|
-
if (!isSoundEnabled())
|
|
22
|
-
return;
|
|
23
|
-
const vol = volume ?? parseFloat(process.env.LEAP_SOUND_VOLUME || "0.5");
|
|
24
|
-
logger.debug("notify.playSound", { soundPath, volume: vol });
|
|
25
|
-
execFile("afplay", ["-v", String(vol), soundPath], (err) => {
|
|
26
|
-
if (err)
|
|
27
|
-
logger.debug("notify.playSound.error", { error: err.message });
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
export function chime(volume) {
|
|
31
|
-
if (!isSoundEnabled())
|
|
32
|
-
return;
|
|
33
|
-
const vol = volume ?? parseFloat(process.env.LEAP_SOUND_VOLUME || "0.5");
|
|
34
|
-
logger.debug("notify.chime", { volume: vol });
|
|
35
|
-
execFile("afplay", ["-v", String(vol), CHIME_PATH], (err) => {
|
|
36
|
-
if (err)
|
|
37
|
-
logger.debug("notify.chime.error", { error: err.message });
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
export function alert(title, message) {
|
|
41
|
-
if (!isNotifyEnabled())
|
|
42
|
-
return;
|
|
43
|
-
logger.debug("notify.alert", { title });
|
|
44
|
-
const t = title.replace(/"/g, '\\"');
|
|
45
|
-
const m = message.replace(/"/g, '\\"');
|
|
46
|
-
execFile("osascript", ["-e", `display notification "${m}" with title "${t}"`], (err) => {
|
|
47
|
-
if (err)
|
|
48
|
-
logger.debug("notify.alert.error", { error: err.message });
|
|
49
|
-
});
|
|
50
|
-
}
|
package/dist/sidecar.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export interface SidecarDeps {
|
|
2
|
-
listSessions: () => Array<{
|
|
3
|
-
id: string;
|
|
4
|
-
name?: string;
|
|
5
|
-
url: string;
|
|
6
|
-
}>;
|
|
7
|
-
focusSession: (id: string) => Promise<void>;
|
|
8
|
-
zoomSession: (id: string) => Promise<void>;
|
|
9
|
-
restoreGrid: () => Promise<void>;
|
|
10
|
-
setLayout: (layout: string) => Promise<void>;
|
|
11
|
-
destroyAll: () => Promise<void>;
|
|
12
|
-
screenshot: (id: string) => Promise<Buffer>;
|
|
13
|
-
}
|
|
14
|
-
export declare class SidecarServer {
|
|
15
|
-
private server;
|
|
16
|
-
private deps;
|
|
17
|
-
constructor(deps: SidecarDeps);
|
|
18
|
-
start(port?: number): Promise<void>;
|
|
19
|
-
stop(): Promise<void>;
|
|
20
|
-
private route;
|
|
21
|
-
/** Run a handler that requires a session ID, with 404 guard. */
|
|
22
|
-
private withSession;
|
|
23
|
-
/** Send a JSON response. */
|
|
24
|
-
private json;
|
|
25
|
-
}
|
package/dist/sidecar.js
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// ─── Sidecar HTTP Control Server ──────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Zero-dependency localhost HTTP server for headed-mode session control.
|
|
4
|
-
// Exposes REST-ish endpoints to list, focus, zoom, tile, and screenshot
|
|
5
|
-
// browser sessions. Started by index.ts when LEAP_TILE is set.
|
|
6
|
-
//
|
|
7
|
-
// All responses are JSON except /screenshot/:id which returns image/png.
|
|
8
|
-
// CORS is wide-open (localhost only, safe).
|
|
9
|
-
import * as http from "node:http";
|
|
10
|
-
import { logger } from "./logger.js";
|
|
11
|
-
// ─── Server ───────────────────────────────────────────────────────────────
|
|
12
|
-
export class SidecarServer {
|
|
13
|
-
server = null;
|
|
14
|
-
deps;
|
|
15
|
-
constructor(deps) {
|
|
16
|
-
this.deps = deps;
|
|
17
|
-
}
|
|
18
|
-
// ── lifecycle ───────────────────────────────────────────────────────────
|
|
19
|
-
start(port = 9222) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const server = http.createServer((req, res) => {
|
|
22
|
-
this.route(req, res).catch(() => {
|
|
23
|
-
/* already handled inside route() */
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
server.once("error", reject);
|
|
27
|
-
server.listen(port, "127.0.0.1", () => {
|
|
28
|
-
server.removeListener("error", reject);
|
|
29
|
-
this.server = server;
|
|
30
|
-
logger.info("sidecar.start", { port });
|
|
31
|
-
resolve();
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
stop() {
|
|
36
|
-
return new Promise((resolve) => {
|
|
37
|
-
if (!this.server) {
|
|
38
|
-
resolve();
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
this.server.close(() => {
|
|
42
|
-
this.server = null;
|
|
43
|
-
logger.info("sidecar.stop");
|
|
44
|
-
resolve();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
// ── routing ─────────────────────────────────────────────────────────────
|
|
49
|
-
async route(req, res) {
|
|
50
|
-
const parsed = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
51
|
-
const segments = parsed.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
|
|
52
|
-
// segments: e.g. ["sessions"], ["focus","abc"], ["screenshot","abc"]
|
|
53
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
54
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
55
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
56
|
-
if (req.method === "OPTIONS") {
|
|
57
|
-
res.writeHead(204);
|
|
58
|
-
res.end();
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const route = segments[0] ?? "";
|
|
62
|
-
const param = segments[1];
|
|
63
|
-
try {
|
|
64
|
-
switch (route) {
|
|
65
|
-
case "health":
|
|
66
|
-
return this.json(res, 200, { ok: true, data: { status: "running" } });
|
|
67
|
-
case "sessions":
|
|
68
|
-
return this.json(res, 200, { ok: true, data: this.deps.listSessions() });
|
|
69
|
-
case "focus":
|
|
70
|
-
return await this.withSession(param, res, async (id) => {
|
|
71
|
-
await this.deps.focusSession(id);
|
|
72
|
-
return { focused: id };
|
|
73
|
-
});
|
|
74
|
-
case "zoom":
|
|
75
|
-
return await this.withSession(param, res, async (id) => {
|
|
76
|
-
await this.deps.zoomSession(id);
|
|
77
|
-
return { zoomed: id };
|
|
78
|
-
});
|
|
79
|
-
case "grid":
|
|
80
|
-
await this.deps.restoreGrid();
|
|
81
|
-
return this.json(res, 200, { ok: true, data: { layout: "grid" } });
|
|
82
|
-
case "layout": {
|
|
83
|
-
const layoutType = param ?? "grid";
|
|
84
|
-
await this.deps.setLayout(layoutType);
|
|
85
|
-
return this.json(res, 200, { ok: true, data: { layout: layoutType } });
|
|
86
|
-
}
|
|
87
|
-
case "stop":
|
|
88
|
-
await this.deps.destroyAll();
|
|
89
|
-
return this.json(res, 200, { ok: true, data: { destroyed: true } });
|
|
90
|
-
case "screenshot":
|
|
91
|
-
return await this.withSession(param, res, async (id) => {
|
|
92
|
-
const buf = await this.deps.screenshot(id);
|
|
93
|
-
res.writeHead(200, {
|
|
94
|
-
"Content-Type": "image/png",
|
|
95
|
-
"Content-Length": buf.length,
|
|
96
|
-
"Access-Control-Allow-Origin": "*",
|
|
97
|
-
});
|
|
98
|
-
res.end(buf);
|
|
99
|
-
return null; // signal: already sent
|
|
100
|
-
});
|
|
101
|
-
default:
|
|
102
|
-
return this.json(res, 404, { ok: false, error: `Unknown route: /${segments.join("/")}` });
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
catch (err) {
|
|
106
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
-
logger.error("sidecar.handler", { route, param, error: message });
|
|
108
|
-
return this.json(res, 500, { ok: false, error: message });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// ── helpers ─────────────────────────────────────────────────────────────
|
|
112
|
-
/** Run a handler that requires a session ID, with 404 guard. */
|
|
113
|
-
async withSession(id, res, handler) {
|
|
114
|
-
if (!id) {
|
|
115
|
-
return this.json(res, 400, { ok: false, error: "Missing session ID in URL" });
|
|
116
|
-
}
|
|
117
|
-
const sessions = this.deps.listSessions();
|
|
118
|
-
if (!sessions.some((s) => s.id === id)) {
|
|
119
|
-
return this.json(res, 404, {
|
|
120
|
-
ok: false,
|
|
121
|
-
error: `Session not found: ${id}`,
|
|
122
|
-
available: sessions.map((s) => s.id),
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
const data = await handler(id);
|
|
126
|
-
if (data !== null) {
|
|
127
|
-
this.json(res, 200, { ok: true, data });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
/** Send a JSON response. */
|
|
131
|
-
json(res, status, body) {
|
|
132
|
-
const payload = JSON.stringify(body);
|
|
133
|
-
res.writeHead(status, {
|
|
134
|
-
"Content-Type": "application/json",
|
|
135
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
136
|
-
"Access-Control-Allow-Origin": "*",
|
|
137
|
-
});
|
|
138
|
-
res.end(payload);
|
|
139
|
-
}
|
|
140
|
-
}
|
package/dist/stealth-bandit.d.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { StealthModeType } from './stealth.js';
|
|
2
|
-
declare const STRATEGIES: readonly ["baseline", "tier1-cookies", "tier2-fingerprint", "tier3-full-stealth"];
|
|
3
|
-
export type StealthStrategy = (typeof STRATEGIES)[number];
|
|
4
|
-
export declare function armToStealthMode(armIndex: number): StealthModeType;
|
|
5
|
-
/**
|
|
6
|
-
* Whether the given arm index implies extra behavioral measures
|
|
7
|
-
* beyond the stealth mode (rate limiting, human-like delays).
|
|
8
|
-
* Only tier3-full-stealth (arm 3) triggers these extras.
|
|
9
|
-
*/
|
|
10
|
-
export declare function armRequiresExtraMeasures(armIndex: number): boolean;
|
|
11
|
-
export declare class StealthBandit {
|
|
12
|
-
private weights;
|
|
13
|
-
private gamma;
|
|
14
|
-
private numArms;
|
|
15
|
-
constructor(numArms: number, gamma?: number);
|
|
16
|
-
/** Probability distribution over arms (EXP3 mixture). */
|
|
17
|
-
getDistribution(): number[];
|
|
18
|
-
/** Sample an arm from the distribution. */
|
|
19
|
-
selectArm(): number;
|
|
20
|
-
/** Importance-weighted exponential update after observing reward. */
|
|
21
|
-
update(arm: number, reward: number): void;
|
|
22
|
-
/** Serialize for persistence in domain records. */
|
|
23
|
-
toJSON(): {
|
|
24
|
-
weights: number[];
|
|
25
|
-
gamma: number;
|
|
26
|
-
numArms: number;
|
|
27
|
-
};
|
|
28
|
-
/** Restore from persisted state. */
|
|
29
|
-
static fromJSON(data: {
|
|
30
|
-
weights: number[];
|
|
31
|
-
gamma: number;
|
|
32
|
-
numArms?: number;
|
|
33
|
-
}): StealthBandit;
|
|
34
|
-
}
|
|
35
|
-
export declare class StrategyManager {
|
|
36
|
-
private bandits;
|
|
37
|
-
private strategies;
|
|
38
|
-
constructor(strategies?: readonly string[]);
|
|
39
|
-
/** Normalize domain: strip www., lowercase. */
|
|
40
|
-
private normalizeDomain;
|
|
41
|
-
/** Get or create the bandit for a domain. */
|
|
42
|
-
private getBandit;
|
|
43
|
-
/** Select a stealth strategy for the given domain. */
|
|
44
|
-
selectStrategy(domain: string): {
|
|
45
|
-
strategy: string;
|
|
46
|
-
armIndex: number;
|
|
47
|
-
};
|
|
48
|
-
/** Record success/failure for a strategy on a domain. */
|
|
49
|
-
recordOutcome(domain: string, armIndex: number, success: boolean): void;
|
|
50
|
-
/** Debugging stats for a domain's bandit. */
|
|
51
|
-
getStats(domain: string): {
|
|
52
|
-
distribution: number[];
|
|
53
|
-
strategies: readonly string[];
|
|
54
|
-
};
|
|
55
|
-
/** Serialize a domain's bandit for persistence. */
|
|
56
|
-
toJSON(domain: string): ReturnType<StealthBandit['toJSON']> | null;
|
|
57
|
-
/** Restore a domain's bandit from persisted data. */
|
|
58
|
-
fromJSON(domain: string, data: {
|
|
59
|
-
weights: number[];
|
|
60
|
-
gamma: number;
|
|
61
|
-
}): void;
|
|
62
|
-
}
|
|
63
|
-
export declare const strategyManager: StrategyManager;
|
|
64
|
-
export {};
|
package/dist/stealth-bandit.js
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// ─── EXP3 Adversarial Bandit for Stealth Strategy Selection ──────────────
|
|
2
|
-
//
|
|
3
|
-
// Bot detection is adversarial — the detector adapts, so static strategies
|
|
4
|
-
// decay. EXP3 provides worst-case regret guarantees regardless of what the
|
|
5
|
-
// detector does. One bandit per domain, because different sites use different
|
|
6
|
-
// detection stacks.
|
|
7
|
-
//
|
|
8
|
-
// Arms map to stealth tiers in DomainRecord.stealthTier:
|
|
9
|
-
// 0 = baseline, 1 = cookies+UA, 2 = fingerprint spoofing, 3 = full stealth
|
|
10
|
-
import { logger } from './logger.js';
|
|
11
|
-
// ─── Strategy Definitions ────────────────────────────────────────────────
|
|
12
|
-
const STRATEGIES = [
|
|
13
|
-
'baseline',
|
|
14
|
-
'tier1-cookies',
|
|
15
|
-
'tier2-fingerprint',
|
|
16
|
-
'tier3-full-stealth',
|
|
17
|
-
];
|
|
18
|
-
// ─── Arm → Stealth Mode Mapping ─────────────────────────────────────────
|
|
19
|
-
//
|
|
20
|
-
// Maps bandit arm indices to the stealth mode that gets applied per-page.
|
|
21
|
-
//
|
|
22
|
-
// Arm 0: baseline → 'off' — raw Playwright, no stealth patches
|
|
23
|
-
// Arm 1: tier1-cookies → 'passive' — remove automation signals only
|
|
24
|
-
// Arm 2: tier2-fingerprint → 'active' — full fingerprint spoofing
|
|
25
|
-
// Arm 3: tier3-full-stealth → 'active' — full stealth + extra measures
|
|
26
|
-
//
|
|
27
|
-
// Key insight: passive mode is the sweet spot for most sites. Active
|
|
28
|
-
// fingerprint spoofing is counterproductive on advanced fingerprinters
|
|
29
|
-
// (CreepJS detects the faked identity as "lies"). The bandit should
|
|
30
|
-
// naturally converge on passive (arm 1) for most sites.
|
|
31
|
-
export function armToStealthMode(armIndex) {
|
|
32
|
-
switch (armIndex) {
|
|
33
|
-
case 0: return 'off'; // baseline — no stealth at all
|
|
34
|
-
case 1: return 'passive'; // tier1 — remove automation signals only
|
|
35
|
-
case 2: return 'active'; // tier2 — full fingerprint spoofing
|
|
36
|
-
case 3: return 'active'; // tier3 — full stealth (same mode, extra measures elsewhere)
|
|
37
|
-
default: return 'passive'; // safe fallback
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Whether the given arm index implies extra behavioral measures
|
|
42
|
-
* beyond the stealth mode (rate limiting, human-like delays).
|
|
43
|
-
* Only tier3-full-stealth (arm 3) triggers these extras.
|
|
44
|
-
*/
|
|
45
|
-
export function armRequiresExtraMeasures(armIndex) {
|
|
46
|
-
return armIndex === 3;
|
|
47
|
-
}
|
|
48
|
-
// ─── EXP3 Bandit ─────────────────────────────────────────────────────────
|
|
49
|
-
export class StealthBandit {
|
|
50
|
-
weights;
|
|
51
|
-
gamma;
|
|
52
|
-
numArms;
|
|
53
|
-
constructor(numArms, gamma) {
|
|
54
|
-
this.numArms = numArms;
|
|
55
|
-
// Theoretical EXP3 optimal: sqrt(K * ln(K) / ((e-1) * T)).
|
|
56
|
-
// Without a known T, we assume T=100 visits per domain as a reasonable
|
|
57
|
-
// horizon. This keeps exploration meaningful for small K while still
|
|
58
|
-
// converging. Clamped to [0.01, 0.5] for stability.
|
|
59
|
-
this.gamma = gamma ?? Math.min(0.5, Math.max(0.01, Math.sqrt(numArms * Math.log(numArms) / ((Math.E - 1) * 100))));
|
|
60
|
-
this.weights = new Array(numArms).fill(1.0);
|
|
61
|
-
}
|
|
62
|
-
/** Probability distribution over arms (EXP3 mixture). */
|
|
63
|
-
getDistribution() {
|
|
64
|
-
const sum = this.weights.reduce((a, b) => a + b, 0);
|
|
65
|
-
return this.weights.map(w => (1 - this.gamma) * (w / sum) + this.gamma / this.numArms);
|
|
66
|
-
}
|
|
67
|
-
/** Sample an arm from the distribution. */
|
|
68
|
-
selectArm() {
|
|
69
|
-
const dist = this.getDistribution();
|
|
70
|
-
let r = Math.random();
|
|
71
|
-
for (let i = 0; i < dist.length; i++) {
|
|
72
|
-
r -= dist[i];
|
|
73
|
-
if (r <= 0)
|
|
74
|
-
return i;
|
|
75
|
-
}
|
|
76
|
-
return this.numArms - 1; // floating-point safety net
|
|
77
|
-
}
|
|
78
|
-
/** Importance-weighted exponential update after observing reward. */
|
|
79
|
-
update(arm, reward) {
|
|
80
|
-
const dist = this.getDistribution();
|
|
81
|
-
const estimatedReward = reward / dist[arm];
|
|
82
|
-
this.weights[arm] *= Math.exp(this.gamma * estimatedReward / this.numArms);
|
|
83
|
-
// Prevent weight explosion — normalize when any weight exceeds 1e10
|
|
84
|
-
const maxWeight = Math.max(...this.weights);
|
|
85
|
-
if (maxWeight > 1e10) {
|
|
86
|
-
this.weights = this.weights.map(w => w / maxWeight);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/** Serialize for persistence in domain records. */
|
|
90
|
-
toJSON() {
|
|
91
|
-
return { weights: [...this.weights], gamma: this.gamma, numArms: this.numArms };
|
|
92
|
-
}
|
|
93
|
-
/** Restore from persisted state. */
|
|
94
|
-
static fromJSON(data) {
|
|
95
|
-
const bandit = new StealthBandit(data.weights.length, data.gamma);
|
|
96
|
-
bandit.weights = [...data.weights];
|
|
97
|
-
return bandit;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// ─── Per-Domain Strategy Manager ─────────────────────────────────────────
|
|
101
|
-
export class StrategyManager {
|
|
102
|
-
bandits = new Map();
|
|
103
|
-
strategies;
|
|
104
|
-
constructor(strategies = STRATEGIES) {
|
|
105
|
-
this.strategies = strategies;
|
|
106
|
-
}
|
|
107
|
-
/** Normalize domain: strip www., lowercase. */
|
|
108
|
-
normalizeDomain(domain) {
|
|
109
|
-
return domain.toLowerCase().replace(/^www\./, '');
|
|
110
|
-
}
|
|
111
|
-
/** Get or create the bandit for a domain. */
|
|
112
|
-
getBandit(domain) {
|
|
113
|
-
const key = this.normalizeDomain(domain);
|
|
114
|
-
let bandit = this.bandits.get(key);
|
|
115
|
-
if (!bandit) {
|
|
116
|
-
bandit = new StealthBandit(this.strategies.length);
|
|
117
|
-
this.bandits.set(key, bandit);
|
|
118
|
-
logger.debug('stealth-bandit:created', { domain: key, arms: this.strategies.length });
|
|
119
|
-
}
|
|
120
|
-
return bandit;
|
|
121
|
-
}
|
|
122
|
-
/** Select a stealth strategy for the given domain. */
|
|
123
|
-
selectStrategy(domain) {
|
|
124
|
-
const bandit = this.getBandit(domain);
|
|
125
|
-
const armIndex = bandit.selectArm();
|
|
126
|
-
const strategy = this.strategies[armIndex];
|
|
127
|
-
logger.debug('stealth-bandit:selected', { domain: this.normalizeDomain(domain), strategy, armIndex });
|
|
128
|
-
return { strategy, armIndex };
|
|
129
|
-
}
|
|
130
|
-
/** Record success/failure for a strategy on a domain. */
|
|
131
|
-
recordOutcome(domain, armIndex, success) {
|
|
132
|
-
const bandit = this.getBandit(domain);
|
|
133
|
-
bandit.update(armIndex, success ? 1 : 0);
|
|
134
|
-
logger.debug('stealth-bandit:outcome', {
|
|
135
|
-
domain: this.normalizeDomain(domain),
|
|
136
|
-
arm: armIndex,
|
|
137
|
-
strategy: this.strategies[armIndex],
|
|
138
|
-
success,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
/** Debugging stats for a domain's bandit. */
|
|
142
|
-
getStats(domain) {
|
|
143
|
-
const bandit = this.getBandit(domain);
|
|
144
|
-
return {
|
|
145
|
-
distribution: bandit.getDistribution(),
|
|
146
|
-
strategies: this.strategies,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
/** Serialize a domain's bandit for persistence. */
|
|
150
|
-
toJSON(domain) {
|
|
151
|
-
const key = this.normalizeDomain(domain);
|
|
152
|
-
const bandit = this.bandits.get(key);
|
|
153
|
-
return bandit ? bandit.toJSON() : null;
|
|
154
|
-
}
|
|
155
|
-
/** Restore a domain's bandit from persisted data. */
|
|
156
|
-
fromJSON(domain, data) {
|
|
157
|
-
const key = this.normalizeDomain(domain);
|
|
158
|
-
this.bandits.set(key, StealthBandit.fromJSON(data));
|
|
159
|
-
logger.debug('stealth-bandit:restored', { domain: key });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
// ─── Singleton ───────────────────────────────────────────────────────────
|
|
163
|
-
export const strategyManager = new StrategyManager();
|