pi-lean-dimension 0.1.0 → 0.2.0
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 +96 -36
- package/node_modules/pi-lean-portal/AGENTS.md +146 -4
- package/node_modules/pi-lean-portal/README.md +112 -50
- package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
- package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
- package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
- package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
- package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
- package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
- package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
- package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
- package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
- package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
- package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
- package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
- package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
- package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
- package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
- package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
- package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
- package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
- package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
- package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
- package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
- package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
- package/node_modules/pi-lean-portal/contributed/README.md +304 -0
- package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
- package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
- package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
- package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
- package/node_modules/pi-lean-portal/core/router.ts +59 -98
- package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
- package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
- package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
- package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
- package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
- package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
- package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
- package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
- package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
- package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
- package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
- package/node_modules/pi-lean-portal/index.ts +42 -7
- package/node_modules/pi-lean-portal/package.json +8 -3
- package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
- package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
- package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
- package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
- package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
- package/node_modules/pi-lean-search/README.md +6 -2
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
- package/node_modules/pi-lean-search/index.ts +4 -21
- package/node_modules/pi-lean-search/package.json +2 -2
- package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
- package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
- package/package.json +4 -4
- package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
- package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
- package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { join, isAbsolute } from "node:path";
|
|
10
|
+
import { USER_BACKENDS_DIR } from "./shared/paths.js";
|
|
10
11
|
// ─── Plugin Config types ──────────────────────────────────────────
|
|
11
12
|
|
|
12
13
|
/** A single plugin entry from the user's settings.json */
|
|
13
14
|
export interface PluginConfig {
|
|
14
15
|
/** Stable identifier used in strategy param, session tracking, errors */
|
|
15
16
|
name: string;
|
|
16
|
-
/** Directory name
|
|
17
|
+
/** Directory name (or absolute path) containing the plugin code.
|
|
18
|
+
* Resolved by `detectPluginType` against the configured roots: an
|
|
19
|
+
* absolute path short-circuits; otherwise the shipped `backends/`
|
|
20
|
+
* root is tried first, then the user `user-backends/` tree. */
|
|
17
21
|
dir: string;
|
|
18
22
|
/** Whether this plugin is active (default: true) */
|
|
19
23
|
enabled: boolean;
|
|
@@ -65,16 +69,11 @@ export interface PluginConfigLoadResult {
|
|
|
65
69
|
errors: string[];
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
*/
|
|
71
|
-
export interface FullConfig {
|
|
72
|
+
/** Internal cache for loadFullConfig() — invalidated via invalidateConfigCache() */
|
|
73
|
+
let _fullConfigCache: {
|
|
72
74
|
browser: BrowserConfig;
|
|
73
75
|
plugins: PluginConfigLoadResult;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Internal cache for loadFullConfig() — invalidated via invalidateConfigCache() */
|
|
77
|
-
let _fullConfigCache: FullConfig | null = null;
|
|
76
|
+
} | null = null;
|
|
78
77
|
|
|
79
78
|
/**
|
|
80
79
|
* Parse the browser config section from settings JSON.
|
|
@@ -129,9 +128,10 @@ function parseBrowserConfig(
|
|
|
129
128
|
* Extracts and validates the `browser.plugins` array.
|
|
130
129
|
* If no plugins are configured, returns a single default Chromium plugin.
|
|
131
130
|
*/
|
|
132
|
-
|
|
131
|
+
/** @internal */
|
|
132
|
+
export function parsePluginConfig(
|
|
133
133
|
raw: Record<string, unknown> | undefined,
|
|
134
|
-
|
|
134
|
+
roots: readonly string[],
|
|
135
135
|
): PluginConfigLoadResult {
|
|
136
136
|
const errors: string[] = [];
|
|
137
137
|
|
|
@@ -180,7 +180,7 @@ function parsePluginConfig(
|
|
|
180
180
|
if (validated) {
|
|
181
181
|
// Also validate that the directory exists and is unambiguous
|
|
182
182
|
try {
|
|
183
|
-
detectPluginType(validated.dir,
|
|
183
|
+
detectPluginType(validated.dir, roots);
|
|
184
184
|
} catch (err) {
|
|
185
185
|
errors.push(
|
|
186
186
|
`plugins[${i}] ('${validated.name}'): ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -222,17 +222,20 @@ function readBrowserConfigRaw(): Record<string, unknown> | undefined {
|
|
|
222
222
|
* Load and cache the full browser configuration from settings.json.
|
|
223
223
|
*
|
|
224
224
|
* Reads settings.json once on first call and caches the result for
|
|
225
|
-
* subsequent calls.
|
|
226
|
-
* delegate to this function.
|
|
225
|
+
* subsequent calls.
|
|
227
226
|
*/
|
|
228
|
-
export function loadFullConfig(
|
|
227
|
+
export function loadFullConfig(roots?: readonly string[]): {
|
|
228
|
+
browser: BrowserConfig;
|
|
229
|
+
plugins: PluginConfigLoadResult;
|
|
230
|
+
} {
|
|
229
231
|
if (_fullConfigCache) return _fullConfigCache;
|
|
230
232
|
|
|
231
233
|
const raw = readBrowserConfigRaw();
|
|
234
|
+
const effectiveRoots = roots ?? DEFAULT_BACKEND_ROOTS;
|
|
232
235
|
|
|
233
236
|
_fullConfigCache = {
|
|
234
237
|
browser: parseBrowserConfig(raw),
|
|
235
|
-
plugins: parsePluginConfig(raw,
|
|
238
|
+
plugins: parsePluginConfig(raw, effectiveRoots),
|
|
236
239
|
};
|
|
237
240
|
|
|
238
241
|
return _fullConfigCache;
|
|
@@ -246,19 +249,6 @@ export function invalidateConfigCache(): void {
|
|
|
246
249
|
_fullConfigCache = null;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
/**
|
|
250
|
-
* Convenience wrapper — returns the `defaultProfile` setting from the
|
|
251
|
-
* cached browser configuration. Delegates to `loadFullConfig()` which
|
|
252
|
-
* reads `settings.json` once and caches the result.
|
|
253
|
-
*
|
|
254
|
-
* The `browser.defaultProfile` field controls what happens when
|
|
255
|
-
* `browser-navigate` is called without an explicit `profile` parameter.
|
|
256
|
-
* See `BrowserConfig` for valid values.
|
|
257
|
-
*/
|
|
258
|
-
export function loadBrowserConfig(): BrowserConfig {
|
|
259
|
-
return loadFullConfig().browser;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
252
|
// ─── Validation ───────────────────────────────────────────────────
|
|
263
253
|
|
|
264
254
|
/**
|
|
@@ -332,38 +322,66 @@ function validateEntry(
|
|
|
332
322
|
/**
|
|
333
323
|
* Detect the plugin type from the directory contents.
|
|
334
324
|
*
|
|
325
|
+
* Resolution order for `dir`:
|
|
326
|
+
* 1. **Absolute path** — if `dir` is absolute, it is used directly
|
|
327
|
+
* (skips all roots; useful for development / power users).
|
|
328
|
+
* 2. **Each root in `roots`, in order** — `join(root, dir)`. The first
|
|
329
|
+
* root that contains an unambiguous entry point wins.
|
|
330
|
+
*
|
|
331
|
+
* Per-root ambiguity: if a single resolved dir contains *both*
|
|
332
|
+
* `index.ts` and `bridge.py`, that is an error (the original behaviour).
|
|
333
|
+
* If the first root has `index.ts` and a later root has `bridge.py`, the
|
|
334
|
+
* first root wins — that is a legitimate multi-root layout, not ambiguity.
|
|
335
|
+
*
|
|
336
|
+
* If no resolved dir has any entry point, throws an error naming every
|
|
337
|
+
* root searched so users can see where the loader looked.
|
|
338
|
+
*
|
|
335
339
|
* - `backends/<dir>/index.ts` exists → Node plugin
|
|
336
340
|
* - `backends/<dir>/bridge.py` exists → Python plugin
|
|
337
|
-
* - Both exist → error (ambiguous)
|
|
338
|
-
* - Neither exists → error
|
|
341
|
+
* - Both exist in the same resolved dir → error (ambiguous)
|
|
342
|
+
* - Neither exists anywhere → error
|
|
339
343
|
*/
|
|
340
344
|
export function detectPluginType(
|
|
341
345
|
dir: string,
|
|
342
|
-
|
|
346
|
+
roots: readonly string[],
|
|
343
347
|
): PluginDetection {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
348
|
+
const candidatePaths: string[] = [];
|
|
349
|
+
if (isAbsolute(dir)) {
|
|
350
|
+
candidatePaths.push(dir);
|
|
351
|
+
} else {
|
|
352
|
+
for (const root of roots) {
|
|
353
|
+
candidatePaths.push(join(root, dir));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
347
356
|
|
|
348
|
-
const
|
|
349
|
-
|
|
357
|
+
for (const dirPath of candidatePaths) {
|
|
358
|
+
const indexPath = join(dirPath, "index.ts");
|
|
359
|
+
const bridgePath = join(dirPath, "bridge.py");
|
|
350
360
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
`Plugin dir '${dir}' is ambiguous: both index.ts and bridge.py found. Remove one.`,
|
|
354
|
-
);
|
|
355
|
-
}
|
|
361
|
+
const hasIndex = existsSync(indexPath);
|
|
362
|
+
const hasBridge = existsSync(bridgePath);
|
|
356
363
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
364
|
+
if (hasIndex && hasBridge) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Plugin dir '${dir}' is ambiguous: both index.ts and bridge.py found in ${dirPath}. Remove one.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (hasIndex) {
|
|
371
|
+
return { type: "node", entryPoint: indexPath };
|
|
372
|
+
}
|
|
360
373
|
|
|
361
|
-
|
|
362
|
-
|
|
374
|
+
if (hasBridge) {
|
|
375
|
+
return { type: "python", entryPoint: bridgePath };
|
|
376
|
+
}
|
|
363
377
|
}
|
|
364
378
|
|
|
379
|
+
const searched = candidatePaths.length
|
|
380
|
+
? candidatePaths.join(", ")
|
|
381
|
+
: "(no roots provided)";
|
|
365
382
|
throw new Error(
|
|
366
|
-
`Plugin dir '${dir}' has no entry point. Expected index.ts (Node) or bridge.py (Python)
|
|
383
|
+
`Plugin dir '${dir}' has no entry point. Expected index.ts (Node) or bridge.py (Python). ` +
|
|
384
|
+
`Searched: ${searched}`,
|
|
367
385
|
);
|
|
368
386
|
}
|
|
369
387
|
|
|
@@ -376,13 +394,12 @@ export function detectPluginType(
|
|
|
376
394
|
export const DEFAULT_BACKENDS_ROOT = join(__dirname, "..", "backends");
|
|
377
395
|
|
|
378
396
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* (
|
|
397
|
+
* Default root search order for plugin discovery: the shipped package
|
|
398
|
+
* `backends/` first, then the user-writable `user-backends/` tree under
|
|
399
|
+
* `~/.pi/agent/pi-lean-portal/`. Callers may pass a custom `roots`
|
|
400
|
+
* array (e.g. tests with temp dirs); when omitted, this default is used.
|
|
383
401
|
*/
|
|
384
|
-
export
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
402
|
+
export const DEFAULT_BACKEND_ROOTS: readonly string[] = [
|
|
403
|
+
DEFAULT_BACKENDS_ROOT,
|
|
404
|
+
USER_BACKENDS_DIR,
|
|
405
|
+
];
|
|
@@ -49,8 +49,6 @@ export function validatePlugin(plugin: BrowserPlugin): string[] {
|
|
|
49
49
|
/** Internal tracking for a registered plugin */
|
|
50
50
|
interface RegistryEntry {
|
|
51
51
|
plugin: BrowserPlugin;
|
|
52
|
-
/** Position in the user's plugins config array (lower = higher priority, used for LLM escalation hints) */
|
|
53
|
-
level: number;
|
|
54
52
|
/** Whether this plugin is enabled */
|
|
55
53
|
enabled: boolean;
|
|
56
54
|
}
|
|
@@ -61,7 +59,7 @@ export class PluginRegistry {
|
|
|
61
59
|
/** Map of plugin name → registry entry */
|
|
62
60
|
private entries = new Map<string, RegistryEntry>();
|
|
63
61
|
|
|
64
|
-
/** Ordered list of plugin names from config (
|
|
62
|
+
/** Ordered list of plugin names from config (lower index = higher priority / recommended first) */
|
|
65
63
|
private orderedNames: string[] = [];
|
|
66
64
|
|
|
67
65
|
/**
|
|
@@ -72,8 +70,8 @@ export class PluginRegistry {
|
|
|
72
70
|
* plugins loaded via dynamic `import()` vs Python plugins registered
|
|
73
71
|
* synchronously).
|
|
74
72
|
*
|
|
75
|
-
* If `register()` finds its name already in the seeded order, it
|
|
76
|
-
*
|
|
73
|
+
* If `register()` finds its name already in the seeded order, it keeps
|
|
74
|
+
* that position instead of appending.
|
|
77
75
|
*
|
|
78
76
|
* @param names - Plugin names in the desired priority order (typically
|
|
79
77
|
* the order from the user's `browser.plugins` config array).
|
|
@@ -85,9 +83,8 @@ export class PluginRegistry {
|
|
|
85
83
|
/**
|
|
86
84
|
* Register a plugin with its config.
|
|
87
85
|
*
|
|
88
|
-
* If the plugin name was pre-seeded via `seedOrder()`,
|
|
89
|
-
*
|
|
90
|
-
* the end.
|
|
86
|
+
* If the plugin name was pre-seeded via `seedOrder()`, it keeps that
|
|
87
|
+
* position in the ordered list. Otherwise it is appended at the end.
|
|
91
88
|
*
|
|
92
89
|
* @throws if a plugin with the same name is already registered
|
|
93
90
|
* @throws if the plugin is missing required operations
|
|
@@ -107,23 +104,17 @@ export class PluginRegistry {
|
|
|
107
104
|
);
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
if (level === -1) {
|
|
113
|
-
level = this.orderedNames.length;
|
|
107
|
+
// Preserve pre-seeded order (from seedOrder); otherwise append.
|
|
108
|
+
if (!this.orderedNames.includes(plugin.name)) {
|
|
114
109
|
this.orderedNames.push(plugin.name);
|
|
115
110
|
}
|
|
116
111
|
|
|
117
112
|
this.entries.set(plugin.name, {
|
|
118
113
|
plugin,
|
|
119
|
-
level,
|
|
120
114
|
enabled: config.enabled,
|
|
121
115
|
});
|
|
122
116
|
}
|
|
123
117
|
|
|
124
|
-
/**
|
|
125
|
-
* Get a plugin by name. Returns undefined if not registered or disabled.
|
|
126
|
-
*/
|
|
127
118
|
get(name: string): BrowserPlugin | undefined {
|
|
128
119
|
const entry = this.entries.get(name);
|
|
129
120
|
if (!entry || !entry.enabled) return undefined;
|
|
@@ -152,14 +143,13 @@ export class PluginRegistry {
|
|
|
152
143
|
|
|
153
144
|
/**
|
|
154
145
|
* Get all enabled plugins in config order (priority order).
|
|
155
|
-
* Each entry includes the plugin and its priority level (lower = recommended first).
|
|
156
146
|
*/
|
|
157
|
-
getOrdered():
|
|
158
|
-
const result:
|
|
147
|
+
getOrdered(): BrowserPlugin[] {
|
|
148
|
+
const result: BrowserPlugin[] = [];
|
|
159
149
|
for (const name of this.orderedNames) {
|
|
160
150
|
const entry = this.entries.get(name);
|
|
161
151
|
if (entry?.enabled) {
|
|
162
|
-
result.push(
|
|
152
|
+
result.push(entry.plugin);
|
|
163
153
|
}
|
|
164
154
|
}
|
|
165
155
|
return result;
|
|
@@ -169,7 +159,7 @@ export class PluginRegistry {
|
|
|
169
159
|
* List all registered plugin names (enabled only).
|
|
170
160
|
*/
|
|
171
161
|
available(): string[] {
|
|
172
|
-
return this.getOrdered().map((
|
|
162
|
+
return this.getOrdered().map((p) => p.name);
|
|
173
163
|
}
|
|
174
164
|
|
|
175
165
|
/**
|
|
@@ -182,26 +172,6 @@ export class PluginRegistry {
|
|
|
182
172
|
});
|
|
183
173
|
}
|
|
184
174
|
|
|
185
|
-
/**
|
|
186
|
-
* Get the priority level for a plugin (lower = recommended first).
|
|
187
|
-
* Used by the LLM to decide whether to escalate to a different backend.
|
|
188
|
-
* Returns undefined if the plugin is not registered.
|
|
189
|
-
*/
|
|
190
|
-
getLevel(name: string): number | undefined {
|
|
191
|
-
return this.entries.get(name)?.level;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Get plugins at higher priority levels (further in the backup chain) than the given level.
|
|
196
|
-
* Used to suggest alternative backends when bot detection fires.
|
|
197
|
-
*/
|
|
198
|
-
getHigherStealth(currentLevel: number): Array<{
|
|
199
|
-
plugin: BrowserPlugin;
|
|
200
|
-
level: number;
|
|
201
|
-
}> {
|
|
202
|
-
return this.getOrdered().filter((e) => e.level > currentLevel);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
175
|
/**
|
|
206
176
|
* Resolve a strategy value to a plugin.
|
|
207
177
|
*
|
|
@@ -248,16 +218,8 @@ export class PluginRegistry {
|
|
|
248
218
|
this.entries.clear();
|
|
249
219
|
this.orderedNames = [];
|
|
250
220
|
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Number of registered plugins (enabled and disabled).
|
|
254
|
-
*/
|
|
255
|
-
get size(): number {
|
|
256
|
-
return this.entries.size;
|
|
257
|
-
}
|
|
258
221
|
}
|
|
259
222
|
|
|
260
223
|
// ─── Singleton ────────────────────────────────────────────────────
|
|
261
224
|
|
|
262
|
-
/** Global plugin registry instance */
|
|
263
225
|
export const pluginRegistry = new PluginRegistry();
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin Router — registry-based dispatch for interactive browser operations.
|
|
3
|
-
*
|
|
4
|
-
* All dispatch goes through the PluginRegistry, which resolves
|
|
5
|
-
* the correct plugin based on the `strategy` parameter.
|
|
6
|
-
*
|
|
7
|
-
* Cross-cutting concerns handled here (not in plugins):
|
|
8
|
-
* - Snapshot truncation (compactSnapshot)
|
|
9
|
-
* - URL safety validation
|
|
10
|
-
* - Session lifecycle (via sessionManager)
|
|
11
|
-
* - Auto-recovery from crashed sessions (via lastNav)
|
|
3
|
+
* Handles snapshot truncation, URL safety, session lifecycle, and crash recovery.
|
|
12
4
|
*/
|
|
13
5
|
|
|
14
6
|
import { writeFileSync } from "node:fs";
|
|
@@ -30,7 +22,7 @@ import {
|
|
|
30
22
|
sanitizeProfileName,
|
|
31
23
|
sessionProfileName,
|
|
32
24
|
} from "./shared/storage-state.js";
|
|
33
|
-
import {
|
|
25
|
+
import { loadFullConfig } from "./plugin-config.js";
|
|
34
26
|
import {
|
|
35
27
|
runExtractor,
|
|
36
28
|
correlateElements,
|
|
@@ -38,6 +30,7 @@ import {
|
|
|
38
30
|
formatElementList,
|
|
39
31
|
formatRoleCountSummary,
|
|
40
32
|
type ExtractResult,
|
|
33
|
+
type QueryStatus,
|
|
41
34
|
} from "./shared/dom-extractor.js";
|
|
42
35
|
import type {
|
|
43
36
|
DialogEvent,
|
|
@@ -228,16 +221,6 @@ async function requireInteractiveSession(taskId: string): Promise<{
|
|
|
228
221
|
return null;
|
|
229
222
|
}
|
|
230
223
|
|
|
231
|
-
/**
|
|
232
|
-
* Get the plugin for a given session. Returns undefined if the session
|
|
233
|
-
* doesn't exist or the plugin is not available.
|
|
234
|
-
*/
|
|
235
|
-
function getPluginForSession(
|
|
236
|
-
session: BrowserSession,
|
|
237
|
-
): import("./plugin-api.js").BrowserPlugin | undefined {
|
|
238
|
-
return pluginRegistry.get(session.pluginName);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
224
|
interface ResolvedSession {
|
|
242
225
|
tid: string;
|
|
243
226
|
session: BrowserSession;
|
|
@@ -255,7 +238,7 @@ async function resolveSession(
|
|
|
255
238
|
const tid = taskId ?? "default";
|
|
256
239
|
const sr = await requireInteractiveSession(tid);
|
|
257
240
|
if (!sr) return null;
|
|
258
|
-
const plugin =
|
|
241
|
+
const plugin = pluginRegistry.get(sr.session.pluginName);
|
|
259
242
|
if (!plugin) return null;
|
|
260
243
|
return {
|
|
261
244
|
tid,
|
|
@@ -463,7 +446,7 @@ export async function navigate(
|
|
|
463
446
|
let resolvedProfileName: string | undefined;
|
|
464
447
|
let profileMode: "none" | "session" | "named" | undefined;
|
|
465
448
|
|
|
466
|
-
const browserConfig =
|
|
449
|
+
const browserConfig = loadFullConfig().browser;
|
|
467
450
|
const profileInput = options.profile ?? browserConfig.defaultProfile;
|
|
468
451
|
|
|
469
452
|
if (profileInput === "none") {
|
|
@@ -731,13 +714,16 @@ export async function snapshot(
|
|
|
731
714
|
|
|
732
715
|
// ─── Interaction tools ──────────────────────────────────────────────
|
|
733
716
|
|
|
734
|
-
|
|
717
|
+
/** Shared shape for the 5 ref-based interaction tools (click/type/scroll/goBack/press). */
|
|
718
|
+
async function wrapInteraction(
|
|
735
719
|
taskId: string | undefined,
|
|
736
|
-
|
|
720
|
+
run: (
|
|
721
|
+
plugin: import("./plugin-api.js").BrowserPlugin,
|
|
722
|
+
tid: string,
|
|
723
|
+
) => Promise<InteractionResult>,
|
|
737
724
|
): Promise<InteractionResult> {
|
|
738
725
|
const resolved = await resolveSession(taskId);
|
|
739
726
|
if (!resolved) return noSessionError();
|
|
740
|
-
|
|
741
727
|
return refBasedInteractionOrSnapshot(
|
|
742
728
|
resolved.tid,
|
|
743
729
|
resolved.wasAutoCreated,
|
|
@@ -745,83 +731,44 @@ export async function click(
|
|
|
745
731
|
async () =>
|
|
746
732
|
compactInteractionResult(
|
|
747
733
|
resolved.tid,
|
|
748
|
-
await resolved.plugin
|
|
734
|
+
await run(resolved.plugin, resolved.tid),
|
|
749
735
|
),
|
|
750
736
|
);
|
|
751
737
|
}
|
|
752
738
|
|
|
739
|
+
export async function click(
|
|
740
|
+
taskId: string | undefined,
|
|
741
|
+
ref: string,
|
|
742
|
+
): Promise<InteractionResult> {
|
|
743
|
+
return wrapInteraction(taskId, (plugin, tid) => plugin.click(tid, ref));
|
|
744
|
+
}
|
|
745
|
+
|
|
753
746
|
export async function type(
|
|
754
747
|
taskId: string | undefined,
|
|
755
748
|
ref: string,
|
|
756
749
|
text: string,
|
|
757
750
|
): Promise<InteractionResult> {
|
|
758
|
-
|
|
759
|
-
if (!resolved) return noSessionError();
|
|
760
|
-
|
|
761
|
-
return refBasedInteractionOrSnapshot(
|
|
762
|
-
resolved.tid,
|
|
763
|
-
resolved.wasAutoCreated,
|
|
764
|
-
resolved.plugin,
|
|
765
|
-
async () =>
|
|
766
|
-
compactInteractionResult(
|
|
767
|
-
resolved.tid,
|
|
768
|
-
await resolved.plugin.type(resolved.tid, ref, text),
|
|
769
|
-
),
|
|
770
|
-
);
|
|
751
|
+
return wrapInteraction(taskId, (plugin, tid) => plugin.type(tid, ref, text));
|
|
771
752
|
}
|
|
772
753
|
|
|
773
754
|
export async function scroll(
|
|
774
755
|
taskId: string | undefined,
|
|
775
756
|
direction: "up" | "down",
|
|
776
757
|
): Promise<InteractionResult> {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
return refBasedInteractionOrSnapshot(
|
|
781
|
-
resolved.tid,
|
|
782
|
-
resolved.wasAutoCreated,
|
|
783
|
-
resolved.plugin,
|
|
784
|
-
async () =>
|
|
785
|
-
compactInteractionResult(
|
|
786
|
-
resolved.tid,
|
|
787
|
-
await resolved.plugin.scroll(resolved.tid, direction),
|
|
788
|
-
),
|
|
758
|
+
return wrapInteraction(taskId, (plugin, tid) =>
|
|
759
|
+
plugin.scroll(tid, direction),
|
|
789
760
|
);
|
|
790
761
|
}
|
|
791
762
|
|
|
792
763
|
export async function goBack(taskId?: string): Promise<InteractionResult> {
|
|
793
|
-
|
|
794
|
-
if (!resolved) return noSessionError();
|
|
795
|
-
|
|
796
|
-
return refBasedInteractionOrSnapshot(
|
|
797
|
-
resolved.tid,
|
|
798
|
-
resolved.wasAutoCreated,
|
|
799
|
-
resolved.plugin,
|
|
800
|
-
async () =>
|
|
801
|
-
compactInteractionResult(
|
|
802
|
-
resolved.tid,
|
|
803
|
-
await resolved.plugin.goBack(resolved.tid),
|
|
804
|
-
),
|
|
805
|
-
);
|
|
764
|
+
return wrapInteraction(taskId, (plugin, tid) => plugin.goBack(tid));
|
|
806
765
|
}
|
|
807
766
|
|
|
808
767
|
export async function press(
|
|
809
768
|
taskId: string | undefined,
|
|
810
769
|
key: string,
|
|
811
770
|
): Promise<InteractionResult> {
|
|
812
|
-
|
|
813
|
-
if (!resolved) return noSessionError();
|
|
814
|
-
|
|
815
|
-
return refBasedInteractionOrSnapshot(
|
|
816
|
-
resolved.tid,
|
|
817
|
-
resolved.wasAutoCreated,
|
|
818
|
-
resolved.plugin,
|
|
819
|
-
async () =>
|
|
820
|
-
compactInteractionResult(
|
|
821
|
-
resolved.tid,
|
|
822
|
-
await resolved.plugin.press(resolved.tid, key),
|
|
823
|
-
),
|
|
824
|
-
);
|
|
771
|
+
return wrapInteraction(taskId, (plugin, tid) => plugin.press(tid, key));
|
|
825
772
|
}
|
|
826
773
|
|
|
827
774
|
/**
|
|
@@ -992,17 +939,24 @@ export async function browserInspect(
|
|
|
992
939
|
};
|
|
993
940
|
}
|
|
994
941
|
|
|
995
|
-
// Run the DOM extractor
|
|
996
|
-
|
|
997
|
-
|
|
942
|
+
// Run the DOM extractor. The !cacheExists guard above guarantees
|
|
943
|
+
// cache is non-null and non-empty here, so we always route to the
|
|
944
|
+
// live-cache query hint on failure.
|
|
945
|
+
const outcome = await runExtractor(tid, plugin);
|
|
946
|
+
if (!outcome.ok) {
|
|
998
947
|
return {
|
|
999
948
|
success: false,
|
|
1000
949
|
content: "",
|
|
1001
950
|
error:
|
|
1002
|
-
|
|
951
|
+
`Text extraction failed: ${outcome.error}. ` +
|
|
952
|
+
`A live element cache (${cache!.size} elements) is available — ` +
|
|
953
|
+
"use browser-inspect with role=, name=, or ref= to query it, " +
|
|
954
|
+
"or browser-snapshot to refresh.",
|
|
1003
955
|
};
|
|
1004
956
|
}
|
|
1005
957
|
|
|
958
|
+
const extracted = outcome.result;
|
|
959
|
+
|
|
1006
960
|
// Keyword filtering — case-insensitive substring match on extracted text
|
|
1007
961
|
if (params.query) {
|
|
1008
962
|
applyQueryFilter(extracted, params.query);
|
|
@@ -1044,16 +998,30 @@ export async function browserInspect(
|
|
|
1044
998
|
};
|
|
1045
999
|
}
|
|
1046
1000
|
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1001
|
+
const status: QueryStatus = {};
|
|
1002
|
+
const filtered = queryElementCache(
|
|
1003
|
+
cache!,
|
|
1004
|
+
{
|
|
1005
|
+
...(params.role !== undefined ? { role: params.role } : {}),
|
|
1006
|
+
...(params.name !== undefined ? { name: params.name } : {}),
|
|
1007
|
+
...(params.ref !== undefined ? { ref: params.ref } : {}),
|
|
1008
|
+
...(params.subtree !== undefined ? { subtree: params.subtree } : {}),
|
|
1009
|
+
},
|
|
1010
|
+
status,
|
|
1011
|
+
);
|
|
1053
1012
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1013
|
+
let content: string;
|
|
1014
|
+
if (filtered.length === 0 && status.refFilteredOut) {
|
|
1015
|
+
const { node, filter, value } = status.refFilteredOut;
|
|
1016
|
+
content =
|
|
1017
|
+
`Element @${node.ref} found in cache (role=${node.role}` +
|
|
1018
|
+
`${node.name ? `, name="${node.name}"` : ""}) but does not match ` +
|
|
1019
|
+
`filter ${filter}="${value}". Drop the ${filter} filter or adjust it.`;
|
|
1020
|
+
} else {
|
|
1021
|
+
content = formatElementList(filtered, {
|
|
1022
|
+
...(params.ref !== undefined ? { ref: params.ref } : {}),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1057
1025
|
|
|
1058
1026
|
return {
|
|
1059
1027
|
success: true,
|
|
@@ -1090,13 +1058,6 @@ export async function browserInspect(
|
|
|
1090
1058
|
function applyQueryFilter(extracted: ExtractResult, query: string): void {
|
|
1091
1059
|
const q = query.toLowerCase();
|
|
1092
1060
|
|
|
1093
|
-
const beforeCount =
|
|
1094
|
-
extracted.headings.length +
|
|
1095
|
-
extracted.paragraphs.length +
|
|
1096
|
-
extracted.links.length +
|
|
1097
|
-
extracted.images.length +
|
|
1098
|
-
extracted.interactive.length;
|
|
1099
|
-
|
|
1100
1061
|
extracted.headings = extracted.headings.filter((h) =>
|
|
1101
1062
|
h.text.toLowerCase().includes(q),
|
|
1102
1063
|
);
|
|
@@ -1122,7 +1083,7 @@ function applyQueryFilter(extracted: ExtractResult, query: string): void {
|
|
|
1122
1083
|
extracted.interactive.length;
|
|
1123
1084
|
|
|
1124
1085
|
// Signal empty results so the agent doesn't get a silent empty page
|
|
1125
|
-
if (
|
|
1086
|
+
if (afterCount === 0) {
|
|
1126
1087
|
extracted.paragraphs.push({
|
|
1127
1088
|
text: `⚠ No content matched "${query}". Try a different keyword or remove the query parameter.`,
|
|
1128
1089
|
});
|