@demigodmode/pi-web-agent 1.11.0 → 1.13.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 (82) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -0
  3. package/dist/backends/config.d.ts +13 -0
  4. package/dist/backends/config.js +44 -1
  5. package/dist/backends/doctor.js +27 -29
  6. package/dist/backends/factory.d.ts +18 -7
  7. package/dist/backends/factory.js +126 -99
  8. package/dist/backends/failure.d.ts +11 -0
  9. package/dist/backends/failure.js +34 -0
  10. package/dist/backends/fallback-policy.d.ts +31 -0
  11. package/dist/backends/fallback-policy.js +239 -0
  12. package/dist/backends/provider-failure.d.ts +21 -0
  13. package/dist/backends/provider-failure.js +110 -0
  14. package/dist/backends/provider-health.d.ts +29 -0
  15. package/dist/backends/provider-health.js +49 -0
  16. package/dist/commands/web-agent-config.d.ts +14 -1
  17. package/dist/commands/web-agent-config.js +75 -3
  18. package/dist/extension.js +47 -3
  19. package/dist/extract/bot-check.d.ts +1 -0
  20. package/dist/extract/bot-check.js +10 -0
  21. package/dist/extract/readability.d.ts +4 -0
  22. package/dist/extract/readability.js +67 -3
  23. package/dist/extract/section-selector.d.ts +14 -0
  24. package/dist/extract/section-selector.js +233 -0
  25. package/dist/fetch/destination-policy.d.ts +32 -0
  26. package/dist/fetch/destination-policy.js +24 -0
  27. package/dist/fetch/firecrawl-fetch.d.ts +1 -1
  28. package/dist/fetch/firecrawl-fetch.js +74 -46
  29. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  30. package/dist/fetch/guard-proxy-fetch.js +82 -0
  31. package/dist/fetch/guard-proxy.d.ts +58 -0
  32. package/dist/fetch/guard-proxy.js +420 -0
  33. package/dist/fetch/guarded-fetch.d.ts +7 -0
  34. package/dist/fetch/guarded-fetch.js +75 -0
  35. package/dist/fetch/headless-fetch.d.ts +11 -2
  36. package/dist/fetch/headless-fetch.js +190 -13
  37. package/dist/fetch/http-fetch.d.ts +1 -1
  38. package/dist/fetch/http-fetch.js +29 -8
  39. package/dist/fetch/network-guard.d.ts +82 -0
  40. package/dist/fetch/network-guard.js +275 -0
  41. package/dist/orchestration/answer-synthesizer.js +2 -0
  42. package/dist/orchestration/evidence-quality.d.ts +3 -2
  43. package/dist/orchestration/evidence-quality.js +2 -1
  44. package/dist/orchestration/index.d.ts +26 -7
  45. package/dist/orchestration/index.js +9 -2
  46. package/dist/orchestration/research-orchestrator.d.ts +23 -7
  47. package/dist/orchestration/research-orchestrator.js +60 -26
  48. package/dist/orchestration/research-types.d.ts +13 -1
  49. package/dist/orchestration/research-worker.d.ts +2 -4
  50. package/dist/orchestration/research-worker.js +51 -15
  51. package/dist/orchestration/stop-decider.js +3 -1
  52. package/dist/presentation/config-store.js +6 -0
  53. package/dist/presentation/explore-presentation.js +3 -1
  54. package/dist/presentation/fetch-presentation.js +16 -9
  55. package/dist/presentation/search-presentation.d.ts +2 -1
  56. package/dist/presentation/search-presentation.js +13 -1
  57. package/dist/readers/resolver.d.ts +3 -7
  58. package/dist/search/brave.d.ts +1 -2
  59. package/dist/search/brave.js +23 -80
  60. package/dist/search/duckduckgo.d.ts +7 -3
  61. package/dist/search/duckduckgo.js +17 -18
  62. package/dist/search/exa.d.ts +1 -2
  63. package/dist/search/exa.js +15 -76
  64. package/dist/search/fanout.d.ts +12 -0
  65. package/dist/search/fanout.js +86 -47
  66. package/dist/search/json-provider.d.ts +32 -0
  67. package/dist/search/json-provider.js +76 -0
  68. package/dist/search/searxng.d.ts +1 -2
  69. package/dist/search/searxng.js +15 -57
  70. package/dist/search/tavily.d.ts +1 -2
  71. package/dist/search/tavily.js +17 -74
  72. package/dist/search/youcom.d.ts +4 -2
  73. package/dist/search/youcom.js +49 -75
  74. package/dist/tools/web-explore.d.ts +9 -0
  75. package/dist/tools/web-explore.js +16 -2
  76. package/dist/tools/web-fetch-headless.d.ts +3 -5
  77. package/dist/tools/web-fetch-headless.js +3 -3
  78. package/dist/tools/web-fetch.d.ts +3 -5
  79. package/dist/tools/web-fetch.js +3 -3
  80. package/dist/tools/web-search.js +41 -103
  81. package/dist/types.d.ts +48 -0
  82. package/package.json +3 -3
