@bacnh85/pi-web 0.5.7 → 0.6.1

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/CHANGELOG.md CHANGED
@@ -1,13 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 (2026-08-30)
4
+
5
+ ### Changed
6
+
7
+ - Trimmed static prompt overhead ~385 tokens/turn: compressed the injected
8
+ `Web Tool Routing` guidance block (1,247 -> 281 chars, same routing table
9
+ + backend rules), cut all 7 tools' promptGuidelines to <=2 unique lines,
10
+ and shortened web_crawl/web_screenshot schema descriptions. One
11
+ hook.test.ts assertion updated to the compressed phrasing. No tool,
12
+ parameter, or default changed.
13
+
14
+ ## 0.6.1 (2026-08-19)
15
+
16
+ ### Changed
17
+
18
+ - `web_extract` agy backend default model updated to `gemini-3.7-flash-medium`
19
+ — the current Flash generation in agy 1.1.x (3.6 is still served, this just
20
+ follows the latest).
21
+
22
+ ## 0.6.0 (2026-08-07)
23
+
24
+ ### Features
25
+
26
+ - **agy extraction backend:** `web_extract` gains a new `agy` mode that uses the Antigravity CLI (Gemini/Claude) native `read_url` web tool to fetch bot-protected and anti-AI-scraping pages that block Firecrawl/Crawl4AI. `auto` mode now falls back static → dynamic → full → agy; explicit `mode: "agy"` forces it. Structured extraction (`prompt`/`schema`) is supported. `web_status` reports `agy.installed`.
27
+ - agy is optional and self-contained: if the CLI is not installed, `auto` mode skips it silently and existing flows are unchanged. Install: `curl -fsSL https://antigravity.google/cli/install.sh | bash`, then authenticate once with `agy`.
28
+
29
+ All notable changes to `pi-web` will be documented in this file.
30
+
3
31
  ## 0.5.7 (2026-08-05)
4
32
 
5
33
  ### Improvements
6
34
 
7
35
  - Patch version bump for release sync and package documentation update.
8
36
 
9
- All notable changes to `pi-web` will be documented in this file.
10
-
11
37
  ## 0.5.6 (2026-08-01)
12
38
 
13
39
  ### Features
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pi extension for **unified web search, content extraction, site crawling, and page capture**.
4
4
 
5
- Auto-selects the best backend from SearXNG (self-hosted), Brave Search, Firecrawl, and Crawl4AI — so agents don't have to know which backend to use. Search selection is adaptive: broad discovery prefers self-hosted SearXNG, while precision-sensitive searches and inline content prefer Brave.
5
+ Auto-selects the best backend from SearXNG (self-hosted), Brave Search, Firecrawl, Crawl4AI, and agy (Gemini/Claude, when installed) — so agents don't have to know which backend to use. Search selection is adaptive: broad discovery prefers self-hosted SearXNG, while precision-sensitive searches and inline content prefer Brave.
6
6
 
7
7
  ## Install
8
8
 
@@ -79,12 +79,13 @@ Use `backend` parameter to force a specific backend when needed.
79
79
 
80
80
  ### `web_extract` — Unified content extraction
81
81
 
82
- Extracts readable content from a URL. Auto-selects backend: static (JSDOM) → dynamic (Firecrawl) → full (Crawl4AI), with extraction diagnostics showing fallback attempts.
82
+ Extracts readable content from a URL. Auto-selects backend: static (JSDOM) → dynamic (Firecrawl) → full (Crawl4AI) → agy (model-backed), with extraction diagnostics showing fallback attempts.
83
83
 
84
84
  ```
85
85
  web_extract url="https://docs.ansible.com/..."
86
86
  web_extract url="https://riven.tv/" mode=static
87
87
  web_extract url="https://example.com" mode=dynamic prompt="Extract pricing plans"
88
+ web_extract url="https://blocked.example.com" mode=agy
88
89
  ```
89
90
 
90
91
  Parameters:
@@ -92,9 +93,9 @@ Parameters:
92
93
  | Parameter | Type | Default | Description |
93
94
  |---|---|---|---|
94
95
  | `url` | string | — | URL to extract |
95
- | `mode` | string | `auto` | `auto`, `static`, `dynamic`, or `full` |
96
- | `prompt` | string | — | Prompt for JSON extraction (dynamic mode) |
97
- | `schema` | any | — | JSON schema for structured extraction (dynamic mode) |
96
+ | `mode` | string | `auto` | `auto`, `static`, `dynamic`, `full`, or `agy` |
97
+ | `prompt` | string | — | Prompt for JSON extraction (dynamic/agy modes) |
98
+ | `schema` | any | — | JSON schema for structured extraction (dynamic/agy modes) |
98
99
  | `content_chars` | number | 20000 | Max content chars |
99
100
  | `wait_for` | number | — | Milliseconds to wait for Firecrawl dynamic rendering. Crawl4AI `/md` full mode may ignore this. |
100
101
  | `mobile` | boolean | false | Emulate mobile viewport (dynamic mode) |
@@ -106,11 +107,14 @@ Parameters:
106
107
  | `static` | JSDOM+Readability | Simple static pages, blog posts, docs | No |
107
108
  | `dynamic` | Firecrawl Scrape | JS-rendered pages, dynamic content | Maybe |
