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 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>&nbsp;&nbsp;|&nbsp;&nbsp;Works with Claude Code, Cursor, Windsurf
9
+ <code>npm i leapfrog-mcp</code>&nbsp;&nbsp;|&nbsp;&nbsp;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`). These cover the vectors that fingerprint services like CreepJS and fingerprint-pro actually check:
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 _(coming)_ |
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 (anti-bot evasion) 19 patches |
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
- | `LEAP_SIDECAR_PORT` | `9222` | Sidecar HTTP server port (used with tiling) |
342
- | `LEAP_CDP_STEALTH` | `true` | CDP detection evasion (`Runtime.enable` filtering) |
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
- 815 passing across 33 suites
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, tracing, sidecar HTTP, bug regression, stress tests, benchmarks.
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
- const LEAP_TILE = process.env.LEAP_TILE;
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. CDP detection will update later.
83
- const defaultW = LEAP_SCREEN_WIDTH > 0 ? LEAP_SCREEN_WIDTH : 1920;
84
- const defaultH = LEAP_SCREEN_HEIGHT > 0 ? LEAP_SCREEN_HEIGHT : 1080;
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
  },
@@ -288,10 +288,9 @@ export class SessionManager {
288
288
  if (stealth.isEnabled()) {
289
289
  launchArgs.push(...stealth.getLaunchArgs());
290
290
  }
291
- // Windows DPI: prevent Chromium from double-scaling window positions
292
- if (process.platform === 'win32') {
293
- launchArgs.push('--force-device-scale-factor=1');
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
@@ -48,8 +48,8 @@ declare class TileManager {
48
48
  */
49
49
  static detectTerminalScreen(): ScreenWorkArea | null;
50
50
  /**
51
- * Windows: detect which monitor contains the foreground window via PowerShell.
52
- * Uses .NET System.Windows.Forms.Screen to map foreground window screen bounds.
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>;
@@ -76,36 +76,12 @@ class TileManager {
76
76
  return null;
77
77
  }
78
78
  /**
79
- * Windows: detect which monitor contains the foreground window via PowerShell.
80
- * Uses .NET System.Windows.Forms.Screen to map foreground window screen bounds.
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
- // PowerShell script:
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.1",
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
- }
@@ -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 {};
@@ -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();