@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/backends/config.d.ts +13 -0
  3. package/dist/backends/config.js +44 -1
  4. package/dist/backends/factory.d.ts +15 -0
  5. package/dist/backends/factory.js +118 -91
  6. package/dist/backends/failure.d.ts +11 -0
  7. package/dist/backends/failure.js +34 -0
  8. package/dist/backends/fallback-policy.d.ts +33 -0
  9. package/dist/backends/fallback-policy.js +239 -0
  10. package/dist/backends/provider-failure.d.ts +21 -0
  11. package/dist/backends/provider-failure.js +111 -0
  12. package/dist/backends/provider-health.d.ts +29 -0
  13. package/dist/backends/provider-health.js +49 -0
  14. package/dist/commands/web-agent-config.d.ts +14 -1
  15. package/dist/commands/web-agent-config.js +75 -3
  16. package/dist/extension.js +47 -3
  17. package/dist/fetch/destination-policy.d.ts +32 -0
  18. package/dist/fetch/destination-policy.js +24 -0
  19. package/dist/fetch/firecrawl-fetch.js +64 -45
  20. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  21. package/dist/fetch/guard-proxy-fetch.js +82 -0
  22. package/dist/fetch/guard-proxy.d.ts +58 -0
  23. package/dist/fetch/guard-proxy.js +420 -0
  24. package/dist/fetch/guarded-fetch.d.ts +7 -0
  25. package/dist/fetch/guarded-fetch.js +75 -0
  26. package/dist/fetch/headless-fetch.d.ts +10 -2
  27. package/dist/fetch/headless-fetch.js +181 -9
  28. package/dist/fetch/http-fetch.js +16 -1
  29. package/dist/fetch/network-guard.d.ts +82 -0
  30. package/dist/fetch/network-guard.js +275 -0
  31. package/dist/orchestration/answer-synthesizer.js +2 -0
  32. package/dist/orchestration/evidence-quality.d.ts +3 -2
  33. package/dist/orchestration/evidence-quality.js +2 -1
  34. package/dist/orchestration/index.d.ts +23 -0
  35. package/dist/orchestration/index.js +9 -2
  36. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  37. package/dist/orchestration/research-orchestrator.js +40 -7
  38. package/dist/orchestration/research-types.d.ts +13 -1
  39. package/dist/orchestration/research-worker.js +38 -3
  40. package/dist/orchestration/stop-decider.js +3 -1
  41. package/dist/presentation/config-store.js +6 -0
  42. package/dist/presentation/explore-presentation.js +3 -1
  43. package/dist/presentation/fetch-presentation.js +16 -9
  44. package/dist/presentation/search-presentation.d.ts +2 -1
  45. package/dist/presentation/search-presentation.js +13 -1
  46. package/dist/search/brave.d.ts +1 -2
  47. package/dist/search/brave.js +23 -80
  48. package/dist/search/duckduckgo.d.ts +7 -3
  49. package/dist/search/duckduckgo.js +17 -18
  50. package/dist/search/exa.d.ts +1 -2
  51. package/dist/search/exa.js +15 -76
  52. package/dist/search/fanout.d.ts +12 -0
  53. package/dist/search/fanout.js +86 -47
  54. package/dist/search/json-provider.d.ts +32 -0
  55. package/dist/search/json-provider.js +76 -0
  56. package/dist/search/searxng.d.ts +1 -2
  57. package/dist/search/searxng.js +15 -57
  58. package/dist/search/tavily.d.ts +1 -2
  59. package/dist/search/tavily.js +17 -74
  60. package/dist/search/youcom.d.ts +1 -2
  61. package/dist/search/youcom.js +15 -76
  62. package/dist/tools/web-explore.d.ts +9 -0
  63. package/dist/tools/web-explore.js +16 -2
  64. package/dist/tools/web-search.js +41 -103
  65. package/dist/types.d.ts +40 -0
  66. 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
- const headers = { 'content-type': 'application/json' };
11
- if (apiKey) {
12
- headers.Authorization = `Bearer ${apiKey}`;
13
- }
14
- const body = {
15
- url,
16
- formats: options?.formats ?? ['markdown'],
17
- ...(options?.onlyMainContent !== undefined ? { onlyMainContent: options.onlyMainContent } : {})
18
- };
19
- const response = await fetchImpl(buildScrapeUrl(baseUrl), {
20
- method: 'POST',
21
- headers,
22
- body: JSON.stringify(body)
23
- });
24
- if (!response.ok) {
25
- throw new Error(`HTTP ${response.status}`);
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: resolvedUrl,
45
+ url,
46
46
  metadata: { method: 'firecrawl', cacheHit: false },
47
- error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
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
- catch (error) {
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: 'error',
60
- url,
72
+ status: 'needs_headless',
73
+ url: resolvedUrl,
61
74
  metadata: { method: 'firecrawl', cacheHit: false },
62
- error: { code: 'FETCH_FAILED', message: `Firecrawl scrape failed: ${errorMessage(error)}` }
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>;