108
109
  | `full` | Crawl4AI | JS-heavy SPA, complex rendering | Maybe |
109
- | `auto` (default) | static dynamic full | Unknown page type | Maybe |
110
+ | `agy` | agy (Gemini/Claude) | Bot-protected / anti-AI-scraping pages | agy CLI installed |
111
+ | `auto` (default) | static → dynamic → full → agy | Unknown page type | Maybe |
110
112
 
111
113
  In `auto` mode, fallbacks are noted in the output (e.g., `[Extraction fell back to Firecrawl Scrape (dynamic mode)]`). If `static` extraction fails, the tool gracefully escalates to heavier backends.
112
114
 
113
- > ⚠️ **Note on Firecrawl Scrape**: Fails on bot-protected sites (Ansible docs, many CDN-backed doc sites). Falls back to `full` mode (Crawl4AI) in `auto` mode.
115
+ > ⚠️ **Note on Firecrawl Scrape**: Fails on bot-protected sites (Ansible docs, many CDN-backed doc sites). Falls back to `full` mode (Crawl4AI) in `auto` mode, and to `agy` mode as a last resort.
116
+
117
+ > **`agy` mode (optional)**: Uses the [Antigravity CLI](https://antigravity.google/) with Gemini/Claude — its native `read_url` browser tool can fetch pages that block Firecrawl/Crawl4AI. Install with `curl -fsSL https://antigravity.google/cli/install.sh | bash`, authenticate once with `agy`, then `auto` mode falls back to it automatically. If agy is not installed, `auto` mode skips it silently; `web_status` reports `agy.installed`.
114
118
 
115
119
  ### `web_map` — Site URL discovery
116
120
 
@@ -172,7 +176,8 @@ Typical output:
172
176
  "baseUrl": "http://172.30.55.22:11235",
173
177
  ...
174
178
  "health": { "status": "healthy", "version": "0.5.0", ... }
175
- }
179
+ },
180
+ "agy": { "installed": true }
176
181
  }
177
182
  ```
178
183
 
@@ -188,6 +193,7 @@ Typical output:
188
193
  | `lib/searxng.ts` | SearXNG metasearch fetch client (internal) |
189
194
  | `lib/firecrawl.ts` | Firecrawl API fetch client with v2→v1 fallback (internal) |
190
195
  | `lib/crawl4ai.ts` | Crawl4AI Docker API fetch client (internal) |
196
+ | `lib/agy.ts` | agy (Antigravity CLI) spawn helper — `read_url` extraction via Gemini/Claude |
191
197
  | `lib/search.ts` | Unified search orchestrator — probes backends, fallback chain |
192
198
  | `lib/extract.ts` | Unified extraction orchestrator — mode-based backend selection |
193
199
 
@@ -57,25 +57,14 @@ const crawl4aiControlSchema = {
57
57
  // the guidance travels with the package and disappears when pi-web is absent.
58
58
  const WEB_ROUTING_GUIDANCE = `## Web Tool Routing (pi-web)
59
59
 
