@demigodmode/pi-web-agent 1.11.0 → 1.12.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 +14 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/factory.d.ts +15 -0
- package/dist/backends/factory.js +118 -91
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +33 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +111 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +14 -1
- package/dist/commands/web-agent-config.js +75 -3
- package/dist/extension.js +47 -3
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.js +64 -45
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +10 -2
- package/dist/fetch/headless-fetch.js +181 -9
- package/dist/fetch/http-fetch.js +16 -1
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +23 -0
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +21 -1
- package/dist/orchestration/research-orchestrator.js +40 -7
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.js +38 -3
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +6 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +1 -2
- package/dist/search/youcom.js +15 -76
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +40 -0
- package/package.json +3 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { BlockedAddressError, UnverifiedDestinationError, type NetworkGuard } from './network-guard.js';
|
|
2
|
+
export type Destination = {
|
|
3
|
+
action: 'connect';
|
|
4
|
+
host: string;
|
|
5
|
+
address: string;
|
|
6
|
+
} | {
|
|
7
|
+
action: 'delegate';
|
|
8
|
+
host: string;
|
|
9
|
+
} | {
|
|
10
|
+
action: 'refuse';
|
|
11
|
+
error: BlockedAddressError | UnverifiedDestinationError;
|
|
12
|
+
};
|
|
13
|
+
export type DestinationMode = {
|
|
14
|
+
/** An upstream proxy is configured. */
|
|
15
|
+
upstream: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The user explicitly trusts the upstream proxy to enforce private-address
|
|
18
|
+
* restrictions. Only meaningful with an upstream.
|
|
19
|
+
*/
|
|
20
|
+
trustProxyDns?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Turns one resolution into where a connection may go (spec revision 2).
|
|
24
|
+
*
|
|
25
|
+
* - blocked answers are refused in every mode
|
|
26
|
+
* - by default the connection goes to an address we checked, never a hostname
|
|
27
|
+
* something else will resolve
|
|
28
|
+
* - with a trusted upstream the hostname is delegated, which also covers names
|
|
29
|
+
* only that proxy can resolve
|
|
30
|
+
* - anything else unresolvable is refused
|
|
31
|
+
*/
|
|
32
|
+
export declare function decideDestination(host: string, guard: NetworkGuard, mode: DestinationMode): Promise<Destination>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { BlockedAddressError, UnverifiedDestinationError } from './network-guard.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns one resolution into where a connection may go (spec revision 2).
|
|
4
|
+
*
|
|
5
|
+
* - blocked answers are refused in every mode
|
|
6
|
+
* - by default the connection goes to an address we checked, never a hostname
|
|
7
|
+
* something else will resolve
|
|
8
|
+
* - with a trusted upstream the hostname is delegated, which also covers names
|
|
9
|
+
* only that proxy can resolve
|
|
10
|
+
* - anything else unresolvable is refused
|
|
11
|
+
*/
|
|
12
|
+
export async function decideDestination(host, guard, mode) {
|
|
13
|
+
const resolution = await guard.resolveHost(host);
|
|
14
|
+
if (resolution.status === 'blocked') {
|
|
15
|
+
return { action: 'refuse', error: new BlockedAddressError(resolution.host, resolution.address) };
|
|
16
|
+
}
|
|
17
|
+
if (mode.upstream && mode.trustProxyDns) {
|
|
18
|
+
return { action: 'delegate', host: resolution.host };
|
|
19
|
+
}
|
|
20
|
+
if (resolution.status === 'unresolved') {
|
|
21
|
+
return { action: 'refuse', error: new UnverifiedDestinationError(resolution.host) };
|
|
22
|
+
}
|
|
23
|
+
return { action: 'connect', host: resolution.host, address: resolution.addresses[0] };
|
|
24
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyHttpFailure, readResponseParts } from '../backends/provider-failure.js';
|
|
1
2
|
function buildScrapeUrl(baseUrl) {
|
|
2
3
|
return new URL('/v1/scrape', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
|
3
4
|
}
|
|
@@ -6,61 +7,79 @@ function errorMessage(error) {
|
|
|
6
7
|
}
|
|
7
8
|
export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = fetch }) {
|
|
8
9
|
return async function firecrawlFetch(url) {
|
|
10
|
+
const failed = (message, failure) => ({
|
|
11
|
+
status: 'error',
|
|
12
|
+
url,
|
|
13
|
+
metadata: { method: 'firecrawl', cacheHit: false },
|
|
14
|
+
error: { code: 'FETCH_FAILED', message, failure }
|
|
15
|
+
});
|
|
16
|
+
const headers = { 'content-type': 'application/json' };
|
|
17
|
+
if (apiKey) {
|
|
18
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
19
|
+
}
|
|
20
|
+
const body = {
|
|
21
|
+
url,
|
|
22
|
+
formats: options?.formats ?? ['markdown'],
|
|
23
|
+
...(options?.onlyMainContent !== undefined ? { onlyMainContent: options.onlyMainContent } : {})
|
|
24
|
+
};
|
|
25
|
+
let response;
|
|
9
26
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
const parsed = (await response.json());
|
|
28
|
-
if (parsed.success === false) {
|
|
29
|
-
throw new Error(typeof parsed.error === 'string' ? parsed.error : 'Firecrawl scrape failed.');
|
|
30
|
-
}
|
|
31
|
-
const text = typeof parsed.data?.markdown === 'string'
|
|
32
|
-
? parsed.data.markdown
|
|
33
|
-
: typeof parsed.data?.html === 'string'
|
|
34
|
-
? parsed.data.html
|
|
35
|
-
: '';
|
|
36
|
-
const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
|
|
37
|
-
? parsed.data.metadata.sourceURL
|
|
38
|
-
: url;
|
|
39
|
-
const title = typeof parsed.data?.metadata?.title === 'string'
|
|
40
|
-
? parsed.data.metadata.title
|
|
41
|
-
: undefined;
|
|
42
|
-
if (!text.trim()) {
|
|
27
|
+
response = await fetchImpl(buildScrapeUrl(baseUrl), { method: 'POST', headers, body: JSON.stringify(body) });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return failed(`Firecrawl scrape failed: ${errorMessage(error)}`, { kind: 'transient' });
|
|
31
|
+
}
|
|
32
|
+
let parts;
|
|
33
|
+
try {
|
|
34
|
+
parts = await readResponseParts(response);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return failed(`Firecrawl scrape response could not be read: ${errorMessage(error)}`, { kind: 'transient' });
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const code = parts.json?.code;
|
|
41
|
+
if (response.status === 500 && code === 'SCRAPE_ALL_ENGINES_FAILED') {
|
|
42
|
+
// No extractable content (https://github.com/firecrawl/firecrawl/issues/2316): same as an empty page.
|
|
43
43
|
return {
|
|
44
44
|
status: 'needs_headless',
|
|
45
|
-
url
|
|
45
|
+
url,
|
|
46
46
|
metadata: { method: 'firecrawl', cacheHit: false },
|
|
47
|
-
error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl
|
|
47
|
+
error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl could not extract content from this page.' }
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
-
return {
|
|
51
|
-
status: 'ok',
|
|
52
|
-
url: resolvedUrl,
|
|
53
|
-
content: { title, text },
|
|
54
|
-
metadata: { method: 'firecrawl', cacheHit: false, truncated: text.length >= 4000 }
|
|
55
|
-
};
|
|
50
|
+
return failed(`Firecrawl scrape failed: HTTP ${response.status}`, classifyHttpFailure('firecrawl', parts));
|
|
56
51
|
}
|
|
57
|
-
|
|
52
|
+
const parsed = parts.json;
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || parsed.success === false) {
|
|
54
|
+
return failed('Firecrawl returned a response that did not match the expected format.', {
|
|
55
|
+
kind: 'bad_response',
|
|
56
|
+
httpStatus: response.status
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const text = typeof parsed.data?.markdown === 'string'
|
|
60
|
+
? parsed.data.markdown
|
|
61
|
+
: typeof parsed.data?.html === 'string'
|
|
62
|
+
? parsed.data.html
|
|
63
|
+
: '';
|
|
64
|
+
const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
|
|
65
|
+
? parsed.data.metadata.sourceURL
|
|
66
|
+
: url;
|
|
67
|
+
const title = typeof parsed.data?.metadata?.title === 'string'
|
|
68
|
+
? parsed.data.metadata.title
|
|
69
|
+
: undefined;
|
|
70
|
+
if (!text.trim()) {
|
|
58
71
|
return {
|
|
59
|
-
status: '
|
|
60
|
-
url,
|
|
72
|
+
status: 'needs_headless',
|
|
73
|
+
url: resolvedUrl,
|
|
61
74
|
metadata: { method: 'firecrawl', cacheHit: false },
|
|
62
|
-
error: { code: '
|
|
75
|
+
error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
|
|
63
76
|
};
|
|
64
77
|
}
|
|
78
|
+
return {
|
|
79
|
+
status: 'ok',
|
|
80
|
+
url: resolvedUrl,
|
|
81
|
+
content: { title, text },
|
|
82
|
+
metadata: { method: 'firecrawl', cacheHit: false, truncated: text.length >= 4000 }
|
|
83
|
+
};
|
|
65
84
|
};
|
|
66
85
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GuardProxy } from './guard-proxy.js';
|
|
2
|
+
/**
|
|
3
|
+
* Model-chosen Node fetches connect through the guard proxy, which is where the
|
|
4
|
+
* address policy is enforced. A refusal comes back as a 403/502 on the tunnel
|
|
5
|
+
* or the forwarded request; the proxy's refusal log turns that into the typed
|
|
6
|
+
* guard error. Only refusals recorded during this call count, so an earlier
|
|
7
|
+
* block can't be blamed for a later unrelated failure.
|
|
8
|
+
*/
|
|
9
|
+
export type GuardProxyFetch = typeof fetch & {
|
|
10
|
+
/** Closes the ProxyAgent (awaiting it if still being created) and rejects later calls. Idempotent. */
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export declare function createGuardProxyFetch(getProxy: () => Promise<GuardProxy>, { tls }?: {
|
|
14
|
+
tls?: {
|
|
15
|
+
ca?: string | Buffer;
|
|
16
|
+
};
|
|
17
|
+
}): GuardProxyFetch;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
2
|
+
import { BLOCKED_HEADER } from './guard-proxy.js';
|
|
3
|
+
function hostOf(input) {
|
|
4
|
+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
5
|
+
try {
|
|
6
|
+
return new URL(raw).hostname.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function createGuardProxyFetch(getProxy, { tls } = {}) {
|
|
13
|
+
let ready;
|
|
14
|
+
let closed = false;
|
|
15
|
+
const ensure = () => {
|
|
16
|
+
if (ready)
|
|
17
|
+
return ready;
|
|
18
|
+
const started = getProxy().then((proxy) => {
|
|
19
|
+
const client = proxy.client('node');
|
|
20
|
+
const agent = new ProxyAgent({
|
|
21
|
+
uri: client.server,
|
|
22
|
+
token: `Basic ${Buffer.from(`${client.username}:${client.password}`).toString('base64')}`,
|
|
23
|
+
...(tls ? { requestTls: tls } : {})
|
|
24
|
+
});
|
|
25
|
+
return { proxy, client, agent };
|
|
26
|
+
});
|
|
27
|
+
ready = started;
|
|
28
|
+
// Don't let one failed attempt poison every later fetch: clear it so the
|
|
29
|
+
// next call retries, unless a newer attempt has already replaced it.
|
|
30
|
+
started.catch(() => {
|
|
31
|
+
if (ready === started)
|
|
32
|
+
ready = undefined;
|
|
33
|
+
});
|
|
34
|
+
return started;
|
|
35
|
+
};
|
|
36
|
+
const guardedFetch = (async (input, init) => {
|
|
37
|
+
if (closed)
|
|
38
|
+
throw new Error('Guard proxy fetch is closed.');
|
|
39
|
+
const { proxy, client, agent } = await ensure();
|
|
40
|
+
const host = hostOf(input);
|
|
41
|
+
const since = proxy.sequence();
|
|
42
|
+
const refusalFor = () => proxy
|
|
43
|
+
.refusalsSince(client.username, since)
|
|
44
|
+
.reverse()
|
|
45
|
+
.find((entry) => host === undefined || entry.host === host);
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = (await undiciFetch(input, { ...init, dispatcher: agent }));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const refusal = refusalFor();
|
|
52
|
+
if (refusal)
|
|
53
|
+
throw refusal.error;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const blockedHeader = response.headers.get(BLOCKED_HEADER);
|
|
57
|
+
if (blockedHeader) {
|
|
58
|
+
// Only the exact refusal the proxy named counts. A destination can send this
|
|
59
|
+
// header too, and a concurrent call may have been refused for the same host.
|
|
60
|
+
const seq = Number(/^\S+ (\d+)(?: |$)/.exec(blockedHeader)?.[1]);
|
|
61
|
+
const refusal = Number.isSafeInteger(seq)
|
|
62
|
+
? proxy
|
|
63
|
+
.refusalsSince(client.username, since)
|
|
64
|
+
.find((entry) => entry.seq === seq && (host === undefined || entry.host === host))
|
|
65
|
+
: undefined;
|
|
66
|
+
if (refusal) {
|
|
67
|
+
await response.body?.cancel().catch(() => undefined);
|
|
68
|
+
throw refusal.error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return response;
|
|
72
|
+
});
|
|
73
|
+
return Object.assign(guardedFetch, {
|
|
74
|
+
async close() {
|
|
75
|
+
closed = true;
|
|
76
|
+
if (!ready)
|
|
77
|
+
return; // never used: nothing to close, and don't start the proxy now
|
|
78
|
+
const created = await ready.catch(() => undefined);
|
|
79
|
+
await created?.agent.close().catch(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Socket } from 'node:net';
|
|
2
|
+
import { type GuardError, type NetworkGuard } from './network-guard.js';
|
|
3
|
+
/**
|
|
4
|
+
* Enforces the private-address policy where connections are opened (#53, spec
|
|
5
|
+
* revision 2). Every model-chosen connection, from Node or from Chromium, goes
|
|
6
|
+
* through here: the destination is resolved once, every answer is checked, and
|
|
7
|
+
* the socket is opened to an approved IP. Nothing downstream resolves the
|
|
8
|
+
* hostname again, so a redirect or a DNS change cannot move the connection.
|
|
9
|
+
*
|
|
10
|
+
* Listens on loopback only and requires per-start credentials, so it is not an
|
|
11
|
+
* open egress proxy for other local processes. It is an owned resource: call
|
|
12
|
+
* close() to stop it. unref() only keeps an idle listener from holding the
|
|
13
|
+
* process open.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BLOCKED_HEADER = "x-pi-web-agent-blocked";
|
|
16
|
+
export type UpstreamProxy = {
|
|
17
|
+
url: string;
|
|
18
|
+
username?: string;
|
|
19
|
+
password?: string;
|
|
20
|
+
};
|
|
21
|
+
export type OpenSocket = (host: string, port: number, useTls: boolean, servername?: string) => Socket;
|
|
22
|
+
export type GuardProxyOptions = {
|
|
23
|
+
guard: NetworkGuard;
|
|
24
|
+
upstream?: UpstreamProxy;
|
|
25
|
+
trustProxyDns?: boolean;
|
|
26
|
+
/** TLS options for an https:// upstream proxy connection. */
|
|
27
|
+
upstreamTls?: {
|
|
28
|
+
ca?: string | Buffer;
|
|
29
|
+
rejectUnauthorized?: boolean;
|
|
30
|
+
};
|
|
31
|
+
/** Outbound sockets (direct or to the upstream) must connect within this. */
|
|
32
|
+
connectTimeoutMs?: number;
|
|
33
|
+
/** Client request/CONNECT headers and the upstream CONNECT response must arrive within this. */
|
|
34
|
+
handshakeTimeoutMs?: number;
|
|
35
|
+
/** Test seam for creating outbound sockets. */
|
|
36
|
+
openSocket?: OpenSocket;
|
|
37
|
+
};
|
|
38
|
+
export type GuardProxyClient = {
|
|
39
|
+
server: string;
|
|
40
|
+
username: string;
|
|
41
|
+
password: string;
|
|
42
|
+
};
|
|
43
|
+
export type Refusal = {
|
|
44
|
+
seq: number;
|
|
45
|
+
client: string;
|
|
46
|
+
host: string;
|
|
47
|
+
error: GuardError;
|
|
48
|
+
};
|
|
49
|
+
export type GuardProxy = {
|
|
50
|
+
url: string;
|
|
51
|
+
/** Credentials for one client. Refusals are recorded against its username. */
|
|
52
|
+
client(name?: string): GuardProxyClient;
|
|
53
|
+
sequence(): number;
|
|
54
|
+
refusalsSince(username: string, seq: number): Refusal[];
|
|
55
|
+
/** Idempotent. Stops the listener and destroys every socket, including ones still connecting. */
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
export declare function startGuardProxy(options: GuardProxyOptions): Promise<GuardProxy>;
|