@demigodmode/pi-web-agent 1.9.0 → 1.10.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/CHANGELOG.md CHANGED
@@ -18,6 +18,19 @@ The format is intentionally simple and release-oriented.
18
18
  ### Breaking
19
19
  - None.
20
20
 
21
+ ## [1.10.0] - 2026-08-25
22
+ ### Added
23
+ - The zero-config default has a safety net now. When DuckDuckGo blocks a search (which happens fast on VPS and datacenter IPs), web search falls back to Tavily's keyless endpoint, no account or API key needed, so a fresh install still returns results instead of an error. Set `PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1` if you'd rather it just fail. (#42)
24
+
25
+ ### Changed
26
+ - The keyless DuckDuckGo default is a lot harder to bot-wall. It sends real browser headers and retries once when a page looks blocked, instead of going out as an obvious script and getting bounced on the first request. (#42)
27
+
28
+ ### Fixed
29
+ - Blocked searches are labeled honestly. A bot-wall page comes back as "blocked" (which triggers the fallback) instead of "couldn't parse", including the 200-status pages that mix a no-results message with a bot-wall notice. (#42)
30
+
31
+ ### Breaking
32
+ - None.
33
+
21
34
  ## [1.9.0] - 2026-08-15
22
35
  ### Added
23
36
  - Search fanout. Ask a hard question and web_explore can now hit several of your configured search backends at once, dedupe the merged results, and rank the pages that more than one provider agreed on to the top. Off by default. Flip it to on or auto in Settings → Backends (auto only fans out when the first provider comes back thin, so easy queries stay cheap). Preview/verbose shows which providers ran and which got skipped. (#35)
package/README.md CHANGED
@@ -1,131 +1,121 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/demigodmode/pi-web-agent/main/docs/public/pi-web-agent-banner.png" alt="pi-web-agent: bounded web research for Pi" width="100%">
3
+ </p>
4
+
1
5
  # pi-web-agent
2
6
 
3
7
  [![CI](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml)
4
8
  [![npm version](https://img.shields.io/npm/v/@demigodmode/pi-web-agent)](https://www.npmjs.com/package/@demigodmode/pi-web-agent)
5
9
  [![Docs](https://img.shields.io/badge/docs-github%20pages-blue)](https://demigodmode.github.io/pi-web-agent/)
6
10
 
7
- `@demigodmode/pi-web-agent` is a Pi package for web access.
11
+ One public tool, `web_explore`, that does bounded web research for Pi: search, fetch, targeted browser rendering, ranking, and honest caveats, all behind a single call.
12
+
13
+ > Most agent web tooling blurs search, fetch, rendering, and synthesis into one vague thing. `pi-web-agent` keeps that boundary simple, and it is stricter about what it actually did: bot-check pages, narrow source sets, unreadable threads, and conflicting evidence show up as caveats instead of fake confidence.
14
+
15
+ ## What you get
16
+
17
+ - **One tool.** `web_explore` handles direct links, discovery, HTTP reads, targeted headless rendering, source ranking, source-quality checks, and caveats internally.
18
+ - **Reads the real content behind links.** Paste a GitHub, PDF, or YouTube URL and it pulls the actual thing (GitHub files/issues/PRs from the API, PDF text, YouTube transcripts), keyless. So "summarize this PDF" or "what does this repo do" works off the source, not the page shell.
19
+ - **Six search backends.** DuckDuckGo (keyless default), SearXNG, Brave, You.com, Exa, Tavily.
20
+ - **Optional search fanout.** Query several backends at once, dedupe, and rank pages that more than one provider agreed on to the top. Off by default; flip it to `on` or `auto`.
21
+ - **Honest by default.** Weak, narrow, blocked, or cautionary evidence gets flagged instead of dressed up as confidence.
22
+ - **Bounded output.** `compact` / `preview` / `verbose` transcript modes.
23
+ - **Zero-config to start.** Runs keyless out of the box (DuckDuckGo search, local browser, the built-in readers). Opt into hosted backends, fallback, search fanout, and per-tool output modes through config when you want more control.
8
24
 
9
- Most agent web tools blur search, fetch, browser rendering, and research into one vague thing. `pi-web-agent` exposes one public research tool, `web_explore`, and keeps search/fetch/headless work inside that bounded workflow.
25
+ ## Why pi-web-agent
10
26
 
11
- The point is keeping the model-facing boundary simple: ask `web_explore` to research a question, and it handles direct links, discovery, HTTP reads, targeted browser rendering, source ranking, source-quality checks, and caveats internally.
27
+ Compared to other web tooling for agents:
12
28
 
13
- That sounds obvious, but a lot of agent tooling gets fuzzy right there. This package is meant to be stricter about what it actually did and more willing to say when a read was not good enough to trust. Bot-check pages, narrow source sets, unreadable threads, and cautionary/conflicting evidence should show up as caveats instead of fake confidence.
29
+ - **Hands-off.** No curator, no browser windows to approve, no step that pops you out of your session. Ask `web_explore` once and the answer comes back with caveats. Nothing to babysit.
30
+ - **Keyless by default.** Search, page reads, and the GitHub/PDF/YouTube readers all work with no API keys. Add hosted providers only when you want them.
31
+ - **Bounded and honest.** Compact output by default, and it says when a read was not good enough instead of returning fake confidence.
14
32
 
15
33
  ## Install
16
34
 
17
- Compatibility notice: current `pi-web-agent` requires Pi 0.74+ because Pi packages moved to the `@earendil-works/*` scope. Update Pi before updating this package. If you are on an older Pi version, stay on `@demigodmode/pi-web-agent@0.6.x` until Pi is updated.
35
+ > `pi-web-agent` requires Pi 0.74+ (Pi packages moved to the `@earendil-works/*` scope). Update Pi before updating this package. On older Pi, stay on `@demigodmode/pi-web-agent@0.6.x`.
18
36
 
19
37
  ```bash
20
38
  pi install npm:@demigodmode/pi-web-agent
21
39
  ```
22
40
 
23
- After installing, reload or restart Pi. Run `/web-agent` for the action menu, or `/web-agent doctor` to check whether the package loaded cleanly and which web backends are configured.
41
+ Reload or restart Pi after installing, then:
24
42
 
25
- Headless rendering first tries a detectable Chromium-family browser: Chrome, Chromium, Edge, or Brave. If none is found, it falls back to Playwright-managed Chromium and still launches headless. Firefox/Safari-only systems can still use search and plain HTTP reads; browser-rendered fallback uses Chromium.
26
-
27
- Later on, update installed packages with:
28
-
29
- ```bash
30
- pi update --extensions
43
+ ```text
44
+ /web-agent doctor # check it loaded and show configured backends
45
+ /web-agent # action menu
31
46
  ```
32
47
 
33
- ## Docs
48
+ Update later with `pi update --extensions`.
34
49
 
35
- Docs site:
50
+ **Browser rendering:** headless first tries a detected Chromium-family browser (Chrome, Chromium, Edge, Brave). If none is found, it falls back to Playwright-managed Chromium. Firefox/Safari-only systems still get search and plain HTTP reads.
36
51
 
37
- - https://demigodmode.github.io/pi-web-agent/
52
+ ## Usage
38
53
 
39
- Work on the docs locally:
54
+ Ask `web_explore` a web question:
40
55
 
41
- ```bash
42
- npm run docs:dev
43
- ```
56
+ > Find current docs and discussions on configuring Vitest coverage with the v8 provider.
44
57
 
45
- Build the docs:
58
+ Or hand it a link to read:
46
59
 
47
- ```bash
48
- npm run docs:build
49
- ```
60
+ > Summarize this PDF: https://arxiv.org/pdf/1706.03762
50
61
 
51
- ## Presentation modes
62
+ If a pass comes back thin, call `web_explore` again with a narrower query.
52
63
 
53
- `pi-web-agent` renders web tool output in one visible mode at a time:
64
+ ## Backends
54
65
 
55
- - `compact` — short summary, default everywhere
56
- - `preview` — slightly richer bounded view
57
- - `verbose` — fuller bounded view
66
+ Defaults are DuckDuckGo search, plain HTTP fetch, and local-browser headless. Switch providers from `/web-agent settings → Backends`. API keys stay in environment variables, never in config files.
58
67
 
59
- See the `v0.3.0` release notes for a before/after of the transcript cleanup:
68
+ | Backend | Role | Enable with |
69
+ | --- | --- | --- |
70
+ | DuckDuckGo | search (default) | nothing, keyless |
71
+ | SearXNG | search (self-hosted) | base URL |
72
+ | Brave | search (hosted) | `PI_WEB_AGENT_BRAVE_API_KEY` |
73
+ | You.com | search (hosted) | `YDC_API_KEY` |
74
+ | Exa | search (hosted) | `EXA_API_KEY` |
75
+ | Tavily | search (hosted) | `TAVILY_API_KEY` |
76
+ | Firecrawl | fetch (self-hosted) | base URL + `PI_WEB_AGENT_FIRECRAWL_API_KEY` |
77
+ | GitHub reader | content | `GITHUB_TOKEN` (optional, raises the rate limit) |
60
78
 
61
- - https://github.com/demigodmode/pi-web-agent/releases/tag/v0.3.0
79
+ Full config shape (fallback, SearXNG/Firecrawl options, fanout): see the [self-hosted backends docs](https://demigodmode.github.io/pi-web-agent/self-hosted-backends).
62
80
 
63
81
  ## Settings
64
82
 
65
- Primary UI:
66
-
67
83
  ```text
68
- /web-agent settings
69
- ```
70
-
71
- Helper commands:
72
-
73
- ```text
74
- /web-agent doctor
75
- /web-agent show
84
+ /web-agent settings # main UI
85
+ /web-agent doctor # health check
86
+ /web-agent show # effective config
76
87
  /web-agent changelog
77
- /web-agent reset project
78
- /web-agent reset global
79
- /web-agent mode preview
80
- /web-agent mode web_explore verbose
81
- /web-agent mode web_explore inherit
88
+ /web-agent mode web_explore verbose # per-tool presentation mode
89
+ /web-agent reset project | global
82
90
  ```
83
91
 
84
- Config files:
92
+ Config is JSON, and project config overrides global:
85
93
 
86
94
  ```text
87
95
  Global: ~/.pi/agent/extensions/pi-web-agent/config.json
88
96
  Project: .pi/extensions/pi-web-agent/config.json
89
97
  ```
90
98
 
91
- Precedence:
92
-
93
- - built-in defaults
94
- - global config
95
- - project config
96
-
97
- Project config overrides global config.
98
-
99
- Example:
100
-
101
99
  ```json
102
100
  {
103
101
  "presentation": {
104
102
  "defaultMode": "compact",
105
- "tools": {
106
- "web_explore": { "mode": "verbose" }
107
- }
103
+ "tools": { "web_explore": { "mode": "verbose" } }
108
104
  }
109
105
  }
110
106
  ```
111
107
 
112
- Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local-browser headless fallback with managed Chromium fallback configured. If you have a Brave Search or You.com API key, either can be selected as a hosted search backend while `web_explore` still handles page reading, ranking, and caveats itself.
108
+ Presentation modes:
113
109
 
114
- Search backends: DuckDuckGo (default), SearXNG (self-hosted), Brave Search (hosted), You.com (hosted).
110
+ - `compact`: short summary, the default everywhere
111
+ - `preview`: slightly richer bounded view
112
+ - `verbose`: fuller bounded view
115
113
 
116
- Backend settings can be changed from:
117
-
118
- ```text
119
- /web-agent settings
120
- ```
121
-
122
- Choose **Backends** to edit search/fetch providers, fallback behavior, and SearXNG or Firecrawl base URLs interactively. Brave Search uses `PI_WEB_AGENT_BRAVE_API_KEY`, You.com uses `YDC_API_KEY`, Exa uses `EXA_API_KEY`, and Tavily uses `TAVILY_API_KEY`. Firecrawl API keys should also stay in environment variables rather than being written into config files.
123
-
124
- For the full backend config shape, including SearXNG, Brave, You.com, Firecrawl, and fallback behavior, see:
114
+ ## Docs
125
115
 
126
- - https://demigodmode.github.io/pi-web-agent/self-hosted-backends
116
+ Full docs: <https://demigodmode.github.io/pi-web-agent/>. Work on them locally with `npm run docs:dev`.
127
117
 
128
- ## Local development
118
+ ## Development
129
119
 
130
120
  ```bash
131
121
  npm install
@@ -134,10 +124,8 @@ npm run lint
134
124
  npm run build
135
125
  ```
136
126
 
137
- For local Pi work, this repo includes `.pi/extensions/pi-web-agent.ts`.
138
-
139
- If Pi is already running, use `/reload` after changes.
127
+ Local Pi work uses `.pi/extensions/pi-web-agent.ts`; run `/reload` after changes.
140
128
 
141
129
  ## License
142
130
 
143
- AGPL-3.0-only. See `LICENSE`.
131
+ AGPL-3.0-only. See [LICENSE](LICENSE).
@@ -54,7 +54,10 @@ function withSearchFallback(primary, fallback, fallbackFrom) {
54
54
  metadata: {
55
55
  ...second.metadata,
56
56
  fallbackFrom,
57
- fallbackReason: first.error?.message ?? `${fallbackFrom} search failed.`
57
+ fallbackReason: first.error?.message ?? `${fallbackFrom} search failed.`,
58
+ // Keep the primary's fanout provenance (which providers were tried/skipped) even though
59
+ // the answer came from the fallback backend.
60
+ ...(first.metadata.fanout ? { fanout: first.metadata.fanout } : {})
58
61
  }
59
62
  };
60
63
  return { ...result, presentation: buildSearchPresentation(result) };
@@ -149,6 +152,14 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
149
152
  mode: fanoutConfig.mode
150
153
  });
151
154
  }
155
+ // Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
156
+ // it wraps whatever search ended up being (plain DDG or the fanout set) so a total failure
157
+ // still has somewhere to go. Opt out with PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1.
158
+ const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
159
+ const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
160
+ if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
161
+ search = withSearchFallback(search, createTavilySearch({ keyless: true }), 'duckduckgo');
162
+ }
152
163
  const httpFetch = createHttpFetch();
153
164
  let fetchPage = config.fetch.provider === 'firecrawl'
154
165
  ? config.fetch.baseUrl
@@ -5,5 +5,14 @@ export type ParsedDuckDuckGoResults = {
5
5
  hasResultContainers: boolean;
6
6
  };
7
7
  export declare function buildSearchUrl(query: string): string;
8
- export declare function fetchDuckDuckGoHtml(query: string): Promise<string>;
8
+ export declare const DUCKDUCKGO_HEADERS: {
9
+ readonly 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
10
+ readonly Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
11
+ readonly 'Accept-Language': "en-US,en;q=0.9";
12
+ };
13
+ export declare function fetchDuckDuckGoHtml(query: string, { fetchImpl, retries, sleep }?: {
14
+ fetchImpl?: typeof fetch;
15
+ retries?: number;
16
+ sleep?: (ms: number) => Promise<void>;
17
+ }): Promise<string>;
9
18
  export declare function parseDuckDuckGoResults(html: string): ParsedDuckDuckGoResults;
@@ -21,12 +21,30 @@ export function buildSearchUrl(query) {
21
21
  const params = new URLSearchParams({ q: query });
22
22
  return `https://html.duckduckgo.com/html/?${params.toString()}`;
23
23
  }
24
- export async function fetchDuckDuckGoHtml(query) {
25
- const response = await fetch(buildSearchUrl(query));
26
- if (!response.ok) {
27
- throw new Error(`DuckDuckGo request failed with ${response.status}`);
24
+ export const DUCKDUCKGO_HEADERS = {
25
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
26
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
27
+ 'Accept-Language': 'en-US,en;q=0.9'
28
+ };
29
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
30
+ export async function fetchDuckDuckGoHtml(query, { fetchImpl = fetch, retries = 1, sleep = defaultSleep } = {}) {
31
+ let lastError;
32
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
33
+ try {
34
+ const response = await fetchImpl(buildSearchUrl(query), { headers: { ...DUCKDUCKGO_HEADERS } });
35
+ if (!response.ok) {
36
+ throw new Error(`DuckDuckGo request failed with ${response.status}`);
37
+ }
38
+ return response.text();
39
+ }
40
+ catch (error) {
41
+ lastError = error;
42
+ if (attempt < retries) {
43
+ await sleep(500);
44
+ }
45
+ }
28
46
  }
29
- return response.text();
47
+ throw lastError instanceof Error ? lastError : new Error('DuckDuckGo request failed');
30
48
  }
31
49
  export function parseDuckDuckGoResults(html) {
32
50
  const $ = cheerio.load(html);
@@ -1,6 +1,7 @@
1
1
  import type { WebSearchResponse } from '../types.js';
2
- export declare function createTavilySearchTool({ apiKey, fetchImpl }: {
2
+ export declare function createTavilySearchTool({ apiKey, keyless, fetchImpl }: {
3
3
  apiKey?: string;
4
+ keyless?: boolean;
4
5
  fetchImpl?: typeof fetch;
5
6
  }): ({ query }: {
6
7
  query: string;
@@ -17,7 +17,7 @@ function normalizeResults(response) {
17
17
  ];
18
18
  });
19
19
  }
20
- export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
20
+ export function createTavilySearchTool({ apiKey, keyless = false, fetchImpl = fetch }) {
21
21
  return async function tavilySearch({ query }) {
22
22
  const normalizedQuery = query.trim();
23
23
  if (!normalizedQuery) {
@@ -28,7 +28,7 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
28
28
  error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
29
29
  });
30
30
  }
31
- if (!apiKey?.trim()) {
31
+ if (!apiKey?.trim() && !keyless) {
32
32
  return resultWithPresentation({
33
33
  status: 'error',
34
34
  results: [],
@@ -45,7 +45,9 @@ export function createTavilySearchTool({ apiKey, fetchImpl = fetch }) {
45
45
  headers: {
46
46
  Accept: 'application/json',
47
47
  'Content-Type': 'application/json',
48
- Authorization: `Bearer ${apiKey}`
48
+ ...(apiKey?.trim()
49
+ ? { Authorization: `Bearer ${apiKey}` }
50
+ : { 'X-Tavily-Access-Mode': 'keyless' })
49
51
  },
50
52
  body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
51
53
  });
@@ -27,7 +27,11 @@ function htmlLooksBlocked(html) {
27
27
  normalized.includes('challenge') ||
28
28
  normalized.includes('verify you are human') ||
29
29
  normalized.includes('are you a robot') ||
30
- normalized.includes('unusual traffic'));
30
+ normalized.includes('unusual traffic') ||
31
+ normalized.includes('automated requests') ||
32
+ normalized.includes('automated queries') ||
33
+ normalized.includes('detected unusual') ||
34
+ normalized.includes('too many requests'));
31
35
  }
32
36
  export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache = createTtlCache({ ttlMs: 30_000 }) } = {}) {
33
37
  return async function webSearch({ query }) {
@@ -57,8 +61,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
57
61
  };
58
62
  }
59
63
  try {
60
- const html = await searchHtml(normalizedQuery);
61
- const parsed = parseDuckDuckGoResults(html);
64
+ let html = await searchHtml(normalizedQuery);
65
+ let parsed = parseDuckDuckGoResults(html);
66
+ // A 200-OK bot-wall reads as a successful fetch, so the fetch-layer retry never sees it.
67
+ // Give a page that looks blocked one more shot here before we classify it.
68
+ if (parsed.results.length === 0 && htmlLooksBlocked(html)) {
69
+ html = await searchHtml(normalizedQuery);
70
+ parsed = parseDuckDuckGoResults(html);
71
+ }
62
72
  if (parsed.results.length > 0) {
63
73
  const result = {
64
74
  status: 'ok',
@@ -71,14 +81,16 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
71
81
  presentation: buildSearchPresentation(result)
72
82
  };
73
83
  }
74
- if (parsed.noResults) {
84
+ // Check for a bot-wall before "no results": a page can carry both markers, and BLOCKED is
85
+ // the honest call since it routes to the fallback instead of a dead end.
86
+ if (htmlLooksBlocked(html)) {
75
87
  const result = {
76
88
  status: 'error',
77
89
  results: [],
78
90
  metadata: { backend: 'duckduckgo', cacheHit: false },
79
91
  error: {
80
- code: 'NO_RESULTS',
81
- message: 'DuckDuckGo returned no usable results for this query.'
92
+ code: 'BLOCKED',
93
+ message: 'DuckDuckGo search appears to be blocked or rate limited.'
82
94
  }
83
95
  };
84
96
  return {
@@ -86,14 +98,14 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
86
98
  presentation: buildSearchPresentation(result)
87
99
  };
88
100
  }
89
- if (htmlLooksBlocked(html)) {
101
+ if (parsed.noResults) {
90
102
  const result = {
91
103
  status: 'error',
92
104
  results: [],
93
105
  metadata: { backend: 'duckduckgo', cacheHit: false },
94
106
  error: {
95
- code: 'BLOCKED',
96
- message: 'DuckDuckGo search appears to be blocked or rate limited.'
107
+ code: 'NO_RESULTS',
108
+ message: 'DuckDuckGo returned no usable results for this query.'
97
109
  }
98
110
  };
99
111
  return {
package/dist/types.d.ts CHANGED
@@ -20,7 +20,7 @@ export type ToolError = {
20
20
  export type SearchMetadata = {
21
21
  backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
22
22
  cacheHit: boolean;
23
- fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
23
+ fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
24
24
  fallbackReason?: string;
25
25
  fanout?: FanoutMetadata;
26
26
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",