@@ -0,0 +1,275 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+ export const BLOCKED_PRIVATE_ADDRESS = 'BLOCKED_PRIVATE_ADDRESS';
4
+ export class BlockedAddressError extends Error {
5
+ host;
6
+ address;
7
+ code = BLOCKED_PRIVATE_ADDRESS;
8
+ constructor(host, address) {
9
+ super(`Blocked ${host}: resolves to private address ${address}. ` +
10
+ 'Add it to backends.network.allowRanges if this is intended.');
11
+ this.host = host;
12
+ this.address = address;
13
+ this.name = 'BlockedAddressError';
14
+ }
15
+ }
16
+ /** No address could be verified, so we refuse rather than let something else resolve it. */
17
+ export class UnverifiedDestinationError extends Error {
18
+ host;
19
+ code = BLOCKED_PRIVATE_ADDRESS;
20
+ constructor(host) {
21
+ super(`Blocked ${host}: could not verify its address before connecting.`);
22
+ this.host = host;
23
+ this.name = 'UnverifiedDestinationError';
24
+ }
25
+ }
26
+ export const UPSTREAM_PROXY_REFUSED = 'UPSTREAM_PROXY_REFUSED';
27
+ /** The user's upstream proxy would not accept the approved IP. We never retry by hostname. */
28
+ export class UpstreamProxyRefusedError extends Error {
29
+ host;
30
+ target;
31
+ status;
32
+ code = UPSTREAM_PROXY_REFUSED;
33
+ constructor(host, target, status) {
34
+ super(`Upstream proxy refused ${target} for ${host} (HTTP ${status}). ` +
35
+ 'If it only accepts hostnames, set backends.network.trustProxyDns to trust it to enforce private-address restrictions.');
36
+ this.host = host;
37
+ this.target = target;
38
+ this.status = status;
39
+ this.name = 'UpstreamProxyRefusedError';
40
+ }
41
+ }
42
+ export function findGuardError(error) {
43
+ let current = error;
44
+ for (let depth = 0; depth < 5 && current; depth += 1) {
45
+ if (current instanceof BlockedAddressError ||
46
+ current instanceof UnverifiedDestinationError ||
47
+ current instanceof UpstreamProxyRefusedError) {
48
+ return current;
49
+ }
50
+ current = current.cause;
51
+ }
52
+ return undefined;
53
+ }
54
+ /** undici wraps connect errors as `TypeError: fetch failed` with the real error in `cause`. */
55
+ export function findBlockedAddressError(error) {
56
+ let current = error;
57
+ for (let depth = 0; depth < 5 && current; depth += 1) {
58
+ if (current instanceof BlockedAddressError)
59
+ return current;
60
+ current = current.cause;
61
+ }
62
+ return undefined;
63
+ }
64
+ const IPV4_WIDTH = 32;
65
+ const IPV6_WIDTH = 128;
66
+ function ipv4ToBigInt(address) {
67
+ const parts = address.split('.');
68
+ if (parts.length !== 4)
69
+ return undefined;
70
+ let value = 0n;
71
+ for (const part of parts) {
72
+ if (!/^\d{1,3}$/.test(part))
73
+ return undefined;
74
+ const octet = Number(part);
75
+ if (octet > 255)
76
+ return undefined;
77
+ value = (value << 8n) | BigInt(octet);
78
+ }
79
+ return value;
80
+ }
81
+ function ipv6ToBigInt(address) {
82
+ let text = address.split('%')[0];
83
+ // Embedded IPv4 tail, e.g. ::ffff:127.0.0.1
84
+ if (text.includes('.')) {
85
+ const lastColon = text.lastIndexOf(':');
86
+ const v4 = ipv4ToBigInt(text.slice(lastColon + 1));
87
+ if (v4 === undefined)
88
+ return undefined;
89
+ const high = ((v4 >> 16n) & 0xffffn).toString(16);
90
+ const low = (v4 & 0xffffn).toString(16);
91
+ text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
92
+ }
93
+ const halves = text.split('::');
94
+ if (halves.length > 2)
95
+ return undefined;
96
+ const head = halves[0] ? halves[0].split(':') : [];
97
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
98
+ const missing = 8 - head.length - tail.length;
99
+ if (halves.length === 1 && missing !== 0)
100
+ return undefined;
101
+ if (halves.length === 2 && missing < 1)
102
+ return undefined;
103
+ const groups = [...head, ...new Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
104
+ let value = 0n;
105
+ for (const group of groups) {
106
+ if (!/^[0-9a-f]{1,4}$/i.test(group))
107
+ return undefined;
108
+ value = (value << 16n) | BigInt(parseInt(group, 16));
109
+ }
110
+ return value;
111
+ }
112
+ const NAT64_WELL_KNOWN_NET = ipv6ToBigInt('64:ff9b::') >> 32n;
113
+ const NAT64_LOCAL_NET = ipv6ToBigInt('64:ff9b:1::') >> 80n;
114
+ const SIXTOFOUR_NET = ipv6ToBigInt('2002::') >> 112n;
115
+ function parseAddress(address) {
116
+ const family = isIP(address.split('%')[0]);
117
+ if (family === 4) {
118
+ const value = ipv4ToBigInt(address);
119
+ return value === undefined ? undefined : { family: 4, value };
120
+ }
121
+ if (family === 6) {
122
+ const value = ipv6ToBigInt(address);
123
+ if (value === undefined)
124
+ return undefined;
125
+ // IPv4-mapped (::ffff:a.b.c.d): judge it as the IPv4 address it really is.
126
+ if (value >> 32n === 0xffffn)
127
+ return { family: 4, value: value & 0xffffffffn };
128
+ // IPv4-compatible (deprecated ::a.b.c.d), but not the special addresses :: and ::1.
129
+ if (value >> 32n === 0n && value > 1n)
130
+ return { family: 4, value };
131
+ // NAT64 (64:ff9b::/96 well-known, 64:ff9b:1::/48 local-use): IPv4 is the low 32 bits.
132
+ // DNS64 networks translate every IPv4-only host into these prefixes, so we cannot
133
+ // block the prefix outright -- judge the embedded address on its own merits instead.
134
+ if (value >> 32n === NAT64_WELL_KNOWN_NET)
135
+ return { family: 4, value: value & 0xffffffffn };
136
+ // Local-use NAT64 lets the operator pick a prefix shorter than /96; only the /96
137
+ // case has the IPv4 address in the low 32 bits, so only unwrap when bits 48-95
138
+ // (between the /48 prefix and the embedded address) are all zero.
139
+ if (value >> 80n === NAT64_LOCAL_NET && ((value >> 32n) & 0xffffffffffffn) === 0n) {
140
+ return { family: 4, value: value & 0xffffffffn };
141
+ }
142
+ // 6to4 (2002::/16): IPv4 is bits 16-48 from the top.
143
+ if (value >> 112n === SIXTOFOUR_NET)
144
+ return { family: 4, value: (value >> 80n) & 0xffffffffn };
145
+ return { family: 6, value };
146
+ }
147
+ return undefined;
148
+ }
149
+ export function parseCidr(text) {
150
+ const trimmed = text.trim();
151
+ const slash = trimmed.indexOf('/');
152
+ if (slash <= 0)
153
+ return undefined;
154
+ const prefixText = trimmed.slice(slash + 1);
155
+ if (!/^\d{1,3}$/.test(prefixText))
156
+ return undefined;
157
+ const prefix = Number(prefixText);
158
+ const address = parseAddress(trimmed.slice(0, slash));
159
+ if (!address)
160
+ return undefined;
161
+ // A mapped IPv6 CIDR would be ambiguous; require the plain family.
162
+ const family = isIP(trimmed.slice(0, slash));
163
+ const width = family === 4 ? IPV4_WIDTH : IPV6_WIDTH;
164
+ if (prefix > width)
165
+ return undefined;
166
+ const value = family === 4 ? ipv4ToBigInt(trimmed.slice(0, slash)) : ipv6ToBigInt(trimmed.slice(0, slash));
167
+ const shift = BigInt(width - prefix);
168
+ return { family, network: (value >> shift) << shift, prefix };
169
+ }
170
+ function cidrContains(cidr, address) {
171
+ if (cidr.family !== address.family)
172
+ return false;
173
+ const width = cidr.family === 4 ? IPV4_WIDTH : IPV6_WIDTH;
174
+ const shift = BigInt(width - cidr.prefix);
175
+ return address.value >> shift === cidr.network >> shift;
176
+ }
177
+ const BLOCKED_RANGES = [
178
+ '0.0.0.0/8',
179
+ '10.0.0.0/8',
180
+ '100.64.0.0/10',
181
+ '127.0.0.0/8',
182
+ '169.254.0.0/16',
183
+ '172.16.0.0/12',
184
+ '192.0.0.0/24',
185
+ '192.168.0.0/16',
186
+ '198.18.0.0/15',
187
+ '224.0.0.0/4',
188
+ '240.0.0.0/4',
189
+ '::1/128',
190
+ '::/128',
191
+ '64:ff9b:1::/48',
192
+ 'fc00::/7',
193
+ 'fe80::/10',
194
+ 'fec0::/10',
195
+ 'ff00::/8'
196
+ ].map((range) => parseCidr(range));
197
+ /** Parses an allow list, dropping invalid entries and anything that allows every address. */
198
+ export function usableAllowRanges(allowRanges = []) {
199
+ return allowRanges
200
+ .map((range) => parseCidr(range))
201
+ .filter((cidr) => cidr !== undefined && cidr.prefix > 0);
202
+ }
203
+ const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
204
+ const defaultLookup = (host) => dnsLookup(host, { all: true, verbatim: true });
205
+ function stripBrackets(host) {
206
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
207
+ }
208
+ export function createNetworkGuard(config = {}, { lookup = defaultLookup, lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS } = {}) {
209
+ const allow = usableAllowRanges(config.allowRanges);
210
+ function isBlockedAddress(address) {
211
+ const parsed = parseAddress(address);
212
+ if (!parsed)
213
+ return true; // fail closed
214
+ if (allow.some((cidr) => cidrContains(cidr, parsed)))
215
+ return false;
216
+ return BLOCKED_RANGES.some((cidr) => cidrContains(cidr, parsed));
217
+ }
218
+ async function resolveHost(rawHost) {
219
+ const host = stripBrackets(rawHost.toLowerCase()).replace(/\.+$/, '');
220
+ if (host === 'localhost' || host.endsWith('.localhost')) {
221
+ return isBlockedAddress('127.0.0.1')
222
+ ? { status: 'blocked', host, address: '127.0.0.1' }
223
+ : { status: 'allowed', host, addresses: ['127.0.0.1'] };
224
+ }
225
+ if (isIP(host)) {
226
+ return isBlockedAddress(host)
227
+ ? { status: 'blocked', host, address: host }
228
+ : { status: 'allowed', host, addresses: [host] };
229
+ }
230
+ let answers;
231
+ let timer;
232
+ try {
233
+ answers = await Promise.race([
234
+ lookup(host),
235
+ new Promise((resolve) => {
236
+ timer = setTimeout(() => resolve(undefined), lookupTimeoutMs);
237
+ timer.unref?.();
238
+ })
239
+ ]);
240
+ }
241
+ catch {
242
+ return { status: 'unresolved', host };
243
+ }
244
+ finally {
245
+ clearTimeout(timer);
246
+ }
247
+ if (!answers || answers.length === 0)
248
+ return { status: 'unresolved', host };
249
+ // Any private answer blocks: which address a connection would pick is not ours to control.
250
+ const blocked = answers.find((entry) => isBlockedAddress(entry.address));
251
+ if (blocked)
252
+ return { status: 'blocked', host, address: blocked.address };
253
+ return { status: 'allowed', host, addresses: answers.map((entry) => entry.address) };
254
+ }
255
+ async function checkHost(rawHost) {
256
+ const resolution = await resolveHost(rawHost);
257
+ if (resolution.status === 'blocked') {
258
+ return { allowed: false, host: resolution.host, address: resolution.address };
259
+ }
260
+ return resolution.status === 'unresolved' ? { allowed: true, unresolved: true } : { allowed: true };
261
+ }
262
+ async function assertUrlAllowed(url) {
263
+ let parsed;
264
+ try {
265
+ parsed = new URL(url);
266
+ }
267
+ catch {
268
+ return; // not a URL; the tools reject it with UNSUPPORTED_URL
269
+ }
270
+ const verdict = await checkHost(parsed.hostname);
271
+ if (!verdict.allowed)
272
+ throw new BlockedAddressError(verdict.host, verdict.address);
273
+ }
274
+ return { isBlockedAddress, resolveHost, checkHost, assertUrlAllowed };
275
+ }
@@ -15,6 +15,8 @@ function sentenceForReason(reason) {
15
15
  return 'readable sources include cautionary or possibly conflicting guidance';
16
16
  case 'bot-check':
17
17
  return 'some candidate sources showed bot-check or security verification pages';
18
+ case 'partial-search-coverage':
19
+ return 'some search backends were unavailable, so results may be incomplete';
18
20
  }
19
21
  }
