@demigodmode/pi-web-agent 1.8.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 +29 -1
- package/README.md +64 -76
- package/dist/backends/config.d.ts +8 -0
- package/dist/backends/config.js +45 -0
- package/dist/backends/doctor.js +33 -0
- package/dist/backends/factory.js +48 -2
- package/dist/commands/web-agent-config.js +63 -6
- package/dist/extension.js +53 -12
- package/dist/orchestration/direct-url.js +2 -25
- package/dist/orchestration/evidence-quality.d.ts +1 -0
- package/dist/orchestration/evidence-quality.js +4 -2
- package/dist/orchestration/evidence-ranker.js +2 -0
- package/dist/orchestration/index.d.ts +2 -0
- package/dist/orchestration/research-orchestrator.d.ts +3 -1
- package/dist/orchestration/research-orchestrator.js +63 -6
- package/dist/orchestration/research-types.d.ts +6 -1
- package/dist/orchestration/research-worker.js +26 -3
- package/dist/orchestration/url.d.ts +6 -0
- package/dist/orchestration/url.js +34 -0
- package/dist/presentation/explore-presentation.js +12 -4
- package/dist/presentation/search-presentation.js +14 -2
- package/dist/readers/github-reader.js +3 -2
- package/dist/readers/limits.d.ts +3 -0
- package/dist/readers/limits.js +3 -0
- package/dist/readers/pdf-reader.js +3 -2
- package/dist/readers/youtube-reader.js +3 -2
- package/dist/search/duckduckgo.d.ts +10 -1
- package/dist/search/duckduckgo.js +23 -5
- package/dist/search/fanout.d.ts +14 -0
- package/dist/search/fanout.js +118 -0
- package/dist/search/tavily.d.ts +2 -1
- package/dist/search/tavily.js +5 -3
- package/dist/tools/web-explore.d.ts +2 -0
- package/dist/tools/web-search.js +21 -9
- package/dist/types.d.ts +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,9 +18,37 @@ 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
|
+
|
|
34
|
+
## [1.9.0] - 2026-08-15
|
|
35
|
+
### Added
|
|
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)
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- None.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- web_explore can finally summarize a link you hand it. Paste a GitHub file/issue/PR, a PDF, or a YouTube URL and ask for a summary: it now reads the actual content (raw source, extracted PDF text, the video transcript) and gives the model the whole thing. 1.8.0 added these readers but then squeezed everything down to a ~180-char snippet before the model ever saw it, so summaries were basically guesswork. Now they're grounded in the real text (long content still capped around 24k characters). (#40, #41)
|
|
43
|
+
- Fanout won't stall on a dead provider anymore. Each one gets an 8s timeout, so an unreachable self-hosted SearXNG can't hang a whole research pass. (#35)
|
|
44
|
+
- Fanout only offers and queries the providers you've actually set up (keyless DuckDuckGo, SearXNG when you give it a URL, the hosted ones only when their key is present) instead of acting like all six are on. (#35)
|
|
45
|
+
|
|
46
|
+
### Breaking
|
|
47
|
+
- None.
|
|
48
|
+
|
|
21
49
|
## [1.8.0] - 2026-08-14
|
|
22
50
|
### Added
|
|
23
|
-
- Read the content behind GitHub, PDF, and YouTube links directly instead of the page shell. GitHub files, issues and PRs come from the API/raw endpoints (optional `GITHUB_TOKEN` raises the rate limit), PDFs are parsed with unpdf, and YouTube links return the transcript. All keyless. Scanned PDFs and caption-less videos are caveated rather than failing. (#39, #40, #41)
|
|
51
|
+
- Read the content behind GitHub, PDF, and YouTube links directly instead of the page shell. GitHub files, issues and PRs come from the API/raw endpoints (optional `GITHUB_TOKEN` raises the rate limit), PDFs are parsed with unpdf, and YouTube links return the transcript (long content capped around 24k characters). All keyless. Scanned PDFs and caption-less videos are caveated rather than failing. (#39, #40, #41)
|
|
24
52
|
|
|
25
53
|
### Changed
|
|
26
54
|
- None.
|
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
|
[](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml)
|
|
4
8
|
[](https://www.npmjs.com/package/@demigodmode/pi-web-agent)
|
|
5
9
|
[](https://demigodmode.github.io/pi-web-agent/)
|
|
6
10
|
|
|
7
|
-
|
|
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
|
-
|
|
25
|
+
## Why pi-web-agent
|
|
10
26
|
|
|
11
|
-
|
|
27
|
+
Compared to other web tooling for agents:
|
|
12
28
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
Reload or restart Pi after installing, then:
|
|
24
42
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
48
|
+
Update later with `pi update --extensions`.
|
|
34
49
|
|
|
35
|
-
|
|
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
|
-
|
|
52
|
+
## Usage
|
|
38
53
|
|
|
39
|
-
|
|
54
|
+
Ask `web_explore` a web question:
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
npm run docs:dev
|
|
43
|
-
```
|
|
56
|
+
> Find current docs and discussions on configuring Vitest coverage with the v8 provider.
|
|
44
57
|
|
|
45
|
-
|
|
58
|
+
Or hand it a link to read:
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
npm run docs:build
|
|
49
|
-
```
|
|
60
|
+
> Summarize this PDF: https://arxiv.org/pdf/1706.03762
|
|
50
61
|
|
|
51
|
-
|
|
62
|
+
If a pass comes back thin, call `web_explore` again with a narrower query.
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
## Backends
|
|
54
65
|
|
|
55
|
-
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
108
|
+
Presentation modes:
|
|
113
109
|
|
|
114
|
-
|
|
110
|
+
- `compact`: short summary, the default everywhere
|
|
111
|
+
- `preview`: slightly richer bounded view
|
|
112
|
+
- `verbose`: fuller bounded view
|
|
115
113
|
|
|
116
|
-
|
|
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
|
-
|
|
116
|
+
Full docs: <https://demigodmode.github.io/pi-web-agent/>. Work on them locally with `npm run docs:dev`.
|
|
127
117
|
|
|
128
|
-
##
|
|
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
|
-
|
|
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
|
|
131
|
+
AGPL-3.0-only. See [LICENSE](LICENSE).
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import type { FanoutMode, SearchProviderName } from '../types.js';
|
|
1
2
|
export type SearxngOptions = {
|
|
2
3
|
categories?: string[];
|
|
3
4
|
language?: string;
|
|
4
5
|
safesearch?: 0 | 1 | 2;
|
|
5
6
|
};
|
|
7
|
+
export type FanoutConfig = {
|
|
8
|
+
mode: FanoutMode;
|
|
9
|
+
providers?: SearchProviderName[];
|
|
10
|
+
};
|
|
6
11
|
export type FirecrawlOptions = {
|
|
7
12
|
formats?: string[];
|
|
8
13
|
onlyMainContent?: boolean;
|
|
@@ -12,6 +17,7 @@ export type SearchBackendConfig = {
|
|
|
12
17
|
baseUrl?: string;
|
|
13
18
|
fallback?: 'duckduckgo';
|
|
14
19
|
options?: SearxngOptions;
|
|
20
|
+
fanout?: FanoutConfig;
|
|
15
21
|
};
|
|
16
22
|
export type FetchBackendConfig = {
|
|
17
23
|
provider: 'http' | 'firecrawl';
|
|
@@ -40,6 +46,7 @@ export type BackendConfigFile = {
|
|
|
40
46
|
baseUrl?: unknown;
|
|
41
47
|
fallback?: unknown;
|
|
42
48
|
options?: unknown;
|
|
49
|
+
fanout?: unknown;
|
|
43
50
|
};
|
|
44
51
|
fetch?: {
|
|
45
52
|
provider?: unknown;
|
|
@@ -54,6 +61,7 @@ export type BackendConfigFile = {
|
|
|
54
61
|
};
|
|
55
62
|
};
|
|
56
63
|
export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
|
|
64
|
+
export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
|
|
57
65
|
export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
|
|
58
66
|
export declare function validateBackendConfig(config: BackendConfig): string[];
|
|
59
67
|
export declare function mergeBackendConfigLayers(...layers: Array<BackendConfig | BackendConfigOverride | undefined>): BackendConfig;
|
package/dist/backends/config.js
CHANGED
|
@@ -35,6 +35,38 @@ function extractFirecrawlOptions(value) {
|
|
|
35
35
|
options.onlyMainContent = raw.onlyMainContent;
|
|
36
36
|
return Object.keys(options).length > 0 ? options : undefined;
|
|
37
37
|
}
|
|
38
|
+
const PROVIDER_NAMES = ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily'];
|
|
39
|
+
export function usableSearchProviders(search, env = process.env) {
|
|
40
|
+
// Match the provider implementations, which treat a blank/whitespace key as unconfigured.
|
|
41
|
+
const usable = ['duckduckgo']; // keyless, always usable
|
|
42
|
+
if (search.baseUrl?.trim())
|
|
43
|
+
usable.push('searxng');
|
|
44
|
+
if (env.PI_WEB_AGENT_BRAVE_API_KEY?.trim())
|
|
45
|
+
usable.push('brave');
|
|
46
|
+
if (env.YDC_API_KEY?.trim())
|
|
47
|
+
usable.push('youcom');
|
|
48
|
+
if (env.EXA_API_KEY?.trim())
|
|
49
|
+
usable.push('exa');
|
|
50
|
+
if (env.TAVILY_API_KEY?.trim())
|
|
51
|
+
usable.push('tavily');
|
|
52
|
+
return usable;
|
|
53
|
+
}
|
|
54
|
+
function extractFanoutConfig(value) {
|
|
55
|
+
if (!value || typeof value !== 'object')
|
|
56
|
+
return undefined;
|
|
57
|
+
const raw = value;
|
|
58
|
+
if (raw.mode !== 'off' && raw.mode !== 'on' && raw.mode !== 'auto')
|
|
59
|
+
return undefined;
|
|
60
|
+
const config = { mode: raw.mode };
|
|
61
|
+
if (Array.isArray(raw.providers)) {
|
|
62
|
+
const providers = raw.providers.filter((p) => typeof p === 'string' && PROVIDER_NAMES.includes(p));
|
|
63
|
+
if (providers.length !== raw.providers.length)
|
|
64
|
+
return undefined; // fail loud on any invalid entry
|
|
65
|
+
if (providers.length > 0)
|
|
66
|
+
config.providers = providers;
|
|
67
|
+
}
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
38
70
|
export function extractBackendConfigOverride(file) {
|
|
39
71
|
const backends = file?.backends;
|
|
40
72
|
const override = {};
|
|
@@ -58,6 +90,10 @@ export function extractBackendConfigOverride(file) {
|
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
}
|
|
93
|
+
const fanout = extractFanoutConfig(backends?.search?.fanout);
|
|
94
|
+
if (fanout) {
|
|
95
|
+
override.search = { ...(override.search ?? {}), fanout };
|
|
96
|
+
}
|
|
61
97
|
if (backends?.fetch?.provider === 'http' || backends?.fetch?.provider === 'firecrawl') {
|
|
62
98
|
override.fetch = { provider: backends.fetch.provider };
|
|
63
99
|
if (typeof backends.fetch.baseUrl === 'string') {
|
|
@@ -106,6 +142,15 @@ export function validateBackendConfig(config) {
|
|
|
106
142
|
if (config.fetch.options?.formats && config.fetch.options.formats.length === 0) {
|
|
107
143
|
issues.push('fetch options.formats must contain at least one format when provided');
|
|
108
144
|
}
|
|
145
|
+
const fanout = config.search.fanout;
|
|
146
|
+
if (fanout) {
|
|
147
|
+
if (fanout.mode !== 'off' && fanout.mode !== 'on' && fanout.mode !== 'auto') {
|
|
148
|
+
issues.push('search fanout.mode must be off, on, or auto');
|
|
149
|
+
}
|
|
150
|
+
if (fanout.providers?.includes('searxng') && !config.search.baseUrl) {
|
|
151
|
+
issues.push('search fanout with searxng requires backends.search.baseUrl');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
109
154
|
return issues;
|
|
110
155
|
}
|
|
111
156
|
function mergeSearchConfig(current, override) {
|
package/dist/backends/doctor.js
CHANGED
|
@@ -208,6 +208,39 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
208
208
|
if (config.search.fallback) {
|
|
209
209
|
lines.push(`search fallback: ${config.search.fallback}`);
|
|
210
210
|
}
|
|
211
|
+
if (config.search.fanout && config.search.fanout.mode !== 'off') {
|
|
212
|
+
const providers = config.search.fanout.providers || ['duckduckgo', 'searxng', 'brave', 'youcom', 'exa', 'tavily'];
|
|
213
|
+
lines.push(`search fanout: ${config.search.fanout.mode} (${providers.join(', ')})`);
|
|
214
|
+
for (const provider of providers) {
|
|
215
|
+
if (provider === 'searxng' && !config.search.baseUrl) {
|
|
216
|
+
lines.push('search fanout provider searxng warning (missing baseUrl)');
|
|
217
|
+
}
|
|
218
|
+
else if (provider === 'brave') {
|
|
219
|
+
const apiKey = process.env.PI_WEB_AGENT_BRAVE_API_KEY;
|
|
220
|
+
if (!apiKey?.trim()) {
|
|
221
|
+
lines.push('search fanout provider brave warning (missing PI_WEB_AGENT_BRAVE_API_KEY)');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (provider === 'youcom') {
|
|
225
|
+
const apiKey = process.env.YDC_API_KEY;
|
|
226
|
+
if (!apiKey?.trim()) {
|
|
227
|
+
lines.push('search fanout provider youcom warning (missing YDC_API_KEY)');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (provider === 'exa') {
|
|
231
|
+
const apiKey = process.env.EXA_API_KEY;
|
|
232
|
+
if (!apiKey?.trim()) {
|
|
233
|
+
lines.push('search fanout provider exa warning (missing EXA_API_KEY)');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (provider === 'tavily') {
|
|
237
|
+
const apiKey = process.env.TAVILY_API_KEY;
|
|
238
|
+
if (!apiKey?.trim()) {
|
|
239
|
+
lines.push('search fanout provider tavily warning (missing TAVILY_API_KEY)');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
211
244
|
if (config.fetch.provider === 'http') {
|
|
212
245
|
lines.push('fetch backend: http');
|
|
213
246
|
}
|
package/dist/backends/factory.js
CHANGED
|
@@ -4,12 +4,13 @@ import { createYouComSearchTool } from '../search/youcom.js';
|
|
|
4
4
|
import { createExaSearchTool } from '../search/exa.js';
|
|
5
5
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
6
6
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
7
|
+
import { createFanoutSearch } from '../search/fanout.js';
|
|
7
8
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
8
9
|
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
9
10
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
10
11
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
11
12
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
12
|
-
import { DEFAULT_BACKEND_CONFIG } from './config.js';
|
|
13
|
+
import { DEFAULT_BACKEND_CONFIG, usableSearchProviders } from './config.js';
|
|
13
14
|
import { createSpecialContentResolver } from '../readers/resolver.js';
|
|
14
15
|
import { createGithubReader } from '../readers/github-reader.js';
|
|
15
16
|
import { createPdfReader } from '../readers/pdf-reader.js';
|
|
@@ -53,7 +54,10 @@ function withSearchFallback(primary, fallback, fallbackFrom) {
|
|
|
53
54
|
metadata: {
|
|
54
55
|
...second.metadata,
|
|
55
56
|
fallbackFrom,
|
|
56
|
-
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 } : {})
|
|
57
61
|
}
|
|
58
62
|
};
|
|
59
63
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
@@ -86,6 +90,25 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
86
90
|
const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
|
|
87
91
|
const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
|
|
88
92
|
const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
|
|
93
|
+
function buildProviderSearch(name) {
|
|
94
|
+
switch (name) {
|
|
95
|
+
case 'searxng':
|
|
96
|
+
return config.search.baseUrl
|
|
97
|
+
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
|
|
98
|
+
: invalidSearxngSearch();
|
|
99
|
+
case 'brave':
|
|
100
|
+
return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY });
|
|
101
|
+
case 'youcom':
|
|
102
|
+
return createYouComSearch({ apiKey: process.env.YDC_API_KEY });
|
|
103
|
+
case 'exa':
|
|
104
|
+
return createExaSearch({ apiKey: process.env.EXA_API_KEY });
|
|
105
|
+
case 'tavily':
|
|
106
|
+
return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
|
|
107
|
+
case 'duckduckgo':
|
|
108
|
+
default:
|
|
109
|
+
return createDuckDuckGoSearch();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
89
112
|
let search = config.search.provider === 'searxng'
|
|
90
113
|
? config.search.baseUrl
|
|
91
114
|
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
|
|
@@ -114,6 +137,29 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
114
137
|
if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
|
|
115
138
|
search = withSearchFallback(search, createDuckDuckGoSearch(), 'tavily');
|
|
116
139
|
}
|
|
140
|
+
const fanoutConfig = config.search.fanout;
|
|
141
|
+
if (fanoutConfig && fanoutConfig.mode !== 'off') {
|
|
142
|
+
const baseNames = fanoutConfig.providers && fanoutConfig.providers.length > 0
|
|
143
|
+
? fanoutConfig.providers
|
|
144
|
+
: usableSearchProviders(config.search);
|
|
145
|
+
// A configured DuckDuckGo fallback must still be honored under fanout: fold it into the set.
|
|
146
|
+
const providerNames = config.search.fallback === 'duckduckgo' && !baseNames.includes('duckduckgo')
|
|
147
|
+
? [...baseNames, 'duckduckgo']
|
|
148
|
+
: baseNames;
|
|
149
|
+
const ordered = [config.search.provider, ...providerNames.filter((n) => n !== config.search.provider)].filter((n, i, arr) => arr.indexOf(n) === i);
|
|
150
|
+
search = createFanoutSearch({
|
|
151
|
+
providers: ordered.map((name) => ({ name, search: buildProviderSearch(name) })),
|
|
152
|
+
mode: fanoutConfig.mode
|
|
153
|
+
});
|
|
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
|
+
}
|
|
117
163
|
const httpFetch = createHttpFetch();
|
|
118
164
|
let fetchPage = config.fetch.provider === 'firecrawl'
|
|
119
165
|
? config.fetch.baseUrl
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig } from '../backends/config.js';
|
|
1
|
+
import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
|
|
2
2
|
import { checkBackendHealth } from '../backends/doctor.js';
|
|
3
3
|
import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
|
|
@@ -20,7 +20,10 @@ function cloneBackendConfig(config) {
|
|
|
20
20
|
return {
|
|
21
21
|
search: {
|
|
22
22
|
...config.search,
|
|
23
|
-
options: config.search.options ? { ...config.search.options } : undefined
|
|
23
|
+
options: config.search.options ? { ...config.search.options } : undefined,
|
|
24
|
+
fanout: config.search.fanout
|
|
25
|
+
? { mode: config.search.fanout.mode, providers: config.search.fanout.providers ? [...config.search.fanout.providers] : undefined }
|
|
26
|
+
: undefined
|
|
24
27
|
},
|
|
25
28
|
fetch: {
|
|
26
29
|
...config.fetch,
|
|
@@ -54,12 +57,16 @@ async function defaultCheckTypebox() {
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
function formatSearchOptions(config) {
|
|
57
|
-
|
|
60
|
+
const parts = [
|
|
58
61
|
config.fallback ? `fallback ${config.fallback}` : undefined,
|
|
59
62
|
config.options?.categories?.length ? `categories ${config.options.categories.join(',')}` : undefined,
|
|
60
63
|
config.options?.language ? `language ${config.options.language}` : undefined,
|
|
61
|
-
config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined
|
|
62
|
-
|
|
64
|
+
config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined,
|
|
65
|
+
config.fanout && config.fanout.mode !== 'off'
|
|
66
|
+
? `fanout ${config.fanout.mode} (${(config.fanout.providers || usableSearchProviders(config)).join(', ')})`
|
|
67
|
+
: undefined
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
return parts.join(' ');
|
|
63
70
|
}
|
|
64
71
|
function formatFetchOptions(config) {
|
|
65
72
|
return [
|
|
@@ -155,6 +162,9 @@ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChang
|
|
|
155
162
|
};
|
|
156
163
|
}
|
|
157
164
|
function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange) {
|
|
165
|
+
const usable = usableSearchProviders(backends.search);
|
|
166
|
+
const fanoutMode = backends.search.fanout?.mode ?? 'off';
|
|
167
|
+
const fanoutProviders = backends.search.fanout?.providers ?? usable;
|
|
158
168
|
return [
|
|
159
169
|
{
|
|
160
170
|
id: 'scope',
|
|
@@ -180,6 +190,18 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
|
|
|
180
190
|
currentValue: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? backends.search.fallback ?? 'off' : 'off',
|
|
181
191
|
values: backends.search.provider === 'searxng' || backends.search.provider === 'brave' || backends.search.provider === 'youcom' || backends.search.provider === 'exa' || backends.search.provider === 'tavily' ? ['off', 'duckduckgo'] : ['off']
|
|
182
192
|
},
|
|
193
|
+
{
|
|
194
|
+
id: 'backend:search:fanout:mode',
|
|
195
|
+
label: 'Search fanout',
|
|
196
|
+
currentValue: fanoutMode,
|
|
197
|
+
values: ['off', 'on', 'auto']
|
|
198
|
+
},
|
|
199
|
+
...(fanoutMode !== 'off' ? usable.map((provider) => ({
|
|
200
|
+
id: `backend:search:fanout:provider:${provider}`,
|
|
201
|
+
label: ` ${provider}`,
|
|
202
|
+
currentValue: fanoutProviders.includes(provider) ? 'included' : 'excluded',
|
|
203
|
+
values: ['included', 'excluded']
|
|
204
|
+
})) : []),
|
|
183
205
|
{
|
|
184
206
|
id: 'backend:secret:brave',
|
|
185
207
|
label: 'Brave API key',
|
|
@@ -333,6 +355,40 @@ export function applySettingsValue(state, id, newValue) {
|
|
|
333
355
|
delete currentBackends.search.fallback;
|
|
334
356
|
}
|
|
335
357
|
}
|
|
358
|
+
if (id === 'backend:search:fanout:mode') {
|
|
359
|
+
if (newValue === 'off') {
|
|
360
|
+
currentBackends.search.fanout = { mode: 'off' };
|
|
361
|
+
}
|
|
362
|
+
else if (newValue === 'on' || newValue === 'auto') {
|
|
363
|
+
currentBackends.search.fanout = {
|
|
364
|
+
mode: newValue,
|
|
365
|
+
providers: undefined
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (id.startsWith('backend:search:fanout:provider:')) {
|
|
370
|
+
const providerName = id.slice('backend:search:fanout:provider:'.length);
|
|
371
|
+
const usableProviders = usableSearchProviders(currentBackends.search);
|
|
372
|
+
// Ensure fanout exists
|
|
373
|
+
if (!currentBackends.search.fanout) {
|
|
374
|
+
currentBackends.search.fanout = { mode: 'on', providers: undefined };
|
|
375
|
+
}
|
|
376
|
+
// Materialize the provider list if it's currently undefined; use usable providers instead of all
|
|
377
|
+
const currentProviders = currentBackends.search.fanout.providers ?? usableProviders;
|
|
378
|
+
if (newValue === 'excluded') {
|
|
379
|
+
// Remove the provider from the list
|
|
380
|
+
const filtered = currentProviders.filter((p) => p !== providerName);
|
|
381
|
+
// never allow an empty set: keep at least one provider (empty would be read as "all")
|
|
382
|
+
if (filtered.length > 0) {
|
|
383
|
+
currentBackends.search.fanout.providers = filtered;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (newValue === 'included') {
|
|
387
|
+
// Add the provider back, maintaining canonical order using usable providers
|
|
388
|
+
const result = usableProviders.filter((p) => currentProviders.includes(p) || p === providerName);
|
|
389
|
+
currentBackends.search.fanout.providers = result;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
336
392
|
if (id === 'backend:search:baseUrl') {
|
|
337
393
|
if (newValue.trim()) {
|
|
338
394
|
currentBackends.search.provider = 'searxng';
|
|
@@ -400,7 +456,8 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
|
|
|
400
456
|
: {
|
|
401
457
|
...(config.search.baseUrl !== inheritedConfig.search.baseUrl ? { baseUrl: config.search.baseUrl } : {}),
|
|
402
458
|
...(config.search.fallback !== inheritedConfig.search.fallback ? { fallback: config.search.fallback } : {}),
|
|
403
|
-
...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {})
|
|
459
|
+
...(!sameJson(config.search.options, inheritedConfig.search.options) ? { options: config.search.options } : {}),
|
|
460
|
+
...(!sameJson(config.search.fanout, inheritedConfig.search.fanout) ? { fanout: config.search.fanout } : {})
|
|
404
461
|
};
|
|
405
462
|
if (config.search.provider !== inheritedConfig.search.provider) {
|
|
406
463
|
override.search.provider = config.search.provider;
|