@demigodmode/pi-web-agent 1.10.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 +27 -0
- package/README.md +19 -4
- package/dist/backends/config.d.ts +40 -0
- package/dist/backends/config.js +140 -1
- package/dist/backends/factory.d.ts +17 -1
- package/dist/backends/factory.js +170 -85
- 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 +17 -1
- package/dist/commands/web-agent-config.js +131 -7
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +49 -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 +17 -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/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -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 +10 -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/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- 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 +4 -3
- package/scripts/patch-jiti-compat.mjs +52 -8
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
|
2
|
+
import { stripProxyCredentials } from '../backends/config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve proxy credentials from the config, falling back to environment
|
|
5
|
+
* variables so secrets can stay out of config files (same policy as API keys).
|
|
6
|
+
*/
|
|
7
|
+
export function resolveProxyCredentials(proxy, env = process.env) {
|
|
8
|
+
const username = proxy.username ?? env.PI_WEB_AGENT_PROXY_USERNAME;
|
|
9
|
+
const password = proxy.password ?? env.PI_WEB_AGENT_PROXY_PASSWORD;
|
|
10
|
+
return {
|
|
11
|
+
username: username?.trim() ? username : undefined,
|
|
12
|
+
password: password ? password : undefined
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a fetch implementation that routes all traffic through the configured
|
|
17
|
+
* HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
|
|
18
|
+
* ProxyAgent dispatcher while keeping the standard fetch API surface.
|
|
19
|
+
*/
|
|
20
|
+
export function createProxyFetch(proxy, options = {}) {
|
|
21
|
+
const credentials = resolveProxyCredentials(proxy);
|
|
22
|
+
const agent = new ProxyAgent({
|
|
23
|
+
// Never trust credentials embedded in the URL; they come from config fields
|
|
24
|
+
// or the PI_WEB_AGENT_PROXY_* env vars via resolveProxyCredentials.
|
|
25
|
+
uri: stripProxyCredentials(proxy.url),
|
|
26
|
+
// undici's `token` option is used verbatim as the Proxy-Authorization header
|
|
27
|
+
// value for both CONNECT tunnels and forwarded HTTP requests.
|
|
28
|
+
...(credentials.username !== undefined
|
|
29
|
+
? {
|
|
30
|
+
token: `Basic ${Buffer.from(credentials.password !== undefined
|
|
31
|
+
? `${credentials.username}:${credentials.password}`
|
|
32
|
+
: `${credentials.username}:`).toString('base64')}`
|
|
33
|
+
}
|
|
34
|
+
: {}),
|
|
35
|
+
...(options.tls ? { requestTls: { rejectUnauthorized: options.tls.rejectUnauthorized } } : {})
|
|
36
|
+
});
|
|
37
|
+
// undici's Request/Response types are structurally near-identical to Node's
|
|
38
|
+
// global fetch types but not nominatively identical, so the wrapper is cast
|
|
39
|
+
// to the standard fetch signature (runtime behavior is identical: Node's
|
|
40
|
+
// global fetch is built on this same undici engine).
|
|
41
|
+
const proxyFetch = (input, init) => undiciFetch(input, {
|
|
42
|
+
...init,
|
|
43
|
+
dispatcher: agent
|
|
44
|
+
});
|
|
45
|
+
return proxyFetch;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ensureJitiCompat } from './jiti-compat.js';
|
|
2
|
+
// Side-effect-only module. It exists so `extension.ts` can run the compat
|
|
3
|
+
// patch as its *first* import: ESM evaluates a module's dependency subtree,
|
|
4
|
+
// including that module's body, before moving on to the next import. A plain
|
|
5
|
+
// `ensureJitiCompat()` call in the extension body would run too late, after
|
|
6
|
+
// jsdom (and therefore tr46/cssstyle) had already been evaluated.
|
|
7
|
+
//
|
|
8
|
+
// Keep this import first in extension.ts.
|
|
9
|
+
ensureJitiCompat();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem/resolver seam so tests can point this at a throwaway directory
|
|
3
|
+
* instead of the real `node_modules/tr46` and `node_modules/cssstyle`. All
|
|
4
|
+
* production call sites use the defaults (real `fs` + real module
|
|
5
|
+
* resolution) and never pass this in.
|
|
6
|
+
*/
|
|
7
|
+
export type JitiCompatDeps = {
|
|
8
|
+
/**
|
|
9
|
+
* Resolve `specifier` as `fromFile` would. Omitting `fromFile` resolves from
|
|
10
|
+
* this module, which is only correct when nothing else claims the package.
|
|
11
|
+
*/
|
|
12
|
+
resolve: (specifier: string, fromFile?: string) => string;
|
|
13
|
+
existsSync: (path: string) => boolean;
|
|
14
|
+
readFileSync: (path: string) => string;
|
|
15
|
+
writeFileSync: (path: string, contents: string) => void;
|
|
16
|
+
};
|
|
17
|
+
export type JitiCompatStatus = {
|
|
18
|
+
/** Files that still need patching. Empty means the tree is healthy. */
|
|
19
|
+
pending: string[];
|
|
20
|
+
/** Files patched during this call. */
|
|
21
|
+
patched: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Apply both patches if they are missing. Safe to call repeatedly: an already
|
|
25
|
+
* patched tree is a few `readFileSync` calls and no writes. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
|
|
28
|
+
/**
|
|
29
|
+
* Read-only view of the same checks, for `/web-agent doctor`. Never throws and
|
|
30
|
+
* never writes.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Works around two incompatibilities between pi's extension loader (a patched
|
|
6
|
+
* jiti) and jsdom's dependency tree:
|
|
7
|
+
*
|
|
8
|
+
* 1. jiti can't resolve the trailing-slash bare specifier require("punycode/")
|
|
9
|
+
* used by tr46.
|
|
10
|
+
* 2. jiti wraps `module.exports = new Set(...)` (cssstyle) in a Proxy, which
|
|
11
|
+
* breaks native Set methods on the exported value.
|
|
12
|
+
*
|
|
13
|
+
* Both files live in the shared `~/.pi/agent/npm/node_modules` tree, so
|
|
14
|
+
* installing or updating any *other* pi extension re-extracts them and reverts
|
|
15
|
+
* the patch. A postinstall hook alone therefore can't keep this healthy, which
|
|
16
|
+
* is why `ensureJitiCompat()` also runs on extension load, before jsdom is
|
|
17
|
+
* evaluated. See https://github.com/demigodmode/pi-web-agent/issues/34.
|
|
18
|
+
*
|
|
19
|
+
* scripts/patch-jiti-compat.mjs duplicates this logic for the postinstall
|
|
20
|
+
* hook. That hook runs before `npm run build`, so it cannot import dist/.
|
|
21
|
+
* Keep the two in sync.
|
|
22
|
+
*/
|
|
23
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
24
|
+
/**
|
|
25
|
+
* Write via a temp file + rename rather than in place. Two reasons, both of
|
|
26
|
+
* which bite harder now that this runs on every extension load instead of once
|
|
27
|
+
* per install:
|
|
28
|
+
*
|
|
29
|
+
* - `writeFileSync` truncates first. A crash, a full disk, or an OOM kill
|
|
30
|
+
* mid-write leaves a half-written file that still contains the marker
|
|
31
|
+
* comment, so every later run would skip it as "already patched" and the
|
|
32
|
+
* doctor would report a broken tree as healthy.
|
|
33
|
+
* - Another Pi session can be `require`-ing the same file while we write it.
|
|
34
|
+
* `rename` is atomic within a filesystem, so readers see the old file or
|
|
35
|
+
* the new one, never a partial one.
|
|
36
|
+
*
|
|
37
|
+
* The rename also breaks a pnpm-style hardlink instead of writing through it
|
|
38
|
+
* into the shared content-addressable store.
|
|
39
|
+
*/
|
|
40
|
+
function writeFileAtomic(path, contents) {
|
|
41
|
+
const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(temp, contents);
|
|
44
|
+
renameSync(temp, path);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(temp))
|
|
49
|
+
unlinkSync(temp);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best effort. Leaving a stray temp file is better than masking the
|
|
53
|
+
// original write failure.
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const defaultDeps = {
|
|
59
|
+
resolve: (specifier, fromFile) => (fromFile ? createRequire(fromFile) : requireFromHere).resolve(specifier),
|
|
60
|
+
existsSync,
|
|
61
|
+
readFileSync: (path) => readFileSync(path, 'utf8'),
|
|
62
|
+
writeFileSync: writeFileAtomic
|
|
63
|
+
};
|
|
64
|
+
const SET_SHIM_MARKER = 'pi/jiti workaround';
|
|
65
|
+
const SET_SHIM = `
|
|
66
|
+
// ${SET_SHIM_MARKER}: expose bound native Set methods as own properties so a
|
|
67
|
+
// Proxy wrapper around this export does not break Set brand checks.
|
|
68
|
+
for (const k of ["has", "add", "delete", "forEach", "keys", "values", "entries"]) {
|
|
69
|
+
module.exports[k] = Set.prototype[k].bind(module.exports);
|
|
70
|
+
}
|
|
71
|
+
module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exports);
|
|
72
|
+
`;
|
|
73
|
+
const PUNYCODE_SPECIFIER = 'require("punycode/")';
|
|
74
|
+
const PUNYCODE_REPLACEMENT = 'require("punycode/punycode.js")';
|
|
75
|
+
function findPackageRoot(deps, entryFile, packageName) {
|
|
76
|
+
let directory = dirname(entryFile);
|
|
77
|
+
for (;;) {
|
|
78
|
+
const manifestFile = join(directory, 'package.json');
|
|
79
|
+
if (deps.existsSync(manifestFile)) {
|
|
80
|
+
try {
|
|
81
|
+
const manifest = JSON.parse(deps.readFileSync(manifestFile));
|
|
82
|
+
if (manifest.name === packageName)
|
|
83
|
+
return directory;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// An unreadable or malformed package.json on the way up is not our
|
|
87
|
+
// problem to report. Keep walking.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const parent = dirname(directory);
|
|
91
|
+
if (parent === directory) {
|
|
92
|
+
throw new Error(`could not find the ${packageName} package root from ${entryFile}`);
|
|
93
|
+
}
|
|
94
|
+
directory = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the copies jsdom actually loads, not whichever copy happens to sit
|
|
99
|
+
* highest in the tree.
|
|
100
|
+
*
|
|
101
|
+
* `~/.pi/agent/npm/node_modules` is shared by every pi extension, so version
|
|
102
|
+
* conflicts routinely push a second copy of a package into a nested
|
|
103
|
+
* `node_modules`. Resolving `cssstyle` or `tr46` from *this* module can then
|
|
104
|
+
* find a hoisted copy that jsdom never requires: the patch lands on the wrong
|
|
105
|
+
* file, the load still fails, and the doctor reports a healthy tree because it
|
|
106
|
+
* checked the same wrong file. Walk the real import chain instead
|
|
107
|
+
* (jsdom -> cssstyle, jsdom -> whatwg-url -> tr46) and only fall back to a
|
|
108
|
+
* direct resolve when jsdom is not resolvable at all.
|
|
109
|
+
*/
|
|
110
|
+
function resolveFromJsdom(deps, specifier, via) {
|
|
111
|
+
try {
|
|
112
|
+
const jsdomEntry = deps.resolve('jsdom');
|
|
113
|
+
const importer = via ? deps.resolve(via, jsdomEntry) : jsdomEntry;
|
|
114
|
+
return deps.resolve(specifier, importer);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// jsdom (or the intermediate package) is not resolvable from here, e.g. a
|
|
118
|
+
// layout we do not recognize. Fall back to a plain resolve rather than
|
|
119
|
+
// giving up on the patch entirely.
|
|
120
|
+
return deps.resolve(specifier);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function tr46Entry(deps) {
|
|
124
|
+
return resolveFromJsdom(deps, 'tr46', 'whatwg-url');
|
|
125
|
+
}
|
|
126
|
+
function cssstyleTargets(deps) {
|
|
127
|
+
const packageRoot = findPackageRoot(deps, resolveFromJsdom(deps, 'cssstyle'), 'cssstyle');
|
|
128
|
+
return [
|
|
129
|
+
join(packageRoot, 'lib', 'allExtraProperties.js'),
|
|
130
|
+
join(packageRoot, 'lib', 'generated', 'allProperties.js'),
|
|
131
|
+
join(packageRoot, 'lib', 'generated', 'implementedProperties.js')
|
|
132
|
+
].map((file) => ({
|
|
133
|
+
file,
|
|
134
|
+
label: `cssstyle/${relative(packageRoot, file).split(sep).join('/')}`
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply both patches if they are missing. Safe to call repeatedly: an already
|
|
139
|
+
* patched tree is a few `readFileSync` calls and no writes. Never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function ensureJitiCompat(deps = defaultDeps) {
|
|
142
|
+
const status = { pending: [], patched: [] };
|
|
143
|
+
try {
|
|
144
|
+
const file = tr46Entry(deps);
|
|
145
|
+
const contents = deps.readFileSync(file);
|
|
146
|
+
if (contents.includes(PUNYCODE_SPECIFIER)) {
|
|
147
|
+
deps.writeFileSync(file, contents.replaceAll(PUNYCODE_SPECIFIER, PUNYCODE_REPLACEMENT));
|
|
148
|
+
status.patched.push('tr46/index.js');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A read-only install, a missing dependency, or a future dependency bump
|
|
153
|
+
// that changes the shape. Report it rather than blocking extension load.
|
|
154
|
+
status.pending.push('tr46/index.js');
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
158
|
+
// Guard the read too: one unreadable file (EACCES, odd permissions) must
|
|
159
|
+
// not abandon the other two, which may be perfectly writable.
|
|
160
|
+
try {
|
|
161
|
+
if (!deps.existsSync(file))
|
|
162
|
+
continue;
|
|
163
|
+
const contents = deps.readFileSync(file);
|
|
164
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
165
|
+
continue;
|
|
166
|
+
if (!contents.includes('module.exports = new Set('))
|
|
167
|
+
continue;
|
|
168
|
+
deps.writeFileSync(file, contents + SET_SHIM);
|
|
169
|
+
status.patched.push(label);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
status.pending.push(label);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
status.pending.push('cssstyle');
|
|
178
|
+
}
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read-only view of the same checks, for `/web-agent doctor`. Never throws and
|
|
183
|
+
* never writes.
|
|
184
|
+
*/
|
|
185
|
+
export function checkJitiCompat(deps = defaultDeps) {
|
|
186
|
+
const status = { pending: [], patched: [] };
|
|
187
|
+
try {
|
|
188
|
+
const contents = deps.readFileSync(tr46Entry(deps));
|
|
189
|
+
if (contents.includes(PUNYCODE_SPECIFIER))
|
|
190
|
+
status.pending.push('tr46/index.js');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
status.pending.push('tr46/index.js');
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
197
|
+
try {
|
|
198
|
+
if (!deps.existsSync(file))
|
|
199
|
+
continue;
|
|
200
|
+
const contents = deps.readFileSync(file);
|
|
201
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
202
|
+
continue;
|
|
203
|
+
if (contents.includes('module.exports = new Set('))
|
|
204
|
+
status.pending.push(label);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
status.pending.push(label);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
status.pending.push('cssstyle');
|
|
213
|
+
}
|
|
214
|
+
return status;
|
|
215
|
+
}
|
|
@@ -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,
|
|
@@ -19,6 +19,7 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
|
|
|
19
19
|
evidence: import("./research-types.js").ResearchEvidence[];
|
|
20
20
|
workerPass: import("./research-types.js").ResearchWorkerResult;
|
|
21
21
|
metadata: {
|
|
22
|
+
attempts?: import("../types.js").Attempt[] | undefined;
|
|
22
23
|
searchPasses: number;
|
|
23
24
|
fetchedPages: number;
|
|
24
25
|
headlessAttempts: number;
|
|
@@ -27,5 +28,27 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
|
|
|
27
28
|
fanoutProviders: import("../types.js").SearchProviderName[] | undefined;
|
|
28
29
|
fanoutSkipped: import("../types.js").SearchProviderName[] | undefined;
|
|
29
30
|
};
|
|
31
|
+
terminalFailure?: undefined;
|
|
32
|
+
} | {
|
|
33
|
+
decision: import("./research-types.js").ResearchOrchestratorDecision;
|
|
34
|
+
evidence: never[];
|
|
35
|
+
workerPass: import("./research-types.js").ResearchWorkerResult;
|
|
36
|
+
metadata: {
|
|
37
|
+
attempts?: import("../types.js").Attempt[] | undefined;
|
|
38
|
+
searchPasses: number;
|
|
39
|
+
fetchedPages: number;
|
|
40
|
+
headlessAttempts: number;
|
|
41
|
+
exhaustedBudget: boolean;
|
|
42
|
+
caveatReasons: import("./evidence-quality.js").EvidenceCaveatReason[];
|
|
43
|
+
fanoutProviders: import("../types.js").SearchProviderName[] | undefined;
|
|
44
|
+
fanoutSkipped: import("../types.js").SearchProviderName[] | undefined;
|
|
45
|
+
};
|
|
46
|
+
terminalFailure: {
|
|
47
|
+
code: string;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
30
50
|
}>;
|
|
51
|
+
} & {
|
|
52
|
+
/** Releases the backend set this workflow created. Injected capabilities are left alone. */
|
|
53
|
+
close(): Promise<void>;
|
|
31
54
|
};
|
|
@@ -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, 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 }: {
|
|
@@ -23,6 +23,7 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
|
|
|
23
23
|
evidence: ResearchEvidence[];
|
|
24
24
|
workerPass: ResearchWorkerResult;
|
|
25
25
|
metadata: {
|
|
26
|
+
attempts?: Attempt[] | undefined;
|
|
26
27
|
searchPasses: number;
|
|
27
28
|
fetchedPages: number;
|
|
28
29
|
headlessAttempts: number;
|
|
@@ -31,5 +32,24 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
|
|
|
31
32
|
fanoutProviders: SearchProviderName[] | undefined;
|
|
32
33
|
fanoutSkipped: SearchProviderName[] | undefined;
|
|
33
34
|
};
|
|
35
|
+
terminalFailure?: undefined;
|
|
36
|
+
} | {
|
|
37
|
+
decision: ResearchOrchestratorDecision;
|
|
38
|
+
evidence: never[];
|
|
39
|
+
workerPass: ResearchWorkerResult;
|
|
40
|
+
metadata: {
|
|
41
|
+
attempts?: Attempt[] | undefined;
|
|
42
|
+
searchPasses: number;
|
|
43
|
+
fetchedPages: number;
|
|
44
|
+
headlessAttempts: number;
|
|
45
|
+
exhaustedBudget: boolean;
|
|
46
|
+
caveatReasons: EvidenceCaveatReason[];
|
|
47
|
+
fanoutProviders: SearchProviderName[] | undefined;
|
|
48
|
+
fanoutSkipped: SearchProviderName[] | undefined;
|
|
49
|
+
};
|
|
50
|
+
terminalFailure: {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
34
54
|
}>;
|
|
35
55
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
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';
|
|
@@ -79,7 +80,7 @@ function shouldRetryDirectWithHeadless(result, evidence) {
|
|
|
79
80
|
return false;
|
|
80
81
|
return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
|
|
81
82
|
}
|
|
82
|
-
function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped }) {
|
|
83
|
+
function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped, attempts }) {
|
|
83
84
|
return {
|
|
84
85
|
searchPasses: previousQueries.length,
|
|
85
86
|
fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
|
|
@@ -87,7 +88,8 @@ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutco
|
|
|
87
88
|
exhaustedBudget,
|
|
88
89
|
caveatReasons,
|
|
89
90
|
fanoutProviders,
|
|
90
|
-
fanoutSkipped
|
|
91
|
+
fanoutSkipped,
|
|
92
|
+
...(attempts && attempts.length > 0 ? { attempts } : {})
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
95
|
function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
|
|
@@ -114,21 +116,33 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
114
116
|
const suggestedHeadlessUrls = [];
|
|
115
117
|
let headlessAttempts = 0;
|
|
116
118
|
let lastPass;
|
|
119
|
+
let searchCoveragePartial = false;
|
|
117
120
|
const fanoutProvidersSeen = new Set();
|
|
118
121
|
const fanoutSkippedSeen = new Set();
|
|
122
|
+
// Search and fetch attempts from every pass and direct URL, for verbose provenance.
|
|
123
|
+
const runAttempts = [];
|
|
119
124
|
function fanoutSnapshot() {
|
|
120
125
|
const providers = fanoutProvidersSeen.size ? [...fanoutProvidersSeen] : undefined;
|
|
121
126
|
const skipped = [...fanoutSkippedSeen].filter((p) => !fanoutProvidersSeen.has(p));
|
|
122
|
-
return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined };
|
|
127
|
+
return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined, attempts: [...runAttempts] };
|
|
123
128
|
}
|
|
124
129
|
if (fetchDirect) {
|
|
125
130
|
for (const url of extractDirectUrls(query).slice(0, 3)) {
|
|
126
131
|
const directResult = await fetchDirect({ url });
|
|
132
|
+
if (directResult.metadata.attempts)
|
|
133
|
+
runAttempts.push(...directResult.metadata.attempts);
|
|
127
134
|
const directEvidence = evidenceFromFetch(directResult);
|
|
128
135
|
if (directEvidence) {
|
|
129
136
|
allEvidence.push(directEvidence);
|
|
130
137
|
continue;
|
|
131
138
|
}
|
|
139
|
+
if (isTerminalFailure(failureOf(directResult))) {
|
|
140
|
+
allGaps.push({
|
|
141
|
+
kind: 'fetch-failed',
|
|
142
|
+
message: directResult.error?.message ?? `Direct URL fetch failed for ${directResult.url}`
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
132
146
|
if (shouldRetryDirectWithHeadless(directResult, directEvidence)) {
|
|
133
147
|
if (headlessAttempts < DEFAULT_MAX_HEADLESS_ATTEMPTS) {
|
|
134
148
|
headlessAttempts++;
|
|
@@ -161,7 +175,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
161
175
|
const quality = analyzeEvidenceQuality({
|
|
162
176
|
evidence: ranked,
|
|
163
177
|
gaps: allGaps,
|
|
164
|
-
lowValueOutcomes: allLowValueOutcomes
|
|
178
|
+
lowValueOutcomes: allLowValueOutcomes,
|
|
179
|
+
partialSearchCoverage: searchCoveragePartial
|
|
165
180
|
});
|
|
166
181
|
return {
|
|
167
182
|
decision: decisionForAnswer({ action: 'answer', query, ranked, exhaustedBudget: false }),
|
|
@@ -200,6 +215,21 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
200
215
|
maxFetches: DEFAULT_MAX_FETCHES_PER_PASS
|
|
201
216
|
});
|
|
202
217
|
lastPass = pass;
|
|
218
|
+
if (pass.searchAttempts)
|
|
219
|
+
runAttempts.push(...pass.searchAttempts);
|
|
220
|
+
if (pass.fetchAttempts)
|
|
221
|
+
runAttempts.push(...pass.fetchAttempts);
|
|
222
|
+
if (pass.searchCoveragePartial)
|
|
223
|
+
searchCoveragePartial = true;
|
|
224
|
+
if (pass.terminalFailure) {
|
|
225
|
+
return {
|
|
226
|
+
decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked: [], exhaustedBudget: false }),
|
|
227
|
+
evidence: [],
|
|
228
|
+
workerPass: combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget: false }),
|
|
229
|
+
metadata: buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget: false, ...fanoutSnapshot() }),
|
|
230
|
+
terminalFailure: pass.terminalFailure
|
|
231
|
+
};
|
|
232
|
+
}
|
|
203
233
|
pass.fanoutProviders?.forEach((p) => fanoutProvidersSeen.add(p));
|
|
204
234
|
pass.fanoutSkipped?.forEach((p) => fanoutSkippedSeen.add(p));
|
|
205
235
|
allEvidence.push(...pass.evidence);
|
|
@@ -211,7 +241,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
211
241
|
const quality = analyzeEvidenceQuality({
|
|
212
242
|
evidence: ranked,
|
|
213
243
|
gaps: allGaps,
|
|
214
|
-
lowValueOutcomes: allLowValueOutcomes
|
|
244
|
+
lowValueOutcomes: allLowValueOutcomes,
|
|
245
|
+
partialSearchCoverage: searchCoveragePartial
|
|
215
246
|
});
|
|
216
247
|
const decision = decideNextResearchStep({
|
|
217
248
|
evidence: ranked,
|
|
@@ -232,7 +263,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
232
263
|
const updatedQuality = analyzeEvidenceQuality({
|
|
233
264
|
evidence: updatedRanked,
|
|
234
265
|
gaps: allGaps,
|
|
235
|
-
lowValueOutcomes: allLowValueOutcomes
|
|
266
|
+
lowValueOutcomes: allLowValueOutcomes,
|
|
267
|
+
partialSearchCoverage: searchCoveragePartial
|
|
236
268
|
});
|
|
237
269
|
const updatedDecision = decideNextResearchStep({
|
|
238
270
|
evidence: updatedRanked,
|
|
@@ -328,7 +360,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
328
360
|
const quality = analyzeEvidenceQuality({
|
|
329
361
|
evidence: ranked,
|
|
330
362
|
gaps: allGaps,
|
|
331
|
-
lowValueOutcomes: allLowValueOutcomes
|
|
363
|
+
lowValueOutcomes: allLowValueOutcomes,
|
|
364
|
+
partialSearchCoverage: searchCoveragePartial
|
|
332
365
|
});
|
|
333
366
|
return {
|
|
334
367
|
decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked, exhaustedBudget: true }),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SearchProviderName } from '../types.js';
|
|
1
|
+
import type { Attempt, SearchProviderName } from '../types.js';
|
|
2
2
|
export type ResearchSourceKind = 'primary-content' | 'official-docs' | 'official-api' | 'official-discussion' | 'community' | 'issue-thread' | 'package-page' | 'other';
|
|
3
3
|
export type ResearchMethod = 'search' | 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
|
|
4
4
|
export type ResearchEvidence = {
|
|
@@ -27,6 +27,13 @@ export type ResearchWorkerResult = {
|
|
|
27
27
|
exhaustedBudget: boolean;
|
|
28
28
|
fanoutProviders?: SearchProviderName[];
|
|
29
29
|
fanoutSkipped?: SearchProviderName[];
|
|
30
|
+
searchCoveragePartial?: boolean;
|
|
31
|
+
searchAttempts?: Attempt[];
|
|
32
|
+
fetchAttempts?: Attempt[];
|
|
33
|
+
terminalFailure?: {
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
30
37
|
};
|
|
31
38
|
export type ResearchRunMetadata = {
|
|
32
39
|
searchPasses: number;
|
|
@@ -35,6 +42,11 @@ export type ResearchRunMetadata = {
|
|
|
35
42
|
exhaustedBudget: boolean;
|
|
36
43
|
fanoutProviders?: SearchProviderName[];
|
|
37
44
|
fanoutSkipped?: SearchProviderName[];
|
|
45
|
+
searchCoveragePartial?: boolean;
|
|
46
|
+
terminalFailure?: {
|
|
47
|
+
code: string;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
38
50
|
};
|
|
39
51
|
export type ResearchOrchestratorDecision = {
|
|
40
52
|
action: 'answer';
|