@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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FanoutMode, SearchProviderName, WebSearchResponse } from '../types.js';
|
|
2
|
+
export type FanoutProvider = {
|
|
3
|
+
name: SearchProviderName;
|
|
4
|
+
search: (input: {
|
|
5
|
+
query: string;
|
|
6
|
+
}) => Promise<WebSearchResponse>;
|
|
7
|
+
};
|
|
8
|
+
export declare function createFanoutSearch({ providers, mode, timeoutMs }: {
|
|
9
|
+
providers: FanoutProvider[];
|
|
10
|
+
mode: Exclude<FanoutMode, 'off'>;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}): ({ query }: {
|
|
13
|
+
query: string;
|
|
14
|
+
}) => Promise<WebSearchResponse>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
2
|
+
import { canonicalizeUrl } from '../orchestration/url.js';
|
|
3
|
+
const FANOUT_MIN_RESULTS = 3;
|
|
4
|
+
function hostOf(url) {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function primaryLooksWeak(results) {
|
|
13
|
+
if (results.length < FANOUT_MIN_RESULTS)
|
|
14
|
+
return true;
|
|
15
|
+
const hosts = new Set(results.map((r) => hostOf(r.url)).filter(Boolean));
|
|
16
|
+
return hosts.size <= 1;
|
|
17
|
+
}
|
|
18
|
+
function merge(lists) {
|
|
19
|
+
const byKey = new Map();
|
|
20
|
+
for (const { name, results } of lists) {
|
|
21
|
+
const seenThisProvider = new Set();
|
|
22
|
+
results.forEach((result, index) => {
|
|
23
|
+
const key = canonicalizeUrl(result.url) ?? result.url;
|
|
24
|
+
if (seenThisProvider.has(key))
|
|
25
|
+
return; // ignore duplicates within a single provider's own list
|
|
26
|
+
seenThisProvider.add(key);
|
|
27
|
+
const existing = byKey.get(key);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
byKey.set(key, { result: { ...result }, providers: new Set([name]), bestRank: index });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
existing.providers.add(name);
|
|
33
|
+
existing.bestRank = Math.min(existing.bestRank, index);
|
|
34
|
+
if ((result.title?.length ?? 0) > (existing.result.title?.length ?? 0))
|
|
35
|
+
existing.result.title = result.title;
|
|
36
|
+
if ((result.snippet?.length ?? 0) > (existing.result.snippet?.length ?? 0))
|
|
37
|
+
existing.result.snippet = result.snippet;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return [...byKey.values()]
|
|
41
|
+
.sort((a, b) => b.providers.size - a.providers.size || a.bestRank - b.bestRank)
|
|
42
|
+
.map((entry) => entry.result);
|
|
43
|
+
}
|
|
44
|
+
function withPresentation(result) {
|
|
45
|
+
return { ...result, presentation: buildSearchPresentation(result) };
|
|
46
|
+
}
|
|
47
|
+
const FANOUT_PROVIDER_TIMEOUT_MS = 8000;
|
|
48
|
+
/** Resolve to undefined if the provider doesn't answer in time, so one slow/unreachable
|
|
49
|
+
* provider (e.g. a down self-hosted SearXNG) can't stall the whole fanout across passes. */
|
|
50
|
+
function withTimeout(promise, ms) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const timer = setTimeout(() => resolve(undefined), ms);
|
|
53
|
+
promise.then((value) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
resolve(value);
|
|
56
|
+
}, () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
resolve(undefined);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function createFanoutSearch({ providers, mode, timeoutMs = FANOUT_PROVIDER_TIMEOUT_MS }) {
|
|
63
|
+
return async function fanoutSearch({ query }) {
|
|
64
|
+
const [primary, ...rest] = providers;
|
|
65
|
+
async function runSet(set) {
|
|
66
|
+
const settled = await Promise.all(set.map((p) => withTimeout(p.search({ query }), timeoutMs)));
|
|
67
|
+
const contributing = [];
|
|
68
|
+
const skipped = [];
|
|
69
|
+
set.forEach((provider, i) => {
|
|
70
|
+
const value = settled[i];
|
|
71
|
+
if (value && value.status === 'ok' && value.results.length > 0) {
|
|
72
|
+
contributing.push({ name: provider.name, results: value.results });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
skipped.push(provider.name);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return { contributing, skipped };
|
|
79
|
+
}
|
|
80
|
+
function finalize(lists, skipped, resolvedMode) {
|
|
81
|
+
if (lists.length === 0) {
|
|
82
|
+
const fanout = { mode: resolvedMode, providers: [] };
|
|
83
|
+
if (skipped.length > 0)
|
|
84
|
+
fanout.skipped = skipped;
|
|
85
|
+
return withPresentation({
|
|
86
|
+
status: 'error',
|
|
87
|
+
results: [],
|
|
88
|
+
metadata: { backend: primary.name, cacheHit: false, fanout },
|
|
89
|
+
error: { code: 'FANOUT_NO_RESULTS', message: 'No fanout provider returned usable results.' }
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const fanout = { mode: resolvedMode, providers: lists.map((l) => l.name) };
|
|
93
|
+
if (skipped.length > 0)
|
|
94
|
+
fanout.skipped = skipped;
|
|
95
|
+
return withPresentation({
|
|
96
|
+
status: 'ok',
|
|
97
|
+
results: merge(lists),
|
|
98
|
+
metadata: { backend: primary.name, cacheHit: false, fanout }
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (mode === 'auto') {
|
|
102
|
+
const primaryOutcome = await withTimeout(primary.search({ query }), timeoutMs);
|
|
103
|
+
const primaryResults = primaryOutcome?.status === 'ok' ? primaryOutcome.results : [];
|
|
104
|
+
if (primaryResults.length > 0 && !primaryLooksWeak(primaryResults)) {
|
|
105
|
+
return withPresentation(primaryOutcome); // strong primary: no fanout
|
|
106
|
+
}
|
|
107
|
+
const { contributing, skipped } = await runSet(rest);
|
|
108
|
+
const lists = [
|
|
109
|
+
...(primaryResults.length ? [{ name: primary.name, results: primaryResults }] : []),
|
|
110
|
+
...contributing
|
|
111
|
+
];
|
|
112
|
+
const allSkipped = [...(primaryResults.length ? [] : [primary.name]), ...skipped];
|
|
113
|
+
return finalize(lists, allSkipped, 'auto');
|
|
114
|
+
}
|
|
115
|
+
const { contributing, skipped } = await runSet(providers);
|
|
116
|
+
return finalize(contributing, skipped, 'on');
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/search/tavily.d.ts
CHANGED
|
@@ -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;
|
package/dist/search/tavily.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
|
@@ -40,6 +40,8 @@ export declare function createWebExploreTool({ explore }?: {
|
|
|
40
40
|
headlessAttempts: number;
|
|
41
41
|
exhaustedBudget: boolean;
|
|
42
42
|
caveatReasons?: string[];
|
|
43
|
+
fanoutProviders?: import("../types.js").SearchProviderName[];
|
|
44
|
+
fanoutSkipped?: import("../types.js").SearchProviderName[];
|
|
43
45
|
};
|
|
44
46
|
error?: import("../types.js").ToolError;
|
|
45
47
|
}>;
|
package/dist/tools/web-search.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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: '
|
|
81
|
-
message: 'DuckDuckGo
|
|
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 (
|
|
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: '
|
|
96
|
-
message: 'DuckDuckGo
|
|
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
|
@@ -6,6 +6,13 @@ export type SearchResult = {
|
|
|
6
6
|
url: string;
|
|
7
7
|
snippet: string;
|
|
8
8
|
};
|
|
9
|
+
export type SearchProviderName = 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
10
|
+
export type FanoutMode = 'off' | 'on' | 'auto';
|
|
11
|
+
export type FanoutMetadata = {
|
|
12
|
+
mode: Exclude<FanoutMode, 'off'>;
|
|
13
|
+
providers: SearchProviderName[];
|
|
14
|
+
skipped?: SearchProviderName[];
|
|
15
|
+
};
|
|
9
16
|
export type ToolError = {
|
|
10
17
|
code: string;
|
|
11
18
|
message: string;
|
|
@@ -13,8 +20,9 @@ export type ToolError = {
|
|
|
13
20
|
export type SearchMetadata = {
|
|
14
21
|
backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
15
22
|
cacheHit: boolean;
|
|
16
|
-
fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
23
|
+
fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
|
|
17
24
|
fallbackReason?: string;
|
|
25
|
+
fanout?: FanoutMetadata;
|
|
18
26
|
};
|
|
19
27
|
export type FetchMethod = 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
|
|
20
28
|
export type FetchMetadata = {
|
|
@@ -70,6 +78,8 @@ export type WebExploreResponse = {
|
|
|
70
78
|
headlessAttempts: number;
|
|
71
79
|
exhaustedBudget: boolean;
|
|
72
80
|
caveatReasons?: string[];
|
|
81
|
+
fanoutProviders?: SearchProviderName[];
|
|
82
|
+
fanoutSkipped?: SearchProviderName[];
|
|
73
83
|
};
|
|
74
84
|
presentation?: PresentationEnvelope;
|
|
75
85
|
error?: ToolError;
|
package/package.json
CHANGED