pi-lean-portal 0.1.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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
package/core/guides.ts ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Web Navigation Guides
3
+ *
4
+ * Stateless, applicable-only guide footer system. All guides are surfaced
5
+ * the same way — no inject/hint distinction, no per-task suppression state.
6
+ *
7
+ * Types, data, file loader, resolution, and footer formatting are all
8
+ * in this single file.
9
+ */
10
+
11
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { PORTAL_DATA_DIR } from "./shared/paths.js";
14
+
15
+ // ═══════════════════════════════════════════════════════════════════
16
+ // Types
17
+ // ═══════════════════════════════════════════════════════════════════
18
+
19
+ export type GuideCategory = "site" | "pattern";
20
+ export type GuideSource = "builtin" | "user";
21
+
22
+ export interface Guide {
23
+ /** Markdown guidance text (≤800 chars recommended). */
24
+ content: string;
25
+ /** ISO date of last update. */
26
+ updated: string;
27
+ category: GuideCategory;
28
+ source: GuideSource;
29
+ /** Emoji shown in badge + footer bullet (e.g. "⚠", "🍪", "📖"). */
30
+ icon: string;
31
+ /** Compact label shown in badge (e.g. "bot detection", "consent", "reddit"). */
32
+ shortName: string;
33
+ /** Domain name(s) this site guide applies to. Pattern guides leave this empty. */
34
+ domains?: string[];
35
+ /** Pattern guides only: signal that triggers this guide (e.g. "botDetected", "dialogDetected"). */
36
+ triggerSignal?: "botDetected" | "dialogDetected";
37
+ }
38
+
39
+ /** An applicable guide for the current page, with presentation fields copied. */
40
+ export interface ApplicableGuide {
41
+ /** Guide lookup key (e.g. "reddit", "bot-detection"). */
42
+ name: string;
43
+ /** Emoji from the underlying Guide. */
44
+ icon: string;
45
+ /** Compact label from the underlying Guide. */
46
+ shortName: string;
47
+ /** One-line reason this guide applies, shown in the footer bullet. */
48
+ reason: string;
49
+ /** Category from the underlying Guide. */
50
+ category: GuideCategory;
51
+ }
52
+
53
+ /**
54
+ * Sort applicable guides: patterns before sites, alphabetical within each category.
55
+ */
56
+ export function sortApplicableGuides(
57
+ guides: ApplicableGuide[],
58
+ ): ApplicableGuide[] {
59
+ return [...guides].sort((a, b) => {
60
+ if (a.category !== b.category) return a.category === "pattern" ? -1 : 1;
61
+ return a.shortName.localeCompare(b.shortName);
62
+ });
63
+ }
64
+
65
+ // ═══════════════════════════════════════════════════════════════════
66
+ // Builtin Guides
67
+ // ═══════════════════════════════════════════════════════════════════
68
+
69
+ export const BUILTIN_GUIDES: Record<string, Guide> = {
70
+ "bot-detection": {
71
+ category: "pattern",
72
+ source: "builtin",
73
+ updated: "2026-06-13",
74
+ icon: "⚠",
75
+ shortName: "bot detection",
76
+ triggerSignal: "botDetected",
77
+ content: [
78
+ "## Bot Detection Patterns",
79
+ "",
80
+ "### When You See a Challenge Page",
81
+ '- Cloudflare: "Just a moment..." or "Checking your browser" — wait 5–10 seconds, some challenges auto-resolve after JavaScript execution',
82
+ "- After waiting, take a fresh `browser-snapshot` to see if the real page loaded",
83
+ "- If still blocked, try `web-fetch` on the same URL — it doesn't execute JS challenges and sometimes succeeds where the browser doesn't",
84
+ "- If both fail, the site is blocking automation and cannot be accessed",
85
+ "",
86
+ "### What NOT to Do",
87
+ "- Don't try to click through CAPTCHA challenges — automated clicks are fingerprinted and often cause permanent blocks",
88
+ "- Don't retry navigation rapidly — rate limits escalate the challenge difficulty",
89
+ "- Don't assume the page is broken — use `read` on the auto-captured screenshot path shown in the navigate output",
90
+ "",
91
+ "### Backend Strategy",
92
+ "- The default `chromium` / `firefox` backends are detected by many anti-automation systems",
93
+ '- A stealth browser backend may be available — try `browser-navigate` with `strategy="stealth"` if the default backend is blocked',
94
+ "- If no stealth backend is configured, this will fail with a clear error — no harm in trying",
95
+ "",
96
+ "### Verifying the Page After a Challenge",
97
+ '- Use `browser-inspect role="dialog"` to check if a challenge dialog is still present — cheaper than a full snapshot',
98
+ "- If the dialog is gone, use `browser-inspect text=true` to read the actual page content",
99
+ "- If `browser-inspect` shows stale refs, take a fresh `browser-snapshot`",
100
+ "",
101
+ "_Last verified against common Cloudflare and Akamai challenge patterns. If the described elements don't appear, fall back to `browser-inspect` and `browser-snapshot` (which includes a screenshot path) to discover the current page structure._",
102
+ ].join("\n"),
103
+ },
104
+
105
+ "cookie-consent": {
106
+ category: "pattern",
107
+ source: "builtin",
108
+ updated: "2026-06-12",
109
+ icon: "🍪",
110
+ shortName: "consent",
111
+ triggerSignal: "dialogDetected",
112
+ content: [
113
+ "## Cookie Consent Patterns",
114
+ "",
115
+ "### Common Dialog Indicators",
116
+ '- role="dialog" or role="alertdialog" at the top of the accessibility tree',
117
+ '- Buttons containing "Accept All", "Reject All", "Decline", "Manage"',
118
+ "- Pressing Escape dismisses many consent dialog variants",
119
+ '- Use `browser-inspect role="dialog"` to quickly check if a dialog is present without loading a full snapshot',
120
+ "",
121
+ "### Navigation After Dismissal",
122
+ '- After dismissing consent, use `browser-inspect role="dialog"` to confirm the dialog is gone — cheaper than a full snapshot',
123
+ "- If `browser-inspect` shows stale refs, take a fresh `browser-snapshot`",
124
+ "- Some sites reload; others hide the dialog client-side. Either way, verify before interacting with page content",
125
+ ].join("\n"),
126
+ },
127
+
128
+ pagination: {
129
+ category: "pattern",
130
+ source: "builtin",
131
+ updated: "2026-06-12",
132
+ icon: "📄",
133
+ shortName: "pagination",
134
+ content: [
135
+ "## Pagination Patterns",
136
+ "",
137
+ "### Common Patterns",
138
+ '- "Next" or "→" button is role="button" or role="link"',
139
+ '- Page numbers may be role="list" with role="listitem" per page',
140
+ "- Infinite scroll: use `browser-scroll` to load more content",
141
+ "- After scrolling, take a fresh snapshot — new elements may appear",
142
+ "",
143
+ "### Progressive Loading",
144
+ "- Use `browser-inspect text=true maxChars=500` for an initial scan of page content, then `maxChars=0` for the full text after confirming content has loaded",
145
+ '- Use `browser-inspect role="button" name="next"` to find pagination controls without loading the full tree',
146
+ "- After scrolling, wait briefly before taking snapshot (content may still be loading)",
147
+ ].join("\n"),
148
+ },
149
+
150
+ search: {
151
+ category: "pattern",
152
+ source: "builtin",
153
+ updated: "2026-06-12",
154
+ icon: "🔍",
155
+ shortName: "search",
156
+ content: [
157
+ "## Search Patterns",
158
+ "",
159
+ "### Common Patterns",
160
+ '- Search bar is role="searchbox" or role="combobox"',
161
+ '- Keyboard shortcut "/" focuses search on many sites (use `browser-press`)',
162
+ "- Results may load in-page (SPA) or via navigation",
163
+ "",
164
+ "### After Searching",
165
+ "- Use `browser-inspect text=true` to read search results with @e refs, rather than loading a full snapshot",
166
+ '- Results are often role="list" with role="listitem" per result',
167
+ "- Pagination controls follow the patterns in the pagination guide",
168
+ ].join("\n"),
169
+ },
170
+ };
171
+
172
+ // ═══════════════════════════════════════════════════════════════════
173
+ // File Loader (user-authored guides)
174
+ // ═══════════════════════════════════════════════════════════════════
175
+
176
+ /**
177
+ * Directory for user-authored web navigation guides.
178
+ * Lives under the portal-owned subtree so it survives package upgrades
179
+ * and is user-writable. No shipped site guides exist in the package —
180
+ * all curated patterns are in BUILTIN_GUIDES (code).
181
+ *
182
+ * Users who git-track this directory for version control should be aware:
183
+ * only top-level .md files with valid YAML frontmatter are loaded;
184
+ * directories (.git/, subfolders) and non-markdown files are safely skipped.
185
+ */
186
+ export const USER_GUIDES_DIR = join(PORTAL_DATA_DIR, "web-guides");
187
+
188
+ /**
189
+ * Parse a raw guide file content string with YAML frontmatter.
190
+ * Separated from the file-reader for testability.
191
+ */
192
+ export function parseGuideContent(
193
+ raw: string,
194
+ filename: string,
195
+ ): [string, Guide] | null {
196
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
197
+ if (!match) return null;
198
+
199
+ const [, frontmatter, content] = match;
200
+ if (frontmatter === undefined || content === undefined) return null;
201
+
202
+ const meta: Record<string, string> = {};
203
+ for (const line of frontmatter.split("\n")) {
204
+ const colonIdx = line.indexOf(":");
205
+ if (colonIdx === -1) continue;
206
+ const key = line.slice(0, colonIdx).trim();
207
+ const value = line.slice(colonIdx + 1).trim();
208
+ if (key) meta[key] = value;
209
+ }
210
+
211
+ const name = filename.replace(/\.md$/, "");
212
+ const category = meta["category"] === "pattern" ? "pattern" : "site";
213
+ const updated = meta["updated"] ?? new Date().toISOString().slice(0, 10);
214
+
215
+ const icon = meta["icon"] ?? "📖";
216
+ const shortName = meta["shortName"] ?? name;
217
+
218
+ let triggerSignal: "botDetected" | "dialogDetected" | undefined;
219
+ if (meta["trigger.signal"]) {
220
+ triggerSignal = meta["trigger.signal"] as "botDetected" | "dialogDetected";
221
+ }
222
+
223
+ const rawDomains = meta["domains"];
224
+ const domains: string[] | undefined = rawDomains
225
+ ? rawDomains
226
+ .split(",")
227
+ .map((d) => d.trim())
228
+ .filter(Boolean)
229
+ : undefined;
230
+
231
+ return [
232
+ name,
233
+ {
234
+ category,
235
+ source: "user" as GuideSource,
236
+ updated,
237
+ icon,
238
+ shortName,
239
+ content: content.trim(),
240
+ ...(domains ? { domains } : {}),
241
+ ...(triggerSignal ? { triggerSignal } : {}),
242
+ },
243
+ ];
244
+ }
245
+
246
+ /** Parse a user guide .md file with YAML frontmatter. */
247
+ export function parseGuideFile(
248
+ filepath: string,
249
+ filename: string,
250
+ ): [string, Guide] | null {
251
+ try {
252
+ const raw = readFileSync(filepath, "utf-8");
253
+ return parseGuideContent(raw, filename);
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /** Load user-authored guides from web-guides/ directory. */
260
+ export function loadUserGuides(): Record<string, Guide> {
261
+ const result: Record<string, Guide> = {};
262
+ try {
263
+ if (!existsSync(USER_GUIDES_DIR)) return result;
264
+ const entries = readdirSync(USER_GUIDES_DIR);
265
+ for (const filename of entries) {
266
+ // Only top-level .md files. Directories (.git/, subfolders) and
267
+ // non-markdown files are skipped — safe for users who git-track
268
+ // this directory for version control.
269
+ if (!filename.endsWith(".md")) continue;
270
+ const filepath = join(USER_GUIDES_DIR, filename);
271
+ if (!statSync(filepath).isFile()) continue;
272
+ const parsed = parseGuideFile(filepath, filename);
273
+ if (parsed) {
274
+ const [name, guide] = parsed;
275
+ result[name] = guide;
276
+ }
277
+ }
278
+ } catch {
279
+ // web-guides/ dir may not exist or be unreadable — degrade gracefully
280
+ }
281
+ return result;
282
+ }
283
+
284
+ // ── Merged Guide Content (lazy) ────────────────────────────────
285
+
286
+ let _guideContentCache: Record<string, Guide> | null = null;
287
+
288
+ /**
289
+ * Get the merged guide content (builtin + user-authored).
290
+ * Lazily built on first call; invalidate via invalidateGuideContent().
291
+ * User-authored guides override builtin guides on name collision.
292
+ */
293
+ export function getGuideContent(): Record<string, Guide> {
294
+ if (!_guideContentCache) {
295
+ _guideContentCache = {
296
+ ...BUILTIN_GUIDES,
297
+ ...loadUserGuides(),
298
+ };
299
+ }
300
+ return _guideContentCache;
301
+ }
302
+
303
+ /** Invalidate the guide content cache so the next getGuideContent() call rescans guides/. */
304
+ export function invalidateGuideContent(): void {
305
+ _guideContentCache = null;
306
+ }
307
+
308
+ /**
309
+ * Override the guide content cache for testing.
310
+ * Pass undefined/null to reset to default (same as invalidateGuideContent).
311
+ * @internal
312
+ */
313
+ export function _setGuideContentForTest(content?: Record<string, Guide>): void {
314
+ _guideContentCache = content ?? null;
315
+ }
316
+
317
+ /** Format guide listing grouped by category, with icon/shortName and trigger info. */
318
+ export function formatGuideList(): string {
319
+ const sites: string[] = [];
320
+ const patterns: string[] = [];
321
+
322
+ for (const [name, g] of Object.entries(getGuideContent())) {
323
+ const trigger = g.triggerSignal
324
+ ? ` — ${g.icon} ${g.shortName}, fires on ${g.triggerSignal}`
325
+ : "";
326
+ const entry = ` ${name} (${g.source}, updated ${g.updated})${trigger}`;
327
+ if (g.category === "site") {
328
+ sites.push(entry);
329
+ } else {
330
+ patterns.push(entry);
331
+ }
332
+ }
333
+
334
+ return [
335
+ "Available guides:\n",
336
+ "Site guides:",
337
+ ...sites.sort(),
338
+ "",
339
+ "Pattern guides:",
340
+ ...patterns.sort(),
341
+ "",
342
+ 'Source: "builtin" = shipped with extension, "user" = loaded from ~/.pi/agent/pi-lean-portal/web-guides/.',
343
+ 'Call web-guide guide="<name>" for guidance.',
344
+ ].join("\n");
345
+ }
346
+
347
+ // ═══════════════════════════════════════════════════════════════════
348
+ // Stateless Guide Resolution
349
+ // ═══════════════════════════════════════════════════════════════════
350
+
351
+ /** Signal → reason string for pattern guide matching. */
352
+ const SIGNAL_REASONS: Record<"botDetected" | "dialogDetected", string> = {
353
+ botDetected: "challenge page detected",
354
+ dialogDetected: "consent dialog detected",
355
+ };
356
+
357
+ /**
358
+ * Resolve all applicable guides for a navigate result.
359
+ *
360
+ * Stateless — no per-task suppression. Returns all matching guides.
361
+ * Pattern triggers (bot-detection, cookie-consent) are evaluated first,
362
+ * followed by domain site-guide lookup.
363
+ */
364
+ export function resolveApplicableGuides(
365
+ url: string,
366
+ dialogDetected: boolean,
367
+ botDetected: boolean,
368
+ ): ApplicableGuide[] {
369
+ const result: ApplicableGuide[] = [];
370
+ const content = getGuideContent();
371
+
372
+ // 1. Pattern guides by trigger signal
373
+ for (const [name, guide] of Object.entries(content)) {
374
+ if (guide.triggerSignal === "botDetected" && botDetected) {
375
+ result.push({
376
+ name,
377
+ icon: guide.icon,
378
+ shortName: guide.shortName,
379
+ reason: SIGNAL_REASONS["botDetected"],
380
+ category: "pattern",
381
+ });
382
+ } else if (guide.triggerSignal === "dialogDetected" && dialogDetected) {
383
+ result.push({
384
+ name,
385
+ icon: guide.icon,
386
+ shortName: guide.shortName,
387
+ reason: SIGNAL_REASONS["dialogDetected"],
388
+ category: "pattern",
389
+ });
390
+ }
391
+ }
392
+
393
+ // 2. Domain site guides
394
+ let hostname: string;
395
+ try {
396
+ hostname = new URL(url).hostname;
397
+ } catch {
398
+ // Invalid URL — pattern results still returned, domain lookup skipped
399
+ return sortApplicableGuides(result);
400
+ }
401
+
402
+ const guideName = buildDomainMap()[hostname];
403
+ if (guideName) {
404
+ const guide = content[guideName];
405
+ if (guide) {
406
+ result.push({
407
+ name: guideName,
408
+ icon: guide.icon,
409
+ shortName: guide.shortName,
410
+ reason: `site guide for ${hostname}`,
411
+ category: guide.category,
412
+ });
413
+ }
414
+ }
415
+
416
+ return sortApplicableGuides(result);
417
+ }
418
+
419
+ /**
420
+ * Format the guide footer appended to navigate output.
421
+ *
422
+ * Input must be pre-sorted via sortApplicableGuides() (patterns before sites,
423
+ * alphabetical within each). Returns "" when no guides are applicable.
424
+ */
425
+ export function formatGuideFooter(guides: ApplicableGuide[]): string {
426
+ if (guides.length === 0) return "";
427
+
428
+ const lines: string[] = [
429
+ "📖 Guides available for this page — call web-guide to review before interacting (once each per conversation):",
430
+ ];
431
+
432
+ let addedSiteHeader = false;
433
+ for (const g of guides) {
434
+ if (g.category === "site" && !addedSiteHeader) {
435
+ lines.push(" Site:");
436
+ addedSiteHeader = true;
437
+ }
438
+ lines.push(` • ${g.icon} ${g.shortName} — ${g.reason}`);
439
+ }
440
+
441
+ return lines.join("\n");
442
+ }
443
+
444
+ // ═══════════════════════════════════════════════════════════════════
445
+ // Dynamic Domain Map
446
+ // ═══════════════════════════════════════════════════════════════════
447
+
448
+ /**
449
+ * Build a domain map (hostname → guide name) from all guides returned
450
+ * by getGuideContent() that have a `domains` field.
451
+ * Derives from the single source of truth (getGuideContent).
452
+ */
453
+ export function buildDomainMap(): Record<string, string> {
454
+ const map: Record<string, string> = {};
455
+ for (const [name, guide] of Object.entries(getGuideContent())) {
456
+ if (
457
+ guide.category === "site" &&
458
+ guide.domains &&
459
+ guide.domains.length > 0
460
+ ) {
461
+ for (const domain of guide.domains) {
462
+ map[domain] = name;
463
+ }
464
+ }
465
+ }
466
+ return map;
467
+ }