60
- The pi-web extension provides 7 unified tools that auto-select the best backend:
60
+ - **web_search** — web search (auto: SearXNG Brave Firecrawl; force via \`backend\`, tune via \`engines\`).
61
+ - **web_extract** — URL → markdown (auto: static JSDOM → dynamic Firecrawl → full Crawl4AI → agy; force via \`mode\`; prompt+schema for JSON extraction).
62
+ - **web_map** — discover site URLs (Firecrawl Map).
63
+ - **web_crawl** — multi-page crawl: \`mode: "light"\` (Firecrawl, url) or \`mode: "full"\` (Crawl4AI, urls[]).
64
+ - **web_screenshot** / **web_pdf** — page capture (Crawl4AI).
65
+ - **web_status** — provider config + health.
61
66
 
62
- - **\`web_search\`**Search the web (auto: SearXNG Brave Firecrawl). Use
63
- \`backend\` for explicit control, \`engines\` for SearXNG tuning.
64
- - **\`web_extract\`** — Extract readable content from a URL (auto: static JSDOM
65
- → dynamic Firecrawl → full Crawl4AI). Use \`mode\` for explicit control.
66
- - **\`web_map\`** — Discover URLs from a site (Firecrawl Map).
67
- - **\`web_crawl\`** — Crawl multiple pages. \`mode: "light"\` (Firecrawl) or
68
- \`mode: "full"\` (Crawl4AI).
69
- - **\`web_screenshot\`** / **\`web_pdf\`** — Visual/page capture (Crawl4AI).
70
- - **\`web_status\`** — Check provider configuration and server health.
71
-
72
- Backend selection rules:
73
-
74
- - Firecrawl Search has poor semantic accuracy on domain-specific queries; prefer
75
- SearXNG or Brave for precision (force via \`backend\`).
76
- - Firecrawl Scrape fails on bot-protected sites (e.g. Ansible docs); Crawl4AI
77
- handles those (force via \`mode: "full"\`).
78
- - Always cite source URLs when web results materially support an answer.`;
67
+ Rules: Firecrawl Search is weak on domain-specific queriesprefer SearXNG/Brave; Firecrawl Scrape fails on bot-protected sites — use Crawl4AI (\`mode: "full"\`) then agy (\`mode: "agy"\`); cite source URLs.`;
79
68
 
80
69
 
81
70
  // ---------------------------------------------------------------------------
@@ -90,13 +79,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
90
79
  description:
91
80
  "Search the web. Auto-selects backends: SearXNG, Brave, Firecrawl.",
92
81
  promptSnippet: "Search current web results",
93
- promptGuidelines: [
94
- "Source discovery, docs, facts, and general search.",
95
- "Broad discovery uses SearXNG; precision/site/docs use Brave; Firecrawl is fallback.",
96
- "Force backend via backend:'brave'|'searxng' for poor auto results.",
97
- "Use engines='google,github' for SearXNG tuning.",
98
- "Cite source URLs.",
99
- ],
82
+ promptGuidelines: ["Source discovery, docs, facts. Precision/site/docs → Brave via backend:'brave'; tune SearXNG via engines.", "Cite source URLs."],
100
83
  parameters: Type.Object({
101
84
  query: Type.String(),
102
85
  count: Type.Optional(Type.Number({ default: 5 })),
@@ -136,23 +119,17 @@ export default function piWebExtension(pi: ExtensionAPI) {
136
119
  name: "web_extract",
137
120
  label: "Web Content Extraction",
138
121
  description:
139
- "Extract readable content from a URL. Auto mode: static\u2192dynamic\u2192full.",
122
+ "Extract readable content from a URL. Auto mode: static\u2192dynamic\u2192full\u2192agy.",
140
123
  promptSnippet: "Extract readable webpage content as markdown",
141
- promptGuidelines: [
142
- "Clean markdown from a known URL.",
143
- "mode: 'static' (no API key, JSDOM), 'dynamic' (Firecrawl JS), 'full' (Crawl4AI).",
144
- "'auto' tries static\u2192dynamic\u2192full; see diagnostics for fallback chain.",
145
- "Use prompt+schema for structured JSON extraction (dynamic mode only).",
146
- "Cite the source URL.",
147
- ],
124
+ promptGuidelines: ["Markdown from a known URL; prompt+schema for structured JSON extraction.", "Cite the source URL."],
148
125
  parameters: Type.Object({
149
126
  url: Type.String(),
150
127
  mode: Type.Optional(Type.Union(
151
- [Type.Literal("auto"), Type.Literal("static"), Type.Literal("dynamic"), Type.Literal("full")],
152
- { default: "auto", description: "auto, static, dynamic, full." },
128
+ [Type.Literal("auto"), Type.Literal("static"), Type.Literal("dynamic"), Type.Literal("full"), Type.Literal("agy")],
129
+ { default: "auto", description: "auto, static, dynamic, full, agy." },
153
130
  )),
154
- prompt: Type.Optional(Type.String({ description: "Prompt for structured JSON extraction (dynamic mode only)." })),
155
- schema: Type.Optional(Type.Any({ description: "JSON schema for structured extraction (dynamic mode only)." })),
131
+ prompt: Type.Optional(Type.String({ description: "Prompt for structured JSON extraction (dynamic/agy modes)." })),
132
+ schema: Type.Optional(Type.Any({ description: "JSON schema for structured extraction (dynamic/agy modes)." })),
156
133
  content_chars: Type.Optional(Type.Number({ default: 20000 })),
157
134
  wait_for: Type.Optional(Type.Number({ description: "Ms to wait for Firecrawl render before extraction." })),
158
135
  mobile: Type.Optional(Type.Boolean({ default: false, description: "Mobile viewport (dynamic mode only)." })),
@@ -188,11 +165,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
188
165
  description:
189
166
  "Discover site URLs via Firecrawl Map.",
190
167
  promptSnippet: "Map site URLs",
191
- promptGuidelines: [
192
- "Discover site URLs before crawling. Prefer web_extract for small jobs.",
193
- "Best on base domains. sitemap:'only' for sub-path discovery.",
194
- "Keep limits small unless broad discovery is requested.",
195
- ],
168
+ promptGuidelines: ["URL discovery before crawling; prefer web_extract for small jobs."],
196
169
  parameters: Type.Object({
197
170
  url: Type.String(),
198
171
  limit: Type.Optional(Type.Number({ default: 100 })),
@@ -231,21 +204,17 @@ export default function piWebExtension(pi: ExtensionAPI) {
231
204
  description:
232
205
  "Crawl pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
233
206
  promptSnippet: "Crawl a small site section",
234
- promptGuidelines: [
235
- "Prefer web_map + web_extract over crawl for small jobs.",
236
- "'light'=Firecrawl (url param), 'full'=Crawl4AI (urls[] param).",
237
- "Keep limit low (default 10).",
238
- ],
207
+ promptGuidelines: ["'light'=Firecrawl (url), 'full'=Crawl4AI (urls[]). Prefer web_map + web_extract for small jobs."],
239
208
  parameters: Type.Object({
240
- url: Type.Optional(Type.String({ description: "URL for Firecrawl-style crawl (mode:'light')." })),
241
- urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style crawl (mode:'full'), up to 100." })),
209
+ url: Type.Optional(Type.String({ description: "URL for mode:'light' (Firecrawl)." })),
210
+ urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for mode:'full' (Crawl4AI), up to 100." })),
242
211
  mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "'light'(Firecrawl) or 'full'(Crawl4AI)." })),
243
212
  limit: Type.Optional(Type.Number({ default: 10 })),
244
- include_paths: Type.Optional(Type.String({ description: "Comma-separated paths to include (Firecrawl mode)." })),
245
- exclude_paths: Type.Optional(Type.String({ description: "Comma-separated paths to exclude (Firecrawl mode)." })),
246
- poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for completion (Firecrawl mode)." })),
247
- browser_config: Type.Optional(Type.Any({ description: "BrowserConfig JSON (full mode only)." })),
248
- crawler_config: Type.Optional(Type.Any({ description: "CrawlerRunConfig JSON (full mode only)." })),
213
+ include_paths: Type.Optional(Type.String({ description: "Comma-separated include paths (light mode)." })),
214
+ exclude_paths: Type.Optional(Type.String({ description: "Comma-separated exclude paths (light mode)." })),
215
+ poll: Type.Optional(Type.Boolean({ default: false, description: "Poll until completion (light mode)." })),
216
+ browser_config: Type.Optional(Type.Any({ description: "BrowserConfig JSON (full mode)." })),
217
+ crawler_config: Type.Optional(Type.Any({ description: "CrawlerRunConfig JSON (full mode)." })),
249
218
  content_chars: Type.Optional(Type.Number({ default: 20000 })),
250
219
  ...firecrawlControlSchema,
251
220
  ...crawl4aiControlSchema,
@@ -319,14 +288,11 @@ export default function piWebExtension(pi: ExtensionAPI) {
319
288
  description:
320
289
  "Full-page PNG screenshot via Crawl4AI.",
321
290
  promptSnippet: "Screenshot a webpage",
322
- promptGuidelines: [
323
- "Use when web_extract fails on JS-heavy or bot-protected pages.",
324
- "wait_for (default 2s) delays capture for dynamic content.",
325
- ],
291
+ promptGuidelines: ["Full-page PNG; use when web_extract fails on JS-heavy pages."],
326
292
  parameters: Type.Object({
327
293
  url: Type.String(),
328
294
  wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capture." })),
329
- wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images before capture." })),
295
+ wait_for_images: Type.Optional(Type.Boolean({ default: false })),
330
296
  ...crawl4aiControlSchema,
331
297
  ...sharedControlSchema,
332
298
  }),
@@ -359,10 +325,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
359
325
  description:
360
326
  "PDF document via Crawl4AI.",
361
327
  promptSnippet: "PDF a webpage",
362
- promptGuidelines: [
363
- "Printable or archivable page snapshot.",
364
- "Returns base64 PDF string.",
365
- ],
328
+ promptGuidelines: ["Printable/archivable page snapshot; returns base64 PDF."],
366
329
  parameters: Type.Object({
367
330
  url: Type.String(),
368
331
  ...crawl4aiControlSchema,
@@ -389,11 +352,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
389
352
  description:
390
353
  "Show web provider config status without printing secrets.",
391
354
  promptSnippet: "Check web provider config and server status",
392
- promptGuidelines: [
393
- "Check which backends are configured and their server status.",
394
- "Never prints secrets — reports only presence and source.",
395
- "apiKeyFound:false is normal for self-hosted Firecrawl; check ready field.",
396
- ],
355
+ promptGuidelines: ["Reports backend presence/health; never prints secrets."],
397
356
  parameters: Type.Object({}),
398
357
  async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
399
358
  const cwd = cwdFromContext(ctx);
@@ -407,6 +366,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
407
366
  const c4aiUrl = findEnvValue("CRAWL4AI_API_URL", cwd, trusted);
408
367
  const c4aiToken = findEnvValue("CRAWL4AI_API_TOKEN", cwd, trusted);
409
368
 
369
+ const { isAgyInstalled } = await import("./lib/agy");
370
+
410
371
  const fcBaseUrl = normalizeFirecrawlBaseUrl(fireUrl.value);
411
372
  const fcHosted = !fireUrl.value || fcBaseUrl.startsWith(HOSTED_FIRECRAWL_BASE_URL);
412
373
 
@@ -427,6 +388,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
427
388
  apiTokenFound: Boolean(c4aiToken.value),
428
389
  apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
429
390
  },
391
+ agy: { installed: isAgyInstalled() },
430
392
  };
431
393
 
432
394
  // Crawl4AI health check
@@ -0,0 +1,201 @@
1
+ // agy-based web extraction — uses agy's native read_url tool via Gemini/Claude.
2
+ // Self-contained: does NOT import from pi-agy (different concern, no coupling).
3
+ //
4
+ // Runs agy in --mode plan (read-only: no file writes). Verified (agy 1.1.11):
5
+ // plan mode auto-approves read_url in headless -p WITHOUT
6
+ // --dangerously-skip-permissions, so no broad permission bypass is needed.
7
+
8
+ import { createRequire } from "node:module";
9
+
10
+ import { sanitizeError } from "./format";
11
+
12
+ // ponytail: require (not ESM import) so tests can patch cp.spawn/spawnSync
13
+ // without mocking the module graph — same pattern as pi-agy.
14
+ const _require = createRequire(import.meta.url);
15
+ const cp = _require("node:child_process") as typeof import("node:child_process");
16
+
17
+ const AGY_FETCH_TIMEOUT_MS = 90_000; // agy needs time for model call + web fetch
18
+ const AGY_PROBE_TIMEOUT_MS = 5_000;
19
+ const AGY_MAX_OUTPUT_BYTES = 200_000; // bound output to protect Pi context
20
+ export const AGY_MODEL = "gemini-3.7-flash-medium"; // ponytail: fixed default; users needing model control use agy_execute
21
+
22
+ // Cache install status with a TTL — spawnSync blocks the event loop up to
23
+ // AGY_PROBE_TIMEOUT_MS, and web_status/extract can call this repeatedly.
24
+ let agyInstalledCache: { ok: boolean; at: number } | null = null;
25
+ const AGY_INSTALL_CACHE_TTL_MS = 60_000;
26
+
27
+ export function isAgyInstalled(): boolean {
28
+ if (agyInstalledCache && Date.now() - agyInstalledCache.at < AGY_INSTALL_CACHE_TTL_MS) {
29
+ return agyInstalledCache.ok;
30
+ }
31
+ // ponytail: spawnSync is the simplest reliable probe; result is cached so the
32
+ // event-loop block happens at most once per 60s.
33
+ try {
34
+ const r = cp.spawnSync("agy", ["--version"], { timeout: AGY_PROBE_TIMEOUT_MS, stdio: "ignore" });
35
+ agyInstalledCache = { ok: r.status === 0, at: Date.now() };
36
+ return agyInstalledCache.ok;
37
+ } catch {
38
+ agyInstalledCache = { ok: false, at: Date.now() };
39
+ return false;
40
+ }
41
+ }
42
+
43
+ // Test-only: clear the cached install status.
44
+ export function resetAgyInstalledCache(): void {
45
+ agyInstalledCache = null;
46
+ }
47
+
48
+ // Validate + sanitize a URL before it is interpolated into the agy model prompt.
49
+ // Prompt-injection guard: reject non-http(s) schemes and strip control chars/newlines
50
+ // so a crafted URL cannot break out of the "fetch this URL" framing.
51
+ export function sanitizeAgyUrl(url: string): string {
52
+ const trimmed = url.trim();
53
+ let parsed: URL;
54
+ try {
55
+ parsed = new URL(trimmed);
56
+ } catch {
57
+ throw new Error(`Invalid URL for agy extraction: ${trimmed.slice(0, 200)}`);
58
+ }
59
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
60
+ throw new Error(`Unsupported URL scheme for agy extraction: ${parsed.protocol}`);
61
+ }
62
+ // Neutralize any control characters/newlines so the URL stays on one line
63
+ // inside the prompt and cannot smuggle instructions.
64
+ return trimmed.replace(/[\x00-\x1f\x7f]/g, "");
65
+ }
66
+
67
+ // Build the agy CLI args for a web-fetch task. Pure — tested without mocking.
68
+ export function buildAgyFetchArgs(url: string, prompt?: string, schema?: unknown): string[] {
69
+ const safeUrl = sanitizeAgyUrl(url);
70
+ const structured = prompt || schema !== undefined;
71
+ const jsonInstruction = schema !== undefined
72
+ ? `\nReturn as JSON matching this schema: ${JSON.stringify(schema)}`
73
+ : "\nReturn the result as JSON.";
74
+ const fetchInstruction = structured
75
+ ? (prompt ? `Then extract this information: ${prompt}` : "Then extract the requested fields.") + jsonInstruction
76
+ : "Return the full page content as clean markdown.";
77
+
78
+ const agyPrompt = `Use your read_url web tool to fetch this URL: ${safeUrl}\n\n${fetchInstruction}\n\nReturn ONLY the result, no commentary.`;
79
+
80
+ return [
81
+ "--model",
82
+ AGY_MODEL,
83
+ "--mode",
84
+ "plan", // read-only: no file writes
85
+ "--print-timeout",
86
+ "90s",
87
+ // Verified (agy 1.1.11): plan mode auto-approves read_url in headless -p
88
+ // WITHOUT --dangerously-skip-permissions — pi-agy's flag is only needed for
89
+ // write modes (accept-edits/sandbox). Omitting it keeps the auto-approval
90
+ // surface at read-only web tools only.
91
+ "--output-format",
92
+ "json", // structured response for clean parsing
93
+ "-p",
94
+ agyPrompt,
95
+ ];
96
+ }
97
+
98
+ export async function extractViaAgy(params: {
99
+ url: string;
100
+ prompt?: string; // structured extraction prompt
101
+ schema?: unknown; // JSON schema for structured extraction
102
+ contentChars?: number;
103
+ signal?: AbortSignal;
104
+ }): Promise<string> {
105
+ const { url, prompt, schema, contentChars, signal } = params;
106
+ const output = await spawnAgyRaw(buildAgyFetchArgs(url, prompt, schema), signal);
107
+ return parseAgyResponse(output, contentChars ?? 20000);
108
+ }
109
+
110
+ // Parse structured JSON out of agy's model output — used when a prompt/schema was
111
+ // requested so the structured result is first-class, matching dynamic mode's
112
+ // `structured` field. Handles both fenced (```json ... ```) and bare JSON, which
113
+ // the model produces nondeterministically. Pure — tested without mocking.
114
+ export function parseAgyStructured(markdown: string): unknown {
115
+ const trimmed = markdown.trim();
116
+ if (!trimmed) return undefined;
117
+ // Fenced block first, then bare JSON object/array.
118
+ const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```/);
119
+ const candidate = m ? m[1] : trimmed.startsWith("{") || trimmed.startsWith("[") ? trimmed : undefined;
120
+ if (candidate === undefined) return undefined;
121
+ try {
122
+ return JSON.parse(candidate);
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ // Spawn agy with bounded output collection + proper error handling.
129
+ async function spawnAgyRaw(args: string[], signal?: AbortSignal): Promise<string> {
130
+ // ponytail: agy is a Go binary in PATH (official installer); no shell wrapper.
131
+ const child = cp.spawn("agy", args, {
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ signal,
134
+ timeout: AGY_FETCH_TIMEOUT_MS + 5_000, // let agy report its own print timeout first
135
+ });
136
+
137
+ const stdout: Buffer[] = [];
138
+ const stderr: Buffer[] = [];
139
+ let stdoutBytes = 0;
140
+ let stderrBytes = 0;
141
+
142
+ child.stdout.on("data", (d: Buffer) => {
143
+ // Capture all bytes up to the cap, including the partial final chunk
144
+ // (mirrors pi-agy's appendBounded behavior).
145
+ if (stdoutBytes < AGY_MAX_OUTPUT_BYTES) {
146
+ stdout.push(d.subarray(0, AGY_MAX_OUTPUT_BYTES - stdoutBytes));
147
+ }
148
+ stdoutBytes += d.length;
149
+ });
150
+ child.stderr.on("data", (d: Buffer) => {
151
+ stderrBytes += d.length;
152
+ if (stderrBytes <= 16 * 1024) stderr.push(d);
153
+ });
154
+
155
+ return new Promise<string>((resolve, reject) => {
156
+ let settled = false;
157
+ const done = (fn: () => void) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ fn();
161
+ };
162
+
163
+ child.on("error", (err: Error) => {
164
+ done(() => {
165
+ if (signal?.aborted) reject(new Error("agy was cancelled"));
166
+ else if ((err as NodeJS.ErrnoException).code === "ENOENT") {
167
+ reject(new Error("Antigravity CLI (agy) not found in PATH. Install: curl -fsSL https://antigravity.google/cli/install.sh | bash"));
168
+ } else reject(new Error(`agy spawn failed: ${sanitizeError(err)}`));
169
+ });
170
+ });
171
+
172
+ child.on("close", (code: number | null, sig: string | null) => {
173
+ done(() => {
174
+ const out = Buffer.concat(stdout).toString("utf8");
175
+ const err = Buffer.concat(stderr).toString("utf8");
176
+
177
+ if (sig === "SIGTERM" || sig === "SIGKILL" || code === null) {
178
+ reject(new Error(`agy was cancelled (${sig || "timeout"})`));
179
+ } else if (code !== 0) {
180
+ reject(new Error(`agy exited with code ${code}: ${sanitizeError((err || out).slice(0, 1000).trim() || "(no output)")}`));
181
+ } else {
182
+ // stdout only — stderr may carry warnings that would corrupt JSON envelope parsing
183
+ resolve(out);
184
+ }
185
+ });
186
+ });
187
+ });
188
+ }
189
+
190
+ // Extract .response from agy's JSON envelope, fall back to raw text. Pure — tested without mocking.
191
+ export function parseAgyResponse(raw: string, maxChars: number): string {
192
+ let text = raw;
193
+ try {
194
+ const parsed = JSON.parse(raw);
195
+ if (typeof parsed.response === "string") text = parsed.response;
196
+ else if (parsed.response !== undefined) text = JSON.stringify(parsed.response);
197
+ } catch {
198
+ // not JSON — use raw
199
+ }
200
+ return text.slice(0, maxChars);
201
+ }
@@ -3,16 +3,18 @@
3
3
  // "static" → JSDOM+Readability (no external API)
4
4
  // "dynamic" → Firecrawl Scrape (JS rendering/structured JSON)
5
5
  // "full" → Crawl4AI markdown endpoint
6
- // "auto" static dynamic full
6
+ // "agy" agy (Gemini/Claude) native read_url — bot-protected/JS-heavy pages
7
+ // "auto" → static → dynamic → full → agy
7
8
 
8
9
  import { cwdFromContext, includeProjectEnv } from "./config";
9
10
  import { loadFirecrawlConfig, loadCrawl4aiConfig, type FirecrawlConfig, type Crawl4aiConfig } from "./config";
10
11
  import { fetchReadableContent } from "./content";
11
12
  import { firecrawlRequest, type FirecrawlResult } from "./firecrawl";
12
13
  import { fetchCrawl4aiMarkdown } from "./crawl4ai";
14
+ import { isAgyInstalled, extractViaAgy, parseAgyStructured } from "./agy";
13
15
  import { formatFirecrawlScrape, sanitizeError } from "./format";
14
16
 
15
- export type ExtractMode = "auto" | "static" | "dynamic" | "full";
17
+ export type ExtractMode = "auto" | "static" | "dynamic" | "full" | "agy";
16
18
  export type ExtractAttemptStatus = "success" | "empty" | "error";
17
19
 
18
20
  export interface ExtractParams {
@@ -142,13 +144,49 @@ async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>)
142
144
  function modesFor(params: ExtractParams): Array<Exclude<ExtractMode, "auto">> {
143
145
  const mode = params.mode ?? "auto";
144
146
  if (mode !== "auto") return [mode];
145
- return ["static", "dynamic", "full"];
147
+ return ["static", "dynamic", "full", "agy"];
146
148
  }
147
149
 
148
150
  async function runExtractor(mode: Exclude<ExtractMode, "auto">, params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
149
151
  if (mode === "static") return extractStatic(params);
150
152
  if (mode === "dynamic") return extractDynamic(params, ctx);
151
- return extractFull(params, ctx);
153
+ if (mode === "full") return extractFull(params, ctx);
154
+ return extractAgy(params);
155
+ }
156
+
157
+ async function extractAgy(params: ExtractParams): Promise<ExtractResult | null> {
158
+ if (!isAgyInstalled()) return null; // graceful skip — recorded as skipped attempt
159
+ const run = () => extractViaAgy({
160
+ url: params.url,
161
+ prompt: params.prompt,
162
+ schema: params.schema,
163
+ contentChars: params.content_chars,
164
+ signal: params.signal,
165
+ });
166
+ // ponytail: the model occasionally returns empty output (transient); one retry
167
+ // materially improves reliability at the cost of one extra spawn.
168
+ let markdown = await run();
169
+ if (!markdown.trim()) markdown = await run();
170
+ let structured: unknown;
171
+ // Match dynamic mode: prompt OR schema requests structured output.
172
+ if (params.prompt || params.schema !== undefined) {
173
+ structured = parseAgyStructured(markdown);
174
+ if (structured !== undefined) {
175
+ // Strip the JSON (fenced or bare) so the result is clean markdown; the
176
+ // structured payload is surfaced separately (rendered like dynamic mode).
177
+ const fenced = /```(?:json)?\s*[\s\S]*?```/.test(markdown);
178
+ let clean = fenced ? markdown.replace(/```(?:json)?\s*[\s\S]*?```/, "").trim() : "";
179
+ if (!fenced && markdown.trim().length > 0) {
180
+ // Bare JSON (no fence) — the whole output is JSON, so nothing remains.
181
+ clean = "";
182
+ }
183
+ // When the model returned only JSON, don't duplicate it as plain text body
184
+ // (it is already surfaced via structuredSection below).
185
+ const body = clean || "(Structured extraction only — see JSON below)";
186
+ return { title: "", markdown: body + structuredSection(structured), backend: "agy", structured };
187
+ }
188
+ }
189
+ return { title: "", markdown, backend: "agy" };
152
190
  }
153
191
 
154
192
  export async function extractWithDiagnostics(params: ExtractParams): Promise<ExtractDiagnostics> {
@@ -162,7 +200,8 @@ export async function extractWithDiagnostics(params: ExtractParams): Promise<Ext
162
200
  const result = await runExtractor(mode, params, ctx);
163
201
  if (isUseful(result, mode, explicit)) {
164
202
  if (!explicit && attempts.length > 0) {
165
- result.markdown = `[Extraction fell back to ${mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : "Crawl4AI (full browser mode)"}]\n\n${result.markdown}`;
203
+ const backendLabel = mode === "dynamic" ? "Firecrawl Scrape (dynamic mode)" : mode === "full" ? "Crawl4AI (full browser mode)" : "agy (model-backed browser)";
204
+ result.markdown = `[Extraction fell back to ${backendLabel}]\n\n${result.markdown}`;
166
205
  }
167
206
  attempts.push({ mode, backend: result.backend, status: "success", message: `Selected ${mode}`, contentLength: result.markdown.length });
168
207
  return { result, attempts, selectedMode: mode, fallbackUsed: attempts.length > 1 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,13 @@
59
59
  "@types/node": "^26.0.1",
60
60
  "@types/turndown": "^5.0.6",
61
61
  "chai": "^6.2.2",
62
- "mocha": "^11.7.6",
62
+ "mocha": "^11.8.0",
63
63
  "tsx": "^4.22.4"
64
+ },
65
+ "overrides": {
66
+ "serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",
67
+ "js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
68
+ "brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
69
+ "diff": "^8.0.3"
64
70
  }
65
71
  }
@@ -5,14 +5,14 @@ description: Web search, content extraction, site crawling, and page capture via
5
5
 
6
6
  # pi-web — Unified Web Tools
7
7
 
8
- Use the **7 unified tools** from the `pi-web` extension for all web-related tasks. These tools automatically select the best backend from SearXNG, Brave Search, Firecrawl, and Crawl4AI — you don't need to know which backend to use. Search selection is adaptive: broad discovery prefers self-hosted SearXNG, while precision-sensitive queries and inline content prefer Brave.
8
+ Use the **7 unified tools** from the `pi-web` extension for all web-related tasks. These tools automatically select the best backend from SearXNG, Brave Search, Firecrawl, Crawl4AI, and agy (when installed) — you don't need to know which backend to use. Search selection is adaptive: broad discovery prefers self-hosted SearXNG, while precision-sensitive queries and inline content prefer Brave.
9
9
 
10
10
  ## Quick Reference
11
11
 
12
12
  | Tool | Purpose | Auto-selection |
13
13
  |---|---|---|
14
14
  | `web_search` | Search the web for sources, docs, facts | SearXNG → Brave → Firecrawl |
15
- | `web_extract` | Extract readable content from a URL | Static (JSDOM) → Dynamic (Firecrawl) → Full (Crawl4AI) |
15
+ | `web_extract` | Extract readable content from a URL | Static (JSDOM) → Dynamic (Firecrawl) → Full (Crawl4AI) → agy (model-backed) |
16
16
  | `web_map` | Discover URLs from a site | Firecrawl Map (only option) |
17
17
  | `web_crawl` | Crawl multiple pages from a site | Light (Firecrawl) or Full (Crawl4AI) |
18
18
  | `web_screenshot` | Capture page screenshot as PNG | Crawl4AI (only option) |
@@ -35,7 +35,8 @@ What do you need?
35
35
  │ ├─ static page (blog, docs): mode=static (fastest, no API key)
36
36
  │ ├─ dynamic page (JS-rendered): mode=dynamic
37
37
  │ ├─ JS-heavy SPA: mode=full
38
- └─ auto (default): tries static > dynamic > full
38
+ ├─ bot-protected / blocked to scrapers: mode=agy (Gemini/Claude via agy)
39
+ │ └─ auto (default): tries static > dynamic > full > agy
39
40
 
40
41
  ├── Site URL discovery (find pages on a site)
41
42
  │ → web_map
@@ -78,6 +79,9 @@ What do you need?
78
79
  - ❌ Fails on bot-protected sites (Ansible docs, many CDN-backed doc sites). Falls through Crawl4AI in `auto` mode.
79
80
  - ✅ Supported: prompt-based JSON extraction, schema-based structured extraction.
80
81
  3. **full** (Crawl4AI headless browser) — handles all content types. **Resource-intensive** (launches a full headless browser). Use only when static and dynamic modes fail, or when explicitly needed.
82
+ 4. **agy** (Gemini/Claude via agy CLI) — last-resort fallback. Uses agy's native `read_url` web tool with a model-driven browser, so it can fetch pages that block Firecrawl/Crawl4AI (bot protection, anti-AI scraping). Requires the `agy` CLI installed and authenticated. Also supports prompt/schema-based structured extraction.
83
+
84
+ > **Requirement for agy mode**: install and authenticate the Antigravity CLI: `curl -fsSL https://antigravity.google/cli/install.sh | bash` then run `agy` once interactively. If agy is not installed, `auto` mode skips it silently; explicit `mode: "agy"` reports the install hint.
81
85
 
82
86
  ## Fallback Strategy
83
87
 
@@ -85,8 +89,9 @@ If one tool fails, try the next option in the chain:
85
89
 
86
90
  - **Search issues**: `web_search` auto-fallbacks and reports backend diagnostics. If all backends fail, configure at least one via env vars (check `web_status`).
87
91
  - **Extraction issues**: `web_extract` auto-fallbacks in `auto` mode. If all modes fail:
88
- 1. Try `web_screenshot` for a visual snapshot may work when extraction is blocked.
89
- 2. The page may require interactive login, CAPTCHA, or be a non-HTML resource.
92
+ 1. Try `mode: "agy"` explicitly agy's model-backed browser often gets pages that block Firecrawl/Crawl4AI.
93
+ 2. Try `web_screenshot` for a visual snapshot may work when extraction is blocked.
94
+ 3. The page may require interactive login, CAPTCHA, or be a non-HTML resource.
90
95
  - **Tool not found**: Ensure `pi-web` extension is installed (`pi install ./extensions/pi-web`).
91
96
 
92
97
  ## Cross-tool Decision Guide
@@ -95,6 +100,7 @@ If one tool fails, try the next option in the chain:
95
100
  |---|---|---|
96
101
  | A few specific pages from a site | `web_map` + `web_extract` on each URL | `web_crawl` (heavier than needed) |
97
102
  | Content from a JS-heavy page that fails in `auto` mode | `web_extract` with `mode: "full"` | Retrying `auto` mode repeatedly |
103
+ | Content from a bot-protected page that blocks Firecrawl/Crawl4AI | `web_extract` with `mode: "agy"` | Retrying `web_extract` with all modes |
98
104
  | A visual of a bot-protected page | `web_screenshot` | Retrying `web_extract` with all modes |
99
105
  | Content alongside search results | `web_search` with `include_content: true` (auto prefers Brave) or `backend: "brave"` | Search snippets alone |
100
106
  | Printable/archivable page | `web_pdf` | Taking a screenshot and converting |
@@ -105,4 +111,5 @@ If one tool fails, try the next option in the chain:
105
111
  - **Always cite source URLs** when using web content in answers.
106
112
  - `web_status` shows which backends are configured without printing secrets. For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth — check the `ready` field to see if Firecrawl is actually usable.
107
113
  - The `backend` and `mode` parameters give explicit control when auto-selection is not desired.
114
+ - `mode: "agy"` requires the `agy` CLI (Antigravity) installed and authenticated. `web_status` reports `agy.installed` so you can check availability without guessing.
108
115
  - Backend-specific config (API keys, URLs) comes from environment variables, not tool parameters. Use `web_status` to verify configuration.