20
22
  function joinReasons(reasons) {
@@ -1,5 +1,5 @@
1
1
  import type { ResearchEvidence, ResearchGap, ResearchLowValueOutcome } from './research-types.js';
2
- export type EvidenceCaveatReason = 'community-only' | 'low-diversity' | 'unreadable-direct-source' | 'unreadable-thread-source' | 'possible-conflict' | 'bot-check';
2
+ export type EvidenceCaveatReason = 'community-only' | 'low-diversity' | 'unreadable-direct-source' | 'unreadable-thread-source' | 'possible-conflict' | 'bot-check' | 'partial-search-coverage';
3
3
  export type EvidenceQualityReport = {
4
4
  counts: {
5
5
  total: number;
@@ -20,8 +20,9 @@ export type EvidenceQualityReport = {
20
20
  };
21
21
  caveatReasons: EvidenceCaveatReason[];
22
22
  };
23
- export declare function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }: {
23
+ export declare function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes, partialSearchCoverage }: {
24
24
  evidence: ResearchEvidence[];
25
25
  gaps: ResearchGap[];
26
26
  lowValueOutcomes: ResearchLowValueOutcome[];
27
+ partialSearchCoverage?: boolean;
27
28
  }): EvidenceQualityReport;
@@ -20,7 +20,7 @@ function addReason(reasons, reason, enabled) {
20
20
  if (enabled && !reasons.includes(reason))
21
21
  reasons.push(reason);
22
22
  }
