pi-lean-dimension 0.2.1 → 0.2.2
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/node_modules/pi-lean-portal/AGENTS.md +1 -1
- package/node_modules/pi-lean-portal/README.md +27 -17
- package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +28 -0
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +237 -181
- package/node_modules/pi-lean-portal/backends/python-adapter.ts +7 -2
- package/node_modules/pi-lean-portal/core/guides.ts +1 -2
- package/node_modules/pi-lean-portal/core/plugin-config.ts +27 -3
- package/node_modules/pi-lean-portal/core/router.ts +2 -1
- package/node_modules/pi-lean-portal/core/shared/storage-state.ts +6 -2
- package/node_modules/pi-lean-portal/package.json +1 -1
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +241 -1
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +222 -2
- package/package.json +3 -3
|
@@ -10,7 +10,10 @@ import { join, delimiter as pathDelimiter } from "node:path";
|
|
|
10
10
|
|
|
11
11
|
import { sessionManager } from "../core/shared/session-manager.js";
|
|
12
12
|
import { persistSessionState } from "../core/shared/storage-state.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_BACKENDS_ROOT,
|
|
15
|
+
loadFullConfig,
|
|
16
|
+
} from "../core/plugin-config.js";
|
|
14
17
|
|
|
15
18
|
import {
|
|
16
19
|
DEFAULT_CAPABILITIES,
|
|
@@ -957,7 +960,8 @@ export class PythonPluginAdapter implements BrowserPlugin {
|
|
|
957
960
|
skip_default_viewport: !!raw.skip_default_viewport,
|
|
958
961
|
skip_networkidle: !!raw.skip_networkidle,
|
|
959
962
|
wrap_mw_eval_in_eval: !!raw.wrap_mw_eval_in_eval,
|
|
960
|
-
csp_safe_readonly_via_init_script:
|
|
963
|
+
csp_safe_readonly_via_init_script:
|
|
964
|
+
!!raw.csp_safe_readonly_via_init_script,
|
|
961
965
|
}),
|
|
962
966
|
(error): QuirksDescriptor => ({
|
|
963
967
|
success: false,
|
|
@@ -1116,6 +1120,7 @@ export class PythonPluginAdapter implements BrowserPlugin {
|
|
|
1116
1120
|
};
|
|
1117
1121
|
},
|
|
1118
1122
|
"via Python bridge",
|
|
1123
|
+
loadFullConfig().browser.maxStorageStateSize,
|
|
1119
1124
|
);
|
|
1120
1125
|
}
|
|
1121
1126
|
|
|
@@ -90,8 +90,7 @@ export const BUILTIN_GUIDES: Record<string, Guide> = {
|
|
|
90
90
|
"",
|
|
91
91
|
"### Backend Strategy",
|
|
92
92
|
"- The default `chromium` / `firefox` backends are detected by many anti-automation systems",
|
|
93
|
-
'- A stealth browser backend may be available —
|
|
94
|
-
"- If no stealth backend is configured, this will fail with a clear error — no harm in trying",
|
|
93
|
+
'- A stealth browser backend may be available — the `browser-navigate` `strategy` parameter lists the registered backend names; retry with a stealth backend name listed there (e.g. `strategy="camoufox"`) if the default is blocked',
|
|
95
94
|
"",
|
|
96
95
|
"### Verifying the Page After a Challenge",
|
|
97
96
|
'- Use `browser-inspect role="dialog"` to check if a challenge dialog is still present — cheaper than a full snapshot',
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { join, isAbsolute } from "node:path";
|
|
10
10
|
import { USER_BACKENDS_DIR } from "./shared/paths.js";
|
|
11
|
+
import {
|
|
12
|
+
sanitizeProfileName,
|
|
13
|
+
DEFAULT_MAX_STORAGE_STATE_SIZE,
|
|
14
|
+
} from "./shared/storage-state.js";
|
|
15
|
+
import { readMergedSettings } from "./shared/settings-reader.js";
|
|
11
16
|
// ─── Plugin Config types ──────────────────────────────────────────
|
|
12
17
|
|
|
13
18
|
/** A single plugin entry from the user's settings.json */
|
|
@@ -34,9 +39,6 @@ export interface PluginDetection {
|
|
|
34
39
|
/** Absolute or relative path to the entry point */
|
|
35
40
|
entryPoint: string;
|
|
36
41
|
}
|
|
37
|
-
import { sanitizeProfileName } from "./shared/storage-state.js";
|
|
38
|
-
import { readMergedSettings } from "./shared/settings-reader.js";
|
|
39
|
-
|
|
40
42
|
// ─── Config types & loading ────────────────────────────────────────
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -51,6 +53,12 @@ export interface BrowserConfig {
|
|
|
51
53
|
* - A named profile string (e.g. "shopping", "work")
|
|
52
54
|
*/
|
|
53
55
|
defaultProfile: "none" | "session" | string;
|
|
56
|
+
/**
|
|
57
|
+
* Byte threshold before a profile's saved storage state logs a size
|
|
58
|
+
* warning (still saved). Defaults to 10 MB (`DEFAULT_MAX_STORAGE_STATE_SIZE`).
|
|
59
|
+
* Set via `browser.maxStorageStateSize` in settings.json.
|
|
60
|
+
*/
|
|
61
|
+
maxStorageStateSize: number;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/** Raw plugin entry from settings.json (before validation) */
|
|
@@ -87,6 +95,7 @@ function parseBrowserConfig(
|
|
|
87
95
|
// Defaults
|
|
88
96
|
const config: BrowserConfig = {
|
|
89
97
|
defaultProfile: "session",
|
|
98
|
+
maxStorageStateSize: DEFAULT_MAX_STORAGE_STATE_SIZE,
|
|
90
99
|
};
|
|
91
100
|
|
|
92
101
|
if (!raw) return config;
|
|
@@ -114,6 +123,21 @@ function parseBrowserConfig(
|
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
// ── maxStorageStateSize ─────────────────────────────────────
|
|
127
|
+
if (raw.maxStorageStateSize !== undefined) {
|
|
128
|
+
if (
|
|
129
|
+
typeof raw.maxStorageStateSize === "number" &&
|
|
130
|
+
Number.isFinite(raw.maxStorageStateSize) &&
|
|
131
|
+
raw.maxStorageStateSize > 0
|
|
132
|
+
) {
|
|
133
|
+
config.maxStorageStateSize = Math.floor(raw.maxStorageStateSize);
|
|
134
|
+
} else {
|
|
135
|
+
errors.push(
|
|
136
|
+
`browser.maxStorageStateSize: expected a positive finite number, got ${JSON.stringify(raw.maxStorageStateSize)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
117
141
|
// Log validation errors (non-fatal, but surfaced in return)
|
|
118
142
|
for (const err of errors) {
|
|
119
143
|
console.warn(`[pi-lean-portal] Config warning: ${err}`);
|
|
@@ -580,7 +580,8 @@ export async function navigate(
|
|
|
580
580
|
elementCount: 0,
|
|
581
581
|
error:
|
|
582
582
|
"Page appears to be blocked by anti-automation protection. " +
|
|
583
|
-
"
|
|
583
|
+
"Retry `browser-navigate` with a stealth backend name from the `strategy` parameter's listed backends, " +
|
|
584
|
+
"or try `web-fetch` for the raw HTML (it skips JS fingerprinting but loses interactivity).",
|
|
584
585
|
backendUsed: plugin.name,
|
|
585
586
|
botDetectionWarning: true,
|
|
586
587
|
} as NavigateResult & {
|
|
@@ -32,7 +32,7 @@ export const PROFILE_DIR = join(PORTAL_DATA_DIR, "browser-state");
|
|
|
32
32
|
const STORAGE_STATE_VERSION = 1;
|
|
33
33
|
|
|
34
34
|
/** Default size limit (10 MB) before a warning is logged on save. */
|
|
35
|
-
const DEFAULT_MAX_STORAGE_STATE_SIZE = 10 * 1024 * 1024;
|
|
35
|
+
export const DEFAULT_MAX_STORAGE_STATE_SIZE = 10 * 1024 * 1024;
|
|
36
36
|
|
|
37
37
|
/** Profile name validation regex. */
|
|
38
38
|
const PROFILE_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
@@ -468,6 +468,9 @@ export function deleteStorageState(profileName: string): void {
|
|
|
468
468
|
* @param getStorageState - Callback that produces the raw storage state.
|
|
469
469
|
* @param viaLabel - Optional backend label inserted into the warning
|
|
470
470
|
* (e.g. `"via Python bridge"`) for diagnostic output.
|
|
471
|
+
* @param maxSizeBytes - Optional byte threshold for the size warning
|
|
472
|
+
* (defaults to `DEFAULT_MAX_STORAGE_STATE_SIZE` when
|
|
473
|
+
* omitted).
|
|
471
474
|
* @returns The raw state object, or `undefined` if the session is
|
|
472
475
|
* non-persistent or the save failed.
|
|
473
476
|
*/
|
|
@@ -475,12 +478,13 @@ export async function persistSessionState(
|
|
|
475
478
|
session: { persistState?: boolean; profileName?: string } | null | undefined,
|
|
476
479
|
getStorageState: () => Promise<{ cookies: unknown[]; origins: unknown[] }>,
|
|
477
480
|
viaLabel: string = "",
|
|
481
|
+
maxSizeBytes?: number,
|
|
478
482
|
): Promise<{ cookies: unknown[]; origins: unknown[] } | undefined> {
|
|
479
483
|
if (!session?.persistState) return undefined;
|
|
480
484
|
const name = session.profileName ?? "default";
|
|
481
485
|
try {
|
|
482
486
|
const state = await getStorageState();
|
|
483
|
-
saveStorageState(name, state);
|
|
487
|
+
saveStorageState(name, state, maxSizeBytes);
|
|
484
488
|
return state;
|
|
485
489
|
} catch (err) {
|
|
486
490
|
console.warn(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-portal",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pi extension. Interactive web browsing for Pi — a /web toggle removes the tools from context when switched off, Playwright Chromium/Firefox deliver accessibility-tree snapshots, persistent profiles, cookies, and domain-matched guides; custom/stealth backends (Camoufox) plug in when a site blocks the shipped browsers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* instance and are not included here.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
9
9
|
import { readSearxngUrl } from "../search-config.js";
|
|
10
10
|
import { webSearchTool } from "../web-search-tool.js";
|
|
11
11
|
|
|
@@ -150,4 +150,244 @@ describe("webSearchTool", () => {
|
|
|
150
150
|
it("has renderResult function", () => {
|
|
151
151
|
expect(typeof webSearchTool.renderResult).toBe("function");
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
describe("renderResult answer badge", () => {
|
|
155
|
+
it("shows 💡 badge when answerCount > 0 and no results", () => {
|
|
156
|
+
const mockTheme = {
|
|
157
|
+
fg: (_style: string, text: string) => text,
|
|
158
|
+
} as any;
|
|
159
|
+
const result = (webSearchTool.renderResult as any)(
|
|
160
|
+
{
|
|
161
|
+
content: [{ text: "" }],
|
|
162
|
+
details: { answerCount: 2, query: "weather" },
|
|
163
|
+
} as any,
|
|
164
|
+
{ expanded: false, isPartial: false },
|
|
165
|
+
mockTheme,
|
|
166
|
+
{} as any,
|
|
167
|
+
) as any;
|
|
168
|
+
expect(result.text).toContain("💡");
|
|
169
|
+
expect(result.text).toContain("2 answer(s)");
|
|
170
|
+
expect(result.text).toContain('"weather"');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("shows 💡 badge alongside 🔍 when both answers and results present", () => {
|
|
174
|
+
const mockTheme = {
|
|
175
|
+
fg: (_style: string, text: string) => text,
|
|
176
|
+
} as any;
|
|
177
|
+
const result = (webSearchTool.renderResult as any)(
|
|
178
|
+
{
|
|
179
|
+
content: [{ text: "" }],
|
|
180
|
+
details: {
|
|
181
|
+
answerCount: 1,
|
|
182
|
+
resultCount: 3,
|
|
183
|
+
query: "test",
|
|
184
|
+
results: [
|
|
185
|
+
{ title: "A", url: "http://a.com", engine: "google" },
|
|
186
|
+
{ title: "B", url: "http://b.com", engine: "google" },
|
|
187
|
+
{ title: "C", url: "http://c.com", engine: "google" },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
} as any,
|
|
191
|
+
{ expanded: false, isPartial: false },
|
|
192
|
+
mockTheme,
|
|
193
|
+
{} as any,
|
|
194
|
+
) as any;
|
|
195
|
+
expect(result.text).toContain("💡");
|
|
196
|
+
expect(result.text).toContain("1 answer(s)");
|
|
197
|
+
expect(result.text).toContain("🔍");
|
|
198
|
+
expect(result.text).toContain("3 result(s)");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("shows answer text above results when expanded", () => {
|
|
202
|
+
const mockTheme = {
|
|
203
|
+
fg: (_style: string, text: string) => text,
|
|
204
|
+
} as any;
|
|
205
|
+
const result = (webSearchTool.renderResult as any)(
|
|
206
|
+
{
|
|
207
|
+
content: [{ text: "" }],
|
|
208
|
+
details: {
|
|
209
|
+
answerCount: 1,
|
|
210
|
+
resultCount: 1,
|
|
211
|
+
query: "weather berlin",
|
|
212
|
+
answers: [
|
|
213
|
+
{
|
|
214
|
+
template: "answer/weather.html",
|
|
215
|
+
text: "Berlin: 14°C, light rain",
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
results: [{ title: "A", url: "http://a.com" }],
|
|
219
|
+
},
|
|
220
|
+
} as any,
|
|
221
|
+
{ expanded: true, isPartial: false },
|
|
222
|
+
mockTheme,
|
|
223
|
+
{} as any,
|
|
224
|
+
) as any;
|
|
225
|
+
const answerIdx = result.text.indexOf("Berlin: 14°C, light rain");
|
|
226
|
+
const resultIdx = result.text.indexOf("A");
|
|
227
|
+
expect(answerIdx).toBeGreaterThan(-1);
|
|
228
|
+
expect(resultIdx).toBeGreaterThan(-1);
|
|
229
|
+
expect(answerIdx).toBeLessThan(resultIdx);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("shows answer text when expanded with no results", () => {
|
|
233
|
+
const mockTheme = {
|
|
234
|
+
fg: (_style: string, text: string) => text,
|
|
235
|
+
} as any;
|
|
236
|
+
const result = (webSearchTool.renderResult as any)(
|
|
237
|
+
{
|
|
238
|
+
content: [{ text: "" }],
|
|
239
|
+
details: {
|
|
240
|
+
answerCount: 1,
|
|
241
|
+
query: "avg 1 2 3",
|
|
242
|
+
answers: [{ template: "answer/legacy.html", text: "2" }],
|
|
243
|
+
},
|
|
244
|
+
} as any,
|
|
245
|
+
{ expanded: true, isPartial: false },
|
|
246
|
+
mockTheme,
|
|
247
|
+
{} as any,
|
|
248
|
+
) as any;
|
|
249
|
+
expect(result.text).toContain("💡");
|
|
250
|
+
expect(result.text).toContain("2");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ─── Execute answer rendering (mocked fetch) ─────────────────
|
|
256
|
+
|
|
257
|
+
describe("execute answer rendering", () => {
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
vi.clearAllMocks();
|
|
260
|
+
// Make readSearxngUrl return a valid URL
|
|
261
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
262
|
+
vi.mocked(readFileSync).mockReturnValue(
|
|
263
|
+
JSON.stringify({ searxng: { url: "http://localhost:8888" } }),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
afterEach(() => {
|
|
268
|
+
vi.unstubAllGlobals();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const mockFetch = (json: unknown) =>
|
|
272
|
+
vi.stubGlobal(
|
|
273
|
+
"fetch",
|
|
274
|
+
vi.fn().mockResolvedValue({
|
|
275
|
+
ok: true,
|
|
276
|
+
status: 200,
|
|
277
|
+
text: async () => JSON.stringify(json),
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it("legacy answer renders + bypasses empty-results early return", async () => {
|
|
282
|
+
mockFetch({
|
|
283
|
+
results: [],
|
|
284
|
+
answers: [{ template: "answer/legacy.html", answer: "614.24" }],
|
|
285
|
+
suggestions: [],
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const result = await webSearchTool.execute(
|
|
289
|
+
"call-1",
|
|
290
|
+
{ query: "614.24" },
|
|
291
|
+
undefined,
|
|
292
|
+
undefined,
|
|
293
|
+
{} as any,
|
|
294
|
+
);
|
|
295
|
+
const r = result as any;
|
|
296
|
+
expect(r.content[0].text).toContain("614.24");
|
|
297
|
+
expect(r.content[0].text).not.toContain("No web search results found");
|
|
298
|
+
expect(r.content[0].text).toContain("No web results");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("weather answer uses server summary", async () => {
|
|
302
|
+
mockFetch({
|
|
303
|
+
results: [],
|
|
304
|
+
answers: [
|
|
305
|
+
{
|
|
306
|
+
template: "answer/weather.html",
|
|
307
|
+
current: {
|
|
308
|
+
location: { name: "Berlin", latitude: 52.52, longitude: 13.405 },
|
|
309
|
+
temperature: { val: 14, unit: "°C" },
|
|
310
|
+
condition: "light rain",
|
|
311
|
+
summary: "Berlin: 14°C, light rain",
|
|
312
|
+
feels_like: { val: 12, unit: "°C" },
|
|
313
|
+
humidity: { val: 80, unit: "%" },
|
|
314
|
+
wind_speed: { val: 18, unit: "km/h" },
|
|
315
|
+
wind_from: { name: "NW" },
|
|
316
|
+
},
|
|
317
|
+
service: "open-meteo",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
suggestions: [],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const result = await webSearchTool.execute(
|
|
324
|
+
"call-2",
|
|
325
|
+
{ query: "weather berlin" },
|
|
326
|
+
undefined,
|
|
327
|
+
undefined,
|
|
328
|
+
{} as any,
|
|
329
|
+
);
|
|
330
|
+
// Uses server-provided summary
|
|
331
|
+
const r = result as any;
|
|
332
|
+
expect(r.content[0].text).toContain("Berlin: 14°C, light rain");
|
|
333
|
+
// Secondary detail line
|
|
334
|
+
expect(r.content[0].text).toContain("feels like 12°C");
|
|
335
|
+
expect(r.content[0].text).toContain("humidity 80%");
|
|
336
|
+
expect(r.content[0].text).toContain("wind 18km/h");
|
|
337
|
+
// forecasts not rendered
|
|
338
|
+
expect(r.content[0].text).not.toContain("forecast");
|
|
339
|
+
// source
|
|
340
|
+
expect(r.content[0].text).toContain("open-meteo");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("unknown template falls back to legacy heuristic", async () => {
|
|
344
|
+
mockFetch({
|
|
345
|
+
results: [],
|
|
346
|
+
answers: [{ template: "answer/future.html", answer: "x" }],
|
|
347
|
+
suggestions: [],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = await webSearchTool.execute(
|
|
351
|
+
"call-3",
|
|
352
|
+
{ query: "future" },
|
|
353
|
+
undefined,
|
|
354
|
+
undefined,
|
|
355
|
+
{} as any,
|
|
356
|
+
);
|
|
357
|
+
const r = result as any;
|
|
358
|
+
expect(r.content[0].text).toContain("x");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("answers + results both present (primary success path)", async () => {
|
|
362
|
+
mockFetch({
|
|
363
|
+
results: [
|
|
364
|
+
{
|
|
365
|
+
title: "Result 1",
|
|
366
|
+
url: "http://example.com/1",
|
|
367
|
+
content: "First result",
|
|
368
|
+
engine: "google",
|
|
369
|
+
score: 0.95,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
answers: [{ template: "answer/legacy.html", answer: "42" }],
|
|
373
|
+
suggestions: [],
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const result = await webSearchTool.execute(
|
|
377
|
+
"call-5",
|
|
378
|
+
{ query: "answer of life" },
|
|
379
|
+
undefined,
|
|
380
|
+
undefined,
|
|
381
|
+
{} as any,
|
|
382
|
+
);
|
|
383
|
+
// Answer above results
|
|
384
|
+
const r = result as any;
|
|
385
|
+
const answerIdx = r.content[0].text.indexOf("42");
|
|
386
|
+
const resultIdx = r.content[0].text.indexOf("Result 1");
|
|
387
|
+
expect(answerIdx).toBeGreaterThanOrEqual(0);
|
|
388
|
+
expect(resultIdx).toBeGreaterThan(answerIdx);
|
|
389
|
+
// Both present
|
|
390
|
+
expect(r.content[0].text).toContain("42");
|
|
391
|
+
expect(r.content[0].text).toContain("Result 1");
|
|
392
|
+
});
|
|
153
393
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pi extension. SearXNG search tool for Pi — pairs with pi-lean-portal's /web toggle; search-only installs are valid, or add it to a portal install for the full web-tools suite.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|