@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.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/doctor.js +27 -29
- package/dist/backends/factory.d.ts +18 -7
- package/dist/backends/factory.js +126 -99
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +31 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +110 -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/extract/bot-check.d.ts +1 -0
- package/dist/extract/bot-check.js +10 -0
- package/dist/extract/readability.d.ts +4 -0
- package/dist/extract/readability.js +67 -3
- package/dist/extract/section-selector.d.ts +14 -0
- package/dist/extract/section-selector.js +233 -0
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.d.ts +1 -1
- package/dist/fetch/firecrawl-fetch.js +74 -46
- 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 +11 -2
- package/dist/fetch/headless-fetch.js +190 -13
- package/dist/fetch/http-fetch.d.ts +1 -1
- package/dist/fetch/http-fetch.js +29 -8
- 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 +26 -7
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +23 -7
- package/dist/orchestration/research-orchestrator.js +60 -26
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.d.ts +2 -4
- package/dist/orchestration/research-worker.js +51 -15
- 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/readers/resolver.d.ts +3 -7
- 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 +4 -2
- package/dist/search/youcom.js +49 -75
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-fetch-headless.d.ts +3 -5
- package/dist/tools/web-fetch-headless.js +3 -3
- package/dist/tools/web-fetch.d.ts +3 -5
- package/dist/tools/web-fetch.js +3 -3
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +48 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
43
|
-
supports: [
|
|
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:
|
|
57
|
-
supports: [
|
|
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
|
-
|
|
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 }),
|