23
- export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }) {
23
+ export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes, partialSearchCoverage = false }) {
24
24
  const official = evidence.filter((item) => item.sourceKind === 'official-docs' || item.sourceKind === 'official-api').length;
25
25
  const community = evidence.filter((item) => item.sourceKind === 'community').length;
26
26
  const thread = evidence.filter((item) => item.sourceKind === 'issue-thread' || item.sourceKind === 'official-discussion').length;
@@ -41,6 +41,7 @@ export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }) {
41
41
  addReason(caveatReasons, 'unreadable-thread-source', hasUnreadableThreadSource);
42
42
  addReason(caveatReasons, 'possible-conflict', hasPossibleConflict);
43
43
  addReason(caveatReasons, 'bot-check', hasBotCheck);
44
+ addReason(caveatReasons, 'partial-search-coverage', partialSearchCoverage);
44
45
  return {
45
46
  counts: {
46
47
  total: evidence.length,
@@ -1,16 +1,12 @@
1
1
  import type { BackendConfig } from '../backends/config.js';
2
- import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
2
+ import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
3
3
  export declare function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch }?: {
4
4
  backendConfig?: BackendConfig;
5
5
  search?: (input: {
6
6
  query: string;
7
7
  }) => Promise<WebSearchResponse>;
8
- fetchPage?: (input: {
9
- url: string;
10
- }) => Promise<WebFetchResponse>;
11
- headlessFetch?: (input: {
12
- url: string;
13
- }) => Promise<WebFetchHeadlessResponse>;
8
+ fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
9
+ headlessFetch?: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
14
10
  }): {
15
11
  run({ query }: {
16
12
  query: string;
@@ -19,6 +15,7 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
19
15
  evidence: import("./research-types.js").ResearchEvidence[];
20
16
  workerPass: import("./research-types.js").ResearchWorkerResult;
21
17
  metadata: {
18
+ attempts?: import("../types.js").Attempt[] | undefined;
22
19
  searchPasses: number;
23
20
  fetchedPages: number;
24
21
  headlessAttempts: number;
@@ -27,5 +24,27 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
27
24
  fanoutProviders: import("../types.js").SearchProviderName[] | undefined;
28
25
  fanoutSkipped: import("../types.js").SearchProviderName[] | undefined;
29
26
  };
27
+ terminalFailure?: undefined;
28
+ } | {
29
+ decision: import("./research-types.js").ResearchOrchestratorDecision;
30
+ evidence: never[];
31
+ workerPass: import("./research-types.js").ResearchWorkerResult;
32
+ metadata: {
33
+ attempts?: import("../types.js").Attempt[] | undefined;
34
+ searchPasses: number;
35
+ fetchedPages: number;
36
+ headlessAttempts: number;
37
+ exhaustedBudget: boolean;
38
+ caveatReasons: import("./evidence-quality.js").EvidenceCaveatReason[];
39
+ fanoutProviders: import("../types.js").SearchProviderName[] | undefined;
40
+ fanoutSkipped: import("../types.js").SearchProviderName[] | undefined;
41
+ };
42
+ terminalFailure: {
43
+ code: string;
44
+ message: string;
45
+ };
30
46
  }>;
47
+ } & {
48
+ /** Releases the backend set this workflow created. Injected capabilities are left alone. */
49
+ close(): Promise<void>;
31
50
  };
@@ -2,14 +2,21 @@ import { createBackendSet } from '../backends/factory.js';
2
2
  import { createResearchOrchestrator } from './research-orchestrator.js';
3
3
  import { createResearchWorker } from './research-worker.js';
4
4
  export function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch } = {}) {
5
- const backends = createBackendSet(backendConfig);
5
+ // Only build (and own) a backend set when something wasn't injected.
6
+ const backends = search && fetchPage && headlessFetch ? undefined : createBackendSet(backendConfig);
6
7
  const resolvedSearch = search ?? backends.search;
7
8
  const resolvedFetchPage = fetchPage ?? backends.fetchPage;
8
9
  const resolvedHeadlessFetch = headlessFetch ?? backends.headlessFetch;
9
10
  const worker = createResearchWorker({ search: resolvedSearch, fetchPage: resolvedFetchPage });
10
- return createResearchOrchestrator({
11
+ const orchestrator = createResearchOrchestrator({
11
12
  worker,
12
13
  fetchDirect: resolvedFetchPage,
13
14
  headlessFetch: resolvedHeadlessFetch
14
15
  });
16
+ return Object.assign(orchestrator, {
17
+ /** Releases the backend set this workflow created. Injected capabilities are left alone. */
18
+ async close() {
19
+ await backends?.close?.();
20
+ }
21
+ });
15
22
  }
@@ -1,4 +1,4 @@
1
- import type { SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
1
+ import type { Attempt, ResearchFetchInput, SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
2
2
  import type { ResearchEvidence, ResearchOrchestratorDecision, ResearchWorkerResult } from './research-types.js';
3
3
  import { type EvidenceCaveatReason } from './evidence-quality.js';
4
4
  export declare function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }: {
@@ -9,12 +9,8 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
9
9
  maxFetches: number;
10
10
  }) => Promise<ResearchWorkerResult>;
11
11
  };
12
- fetchDirect?: (input: {
13
- url: string;
14
- }) => Promise<WebFetchResponse>;
15
- headlessFetch: (input: {
16
- url: string;
17
- }) => Promise<WebFetchHeadlessResponse>;
12
+ fetchDirect?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
13
+ headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
18
14
  }): {
19
15
  run({ query }: {
20
16
  query: string;
@@ -23,6 +19,7 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
23
19
  evidence: ResearchEvidence[];
24
20
  workerPass: ResearchWorkerResult;
25
21
  metadata: {
22
+ attempts?: Attempt[] | undefined;
26
23
  searchPasses: number;
27
24
  fetchedPages: number;
28
25
  headlessAttempts: number;
@@ -31,5 +28,24 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
31
28
  fanoutProviders: SearchProviderName[] | undefined;
32
29
  fanoutSkipped: SearchProviderName[] | undefined;
33
30
  };
31
+ terminalFailure?: undefined;
32
+ } | {
33
+ decision: ResearchOrchestratorDecision;
34
+ evidence: never[];
35
+ workerPass: ResearchWorkerResult;
36
+ metadata: {
37
+ attempts?: Attempt[] | undefined;
38
+ searchPasses: number;
39
+ fetchedPages: number;
40
+ headlessAttempts: number;
41
+ exhaustedBudget: boolean;
42
+ caveatReasons: EvidenceCaveatReason[];
43
+ fanoutProviders: SearchProviderName[] | undefined;
44
+ fanoutSkipped: SearchProviderName[] | undefined;
45
+ };
46
+ terminalFailure: {
47
+ code: string;
48
+ message: string;
49
+ };
34
50
  }>;
35
51
  };
@@ -1,28 +1,30 @@
1
+ import { failureOf, isTerminalFailure } from '../backends/failure.js';
1
2
  import { rankEvidence } from './evidence-ranker.js';
2
3
  import { planSearchQueries } from './query-planner.js';
3
4
  import { classifySourceProfile } from './source-profile.js';
4
5
  import { extractDirectUrls } from './direct-url.js';
5
6
  import { decideNextResearchStep } from './stop-decider.js';
6
7
  import { analyzeEvidenceQuality } from './evidence-quality.js';
8
+ import { selectRelevantExcerpt } from '../extract/section-selector.js';
9
+ import { hasBotCheckContent } from '../extract/bot-check.js';
7
10
  const DEFAULT_MAX_PASSES = 3;
8
11
  const DEFAULT_MAX_FETCHES_PER_PASS = 4;
9
12
  const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
10
13
  function classifyEvidenceUrl(url) {
11
14
  return classifySourceProfile(url).sourceKind;
12
15
  }
13
- function summarizeText(text, maxLength = 180) {
14
- return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
15
- }
16
16
  function isReaderMethod(method) {
17
17
  return method === 'github' || method === 'pdf' || method === 'youtube';
18
18
  }
19
- function isBotCheckContent({ title = '', text }) {
20
- return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
19
+ function isBotCheckContent({ title = '', text, botCheck }) {
20
+ if (botCheck)
21
+ return true;
22
+ return hasBotCheckContent(`${title}\n${text}`);
21
23
  }
22
- function evidenceFromFetch(result) {
24
+ function evidenceFromFetch(result, query) {
23
25
  if (result.status !== 'ok' || !result.content?.text.trim())
24
26
  return null;
25
- if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
27
+ if (isBotCheckContent({ title: result.content.title, text: result.content.text, botCheck: result.content.botCheck }))
26
28
  return null;
27
29
  if (isReaderMethod(result.metadata.method)) {
28
30
  return {
@@ -39,22 +41,22 @@ function evidenceFromFetch(result) {
39
41
  url: result.url,
40
42
  sourceKind: classifyEvidenceUrl(result.url),
41
43
  method: result.metadata.method,
42
- summary: summarizeText(result.content.text),
43
- supports: [summarizeText(result.content.text, 120)]
44
+ summary: selectRelevantExcerpt(result.content.text, query, 180),
45
+ supports: [selectRelevantExcerpt(result.content.text, query, 120)]
44
46
  };
45
47
  }
46
- function evidenceFromHeadless(result) {
48
+ function evidenceFromHeadless(result, query) {
47
49
  if (result.status !== 'ok' || !result.content?.text.trim())
48
50
  return null;
49
- if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
51
+ if (isBotCheckContent({ title: result.content.title, text: result.content.text, botCheck: result.content.botCheck }))
50
52
  return null;
51
53
  return {
52
54
  title: result.content.title ?? result.url,
53
55
  url: result.url,
54
56
  sourceKind: classifyEvidenceUrl(result.url),
55
57
  method: 'headless',
56
- summary: summarizeText(result.content.text),
57
- supports: [summarizeText(result.content.text, 120)]
58
+ summary: selectRelevantExcerpt(result.content.text, query, 180),
59
+ supports: [selectRelevantExcerpt(result.content.text, query, 120)]
58
60
  };
59
61
  }
60
62
  function combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget }) {
@@ -79,7 +81,7 @@ function shouldRetryDirectWithHeadless(result, evidence) {
79
81
  return false;
80
82
  return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
81
83
  }
82
- function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped }) {
84
+ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped, attempts }) {
83
85
  return {
84
86
  searchPasses: previousQueries.length,
85
87
  fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
@@ -87,7 +89,8 @@ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutco
87
89
  exhaustedBudget,
88
90
  caveatReasons,
89
91
  fanoutProviders,
90
- fanoutSkipped
92
+ fanoutSkipped,
93
+ ...(attempts && attempts.length > 0 ? { attempts } : {})
91
94
  };
92
95
  }
93
96
  function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
@@ -114,26 +117,38 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
114
117
  const suggestedHeadlessUrls = [];
115
118
  let headlessAttempts = 0;
116
119
  let lastPass;
120
+ let searchCoveragePartial = false;
117
121
  const fanoutProvidersSeen = new Set();
118
122
  const fanoutSkippedSeen = new Set();
123
+ // Search and fetch attempts from every pass and direct URL, for verbose provenance.
124
+ const runAttempts = [];
119
125
  function fanoutSnapshot() {
120
126
  const providers = fanoutProvidersSeen.size ? [...fanoutProvidersSeen] : undefined;
121
127
  const skipped = [...fanoutSkippedSeen].filter((p) => !fanoutProvidersSeen.has(p));
122
- return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined };
128
+ return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined, attempts: [...runAttempts] };
123
129
  }
