@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +19 -4
  3. package/dist/backends/config.d.ts +40 -0
  4. package/dist/backends/config.js +140 -1
  5. package/dist/backends/factory.d.ts +17 -1
  6. package/dist/backends/factory.js +170 -85
  7. package/dist/backends/failure.d.ts +11 -0
  8. package/dist/backends/failure.js +34 -0
  9. package/dist/backends/fallback-policy.d.ts +33 -0
  10. package/dist/backends/fallback-policy.js +239 -0
  11. package/dist/backends/provider-failure.d.ts +21 -0
  12. package/dist/backends/provider-failure.js +111 -0
  13. package/dist/backends/provider-health.d.ts +29 -0
  14. package/dist/backends/provider-health.js +49 -0
  15. package/dist/commands/web-agent-config.d.ts +17 -1
  16. package/dist/commands/web-agent-config.js +131 -7
  17. package/dist/extension.d.ts +1 -0
  18. package/dist/extension.js +49 -3
  19. package/dist/fetch/destination-policy.d.ts +32 -0
  20. package/dist/fetch/destination-policy.js +24 -0
  21. package/dist/fetch/firecrawl-fetch.js +64 -45
  22. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  23. package/dist/fetch/guard-proxy-fetch.js +82 -0
  24. package/dist/fetch/guard-proxy.d.ts +58 -0
  25. package/dist/fetch/guard-proxy.js +420 -0
  26. package/dist/fetch/guarded-fetch.d.ts +7 -0
  27. package/dist/fetch/guarded-fetch.js +75 -0
  28. package/dist/fetch/headless-fetch.d.ts +17 -2
  29. package/dist/fetch/headless-fetch.js +181 -9
  30. package/dist/fetch/http-fetch.js +16 -1
  31. package/dist/fetch/network-guard.d.ts +82 -0
  32. package/dist/fetch/network-guard.js +275 -0
  33. package/dist/fetch/proxy-fetch.d.ts +22 -0
  34. package/dist/fetch/proxy-fetch.js +46 -0
  35. package/dist/jiti-compat-run.d.ts +1 -0
  36. package/dist/jiti-compat-run.js +9 -0
  37. package/dist/jiti-compat.d.ts +32 -0
  38. package/dist/jiti-compat.js +215 -0
  39. package/dist/orchestration/answer-synthesizer.js +2 -0
  40. package/dist/orchestration/evidence-quality.d.ts +3 -2
  41. package/dist/orchestration/evidence-quality.js +2 -1
  42. package/dist/orchestration/index.d.ts +23 -0
  43. package/dist/orchestration/index.js +9 -2
  44. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  45. package/dist/orchestration/research-orchestrator.js +40 -7
  46. package/dist/orchestration/research-types.d.ts +13 -1
  47. package/dist/orchestration/research-worker.js +38 -3
  48. package/dist/orchestration/stop-decider.js +3 -1
  49. package/dist/presentation/config-store.js +10 -0
  50. package/dist/presentation/explore-presentation.js +3 -1
  51. package/dist/presentation/fetch-presentation.js +16 -9
  52. package/dist/presentation/search-presentation.d.ts +2 -1
  53. package/dist/presentation/search-presentation.js +13 -1
  54. package/dist/readers/youtube-reader.d.ts +3 -1
  55. package/dist/readers/youtube-reader.js +11 -3
  56. package/dist/search/brave.d.ts +1 -2
  57. package/dist/search/brave.js +23 -80
  58. package/dist/search/duckduckgo.d.ts +7 -3
  59. package/dist/search/duckduckgo.js +17 -18
  60. package/dist/search/exa.d.ts +1 -2
  61. package/dist/search/exa.js +15 -76
  62. package/dist/search/fanout.d.ts +12 -0
  63. package/dist/search/fanout.js +86 -47
  64. package/dist/search/json-provider.d.ts +32 -0
  65. package/dist/search/json-provider.js +76 -0
  66. package/dist/search/searxng.d.ts +1 -2
  67. package/dist/search/searxng.js +15 -57
  68. package/dist/search/tavily.d.ts +1 -2
  69. package/dist/search/tavily.js +17 -74
  70. package/dist/search/youcom.d.ts +1 -2
  71. package/dist/search/youcom.js +15 -76
  72. package/dist/tools/web-explore.d.ts +9 -0
  73. package/dist/tools/web-explore.js +16 -2
  74. package/dist/tools/web-search.js +41 -103
  75. package/dist/types.d.ts +40 -0
  76. package/package.json +4 -3
  77. 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
- 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, 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';