124
130
  if (fetchDirect) {
125
131
  for (const url of extractDirectUrls(query).slice(0, 3)) {
126
- const directResult = await fetchDirect({ url });
127
- const directEvidence = evidenceFromFetch(directResult);
132
+ const directResult = await fetchDirect({ url, query });
133
+ if (directResult.metadata.attempts)
134
+ runAttempts.push(...directResult.metadata.attempts);
135
+ const directEvidence = evidenceFromFetch(directResult, query);
128
136
  if (directEvidence) {
129
137
  allEvidence.push(directEvidence);
130
138
  continue;
131
139
  }
140
+ if (isTerminalFailure(failureOf(directResult))) {
141
+ allGaps.push({
142
+ kind: 'fetch-failed',
143
+ message: directResult.error?.message ?? `Direct URL fetch failed for ${directResult.url}`
144
+ });
145
+ continue;
146
+ }
132
147
  if (shouldRetryDirectWithHeadless(directResult, directEvidence)) {
133
148
  if (headlessAttempts < DEFAULT_MAX_HEADLESS_ATTEMPTS) {
134
149
  headlessAttempts++;
135
- const headlessResult = await headlessFetch({ url: directResult.url });
136
- const headlessEvidence = evidenceFromHeadless(headlessResult);
150
+ const headlessResult = await headlessFetch({ url: directResult.url, query });
151
+ const headlessEvidence = evidenceFromHeadless(headlessResult, query);
137
152
  if (headlessEvidence) {
138
153
  allEvidence.push(headlessEvidence);
139
154
  }
@@ -161,7 +176,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
161
176
  const quality = analyzeEvidenceQuality({
162
177
  evidence: ranked,
163
178
  gaps: allGaps,
164
- lowValueOutcomes: allLowValueOutcomes
179
+ lowValueOutcomes: allLowValueOutcomes,
180
+ partialSearchCoverage: searchCoveragePartial
165
181
  });
166
182
  return {
167
183
  decision: decisionForAnswer({ action: 'answer', query, ranked, exhaustedBudget: false }),
@@ -200,6 +216,21 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
200
216
  maxFetches: DEFAULT_MAX_FETCHES_PER_PASS
201
217
  });
202
218
  lastPass = pass;
219
+ if (pass.searchAttempts)
220
+ runAttempts.push(...pass.searchAttempts);
221
+ if (pass.fetchAttempts)
222
+ runAttempts.push(...pass.fetchAttempts);
223
+ if (pass.searchCoveragePartial)
224
+ searchCoveragePartial = true;
225
+ if (pass.terminalFailure) {
226
+ return {
227
+ decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked: [], exhaustedBudget: false }),
228
+ evidence: [],
229
+ workerPass: combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget: false }),
230
+ metadata: buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget: false, ...fanoutSnapshot() }),
231
+ terminalFailure: pass.terminalFailure
232
+ };
233
+ }
203
234
  pass.fanoutProviders?.forEach((p) => fanoutProvidersSeen.add(p));
204
235
  pass.fanoutSkipped?.forEach((p) => fanoutSkippedSeen.add(p));
205
236
  allEvidence.push(...pass.evidence);
@@ -211,7 +242,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
211
242
  const quality = analyzeEvidenceQuality({
212
243
  evidence: ranked,
213
244
  gaps: allGaps,
214
- lowValueOutcomes: allLowValueOutcomes
245
+ lowValueOutcomes: allLowValueOutcomes,
246
+ partialSearchCoverage: searchCoveragePartial
215
247
  });
216
248
  const decision = decideNextResearchStep({
217
249
  evidence: ranked,
@@ -224,15 +256,16 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
224
256
  });
225
257
  if (decision.action === 'headless') {
226
258
  headlessAttempts++;
227
- const headlessResult = await headlessFetch({ url: decision.url });
228
- const headlessEvidence = evidenceFromHeadless(headlessResult);
259
+ const headlessResult = await headlessFetch({ url: decision.url, query });
260
+ const headlessEvidence = evidenceFromHeadless(headlessResult, query);
229
261
  if (headlessEvidence) {
230
262
  allEvidence.push(headlessEvidence);
231
263
  const updatedRanked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
232
264
  const updatedQuality = analyzeEvidenceQuality({
233
265
  evidence: updatedRanked,
234
266
  gaps: allGaps,
235
- lowValueOutcomes: allLowValueOutcomes
267
+ lowValueOutcomes: allLowValueOutcomes,
268
+ partialSearchCoverage: searchCoveragePartial
236
269
  });
237
270
  const updatedDecision = decideNextResearchStep({
238
271
  evidence: updatedRanked,
@@ -328,7 +361,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
328
361
  const quality = analyzeEvidenceQuality({
329
362
  evidence: ranked,
330
363
  gaps: allGaps,
331
- lowValueOutcomes: allLowValueOutcomes
364
+ lowValueOutcomes: allLowValueOutcomes,
365
+ partialSearchCoverage: searchCoveragePartial
332
366
  });
333
367
  return {
334
368
  decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked, exhaustedBudget: true }),