@demigodmode/pi-web-agent 1.12.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 CHANGED
@@ -18,6 +18,21 @@ The format is intentionally simple and release-oriented.
18
18
  ### Breaking
19
19
  - None.
20
20
 
21
+ ## [1.13.0] - 2026-09-24
22
+ ### Added
23
+ - `web_explore` now uses the research question to pick relevant sections from long HTML pages instead of stopping at the first 4,000 characters. It can reach an answer buried later in a page, whether the page came through HTTP, headless browsing, or Firecrawl. Direct `web_fetch` output is unchanged. (#36)
24
+
25
+ ### Changed
26
+ - You.com search and `/web-agent doctor` now use the documented `/v1/search` endpoint. Web and news results appear in the usual search list, with web results first. Result descriptions provide the snippets when present. (#60)
27
+
28
+ ### Fixed
29
+ - Research still excludes recognized bot-check pages when their verification message sits outside the section chosen for the question. That page cannot be used as evidence just because another section looks relevant. (#36)
30
+ - `/web-agent doctor` no longer repeats the default DuckDuckGo and HTTP backends after listing them in the config summary. It still shows checks and warnings for backends that need them. (#64)
31
+ - On pages with several `<article>` sections and no `<main>`, query-based reads now search across the articles. A match in the first article no longer hides an answer in a later one. (#71)
32
+
33
+ ### Breaking
34
+ - None.
35
+
21
36
  ## [1.12.0] - 2026-09-17
22
37
  ### Added
23
38
  - A network allow list, under Settings → Backends → Network allow list. web_explore now refuses private and local addresses when the link came from the model or a page it read, so if you genuinely want it to read something on your own network (an internal docs site, a service on localhost), add that range here as a CIDR like `10.0.0.0/24`. Entries are checked when you save, and ones that would allow everything (`0.0.0.0/0`, `::/0`) are rejected, since that would quietly turn the protection off. It is also the fix if every fetch suddenly fails: some proxy apps run in fake-IP mode and make every site look like it lives in `198.18.0.0/15`, and adding that range gets you going again. (#53)
package/README.md CHANGED
@@ -34,6 +34,8 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
34
34
  - **Six search backends.** DuckDuckGo (keyless default), SearXNG, Brave, You.com, Exa, Tavily.
35
35
  - **Optional search fanout.** Query several backends at once, dedupe, and rank pages that more than one provider agreed on to the top. Off by default; flip it to `on` or `auto`.
36
36
  - **Honest by default.** Weak, narrow, blocked, or cautionary evidence gets flagged instead of dressed up as confidence.
37
+ - **Safe with untrusted pages.** Links the model picks, or finds on a page it read, can't reach localhost, your LAN, or cloud metadata endpoints. That includes redirects and everything a headless page loads. Addresses you configure yourself aren't affected, and an allow list covers the private ranges you do want.
38
+ - **Fallback that knows why.** Rate-limited or misconfigured backends get skipped for a while, flaky ones get one retry, and answers say when some search backends were unavailable.
37
39
  - **Bounded output.** `compact` / `preview` / `verbose` transcript modes.
38
40
  - **Zero-config to start.** Runs keyless out of the box (DuckDuckGo search, local browser, the built-in readers). Opt into hosted backends, fallback, search fanout, and per-tool output modes through config when you want more control.
39
41
 
@@ -1,3 +1,4 @@
1
+ import { normalizeYouComResults, YOUCOM_SEARCH_URL } from '../search/youcom.js';
1
2
  function withTimeout(timeoutMs) {
2
3
  const controller = new AbortController();
3
4
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -13,7 +14,7 @@ function braveDoctorUrl() {
13
14
  return url.toString();
14
15
  }
15
16
  function youcomDoctorBody() {
16
- return JSON.stringify({ query: 'pi-web-agent-doctor', max_results: 1 });
17
+ return JSON.stringify({ query: 'pi-web-agent-doctor', count: 1 });
17
18
  }
18
19
  function exaDoctorBody() {
19
20
  return JSON.stringify({ query: 'pi-web-agent-doctor', numResults: 1 });
@@ -42,10 +43,7 @@ function firecrawlDoctorBody(options = {}) {
42
43
  }
43
44
  export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
44
45
  const lines = [];
45
- if (config.search.provider === 'duckduckgo') {
46
- lines.push('search backend: duckduckgo');
47
- }
48
- else if (config.search.provider === 'brave') {
46
+ if (config.search.provider === 'brave') {
49
47
  const apiKey = process.env.PI_WEB_AGENT_BRAVE_API_KEY;
50
48
  if (!apiKey?.trim()) {
51
49
  lines.push('search backend: brave warning (missing PI_WEB_AGENT_BRAVE_API_KEY)');
@@ -86,7 +84,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
86
84
  else {
87
85
  const timeout = withTimeout(timeoutMs);
88
86
  try {
89
- const response = await fetchImpl('https://api.you.com/v1/agents/search', {
87
+ const response = await fetchImpl(YOUCOM_SEARCH_URL, {
90
88
  method: 'POST',
91
89
  headers: {
92
90
  Accept: 'application/json',
@@ -100,8 +98,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
100
98
  lines.push(`search backend: youcom warning (HTTP ${response.status})`);
101
99
  }
102
100
  else {
103
- const json = (await response.json());
104
- lines.push(Array.isArray(json.results)
101
+ const normalized = normalizeYouComResults(await response.json());
102
+ lines.push(normalized && (normalized.rawCount === 0 || normalized.results.length > 0)
105
103
  ? 'search backend: youcom ok'
106
104
  : 'search backend: youcom warning (unexpected response)');
107
105
  }
@@ -186,23 +184,26 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
186
184
  }
187
185
  }
188
186
  }
189
- else if (!config.search.baseUrl) {
190
- lines.push('search backend: searxng warning (missing baseUrl)');
191
- }
192
- else {
193
- const timeout = withTimeout(timeoutMs);
194
- try {
195
- const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl, config.search.options), { signal: timeout.signal });
196
- const json = (await response.json());
197
- lines.push(response.ok && Array.isArray(json.results)
198
- ? 'search backend: searxng ok'
199
- : 'search backend: searxng warning (unexpected response)');
200
- }
201
- catch (error) {
202
- lines.push(`search backend: searxng warning (${message(error)})`);
187
+ else if (config.search.provider === 'searxng') {
188
+ const baseUrl = config.search.baseUrl;
189
+ if (!baseUrl) {
190
+ lines.push('search backend: searxng warning (missing baseUrl)');
203
191
  }
204
- finally {
205
- timeout.done();
192
+ else {
193
+ const timeout = withTimeout(timeoutMs);
194
+ try {
195
+ const response = await fetchImpl(searxngDoctorUrl(baseUrl, config.search.options), { signal: timeout.signal });
196
+ const json = (await response.json());
197
+ lines.push(response.ok && Array.isArray(json.results)
198
+ ? 'search backend: searxng ok'
199
+ : 'search backend: searxng warning (unexpected response)');
200
+ }
201
+ catch (error) {
202
+ lines.push(`search backend: searxng warning (${message(error)})`);
203
+ }
204
+ finally {
205
+ timeout.done();
206
+ }
206
207
  }
207
208
  }
208
209
  if (config.search.fallback) {
@@ -241,13 +242,10 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
241
242
  }
242
243
  }
243
244
  }
244
- if (config.fetch.provider === 'http') {
245
- lines.push('fetch backend: http');
246
- }
247
- else if (!config.fetch.baseUrl) {
245
+ if (config.fetch.provider !== 'http' && !config.fetch.baseUrl) {
248
246
  lines.push('fetch backend: firecrawl warning (missing baseUrl)');
249
247
  }
250
- else {
248
+ else if (config.fetch.provider !== 'http') {
251
249
  const timeout = withTimeout(timeoutMs);
252
250
  try {
253
251
  const headers = { 'content-type': 'application/json' };
@@ -11,18 +11,14 @@ import { type ProviderHealth } from './provider-health.js';
11
11
  import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
12
12
  import { createWebFetchTool } from '../tools/web-fetch.js';
13
13
  import { createWebSearchTool } from '../tools/web-search.js';
14
- import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
14
+ import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
15
15
  import { type BackendConfig, type ProxyConfig } from './config.js';
16
16
  export type BackendSet = {
17
17
  search: (input: {
18
18
  query: string;
19
19
  }) => Promise<WebSearchResponse>;
20
- fetchPage: (input: {
21
- url: string;
22
- }) => Promise<WebFetchResponse>;
23
- headlessFetch: (input: {
24
- url: string;
25
- }) => Promise<WebFetchHeadlessResponse>;
20
+ fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
21
+ headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
26
22
  /** Releases the guard proxy and its agents. Idempotent; never starts the proxy. */
27
23
  close: () => Promise<void>;
28
24
  };
@@ -241,18 +241,18 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
241
241
  // chainSearch only falls back on non-terminal failures, so a terminal result or bad_request never reaches keyless Tavily.
242
242
  search = chainSearch([search, guarded('tavily', createTavilySearch({ keyless: true, fetchImpl }), 'tavily-keyless')], policyDeps);
243
243
  }
244
- const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl: targetFetch }) });
244
+ const httpFetcher = createHttpFetcher({ fetchImpl: targetFetch });
245
+ const httpFetch = createHttpFetch({ fetchPage: ({ url, query }) => httpFetcher(url, query) });
246
+ const firecrawlFetcher = config.fetch.baseUrl
247
+ ? createFirecrawlFetch({
248
+ baseUrl: config.fetch.baseUrl,
249
+ apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
250
+ options: config.fetch.options,
251
+ fetchImpl
252
+ })
253
+ : invalidFirecrawlFetch();
245
254
  const fetchPage = config.fetch.provider === 'firecrawl'
246
- ? withFetchPolicy(config.fetch.baseUrl
247
- ? createHttpFetch({
248
- fetchPage: createFirecrawlFetch({
249
- baseUrl: config.fetch.baseUrl,
250
- apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
251
- options: config.fetch.options,
252
- fetchImpl
253
- })
254
- })
255
- : createHttpFetch({ fetchPage: invalidFirecrawlFetch() }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
255
+ ? withFetchPolicy(createHttpFetch({ fetchPage: ({ url, query }) => firecrawlFetcher(url, query) }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
256
256
  : httpFetch;
257
257
  const fetchPageWithReaders = createSpecialContentResolver({
258
258
  readers: [
@@ -262,7 +262,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
262
262
  ],
263
263
  fallback: fetchPage
264
264
  });
265
- const headlessPage = (url) => headlessFetch(url, { guard: networkGuard, guardProxy: getGuardProxy });
265
+ const headlessPage = ({ url, query }) => headlessFetch(url, { query, guard: networkGuard, guardProxy: getGuardProxy });
266
266
  return {
267
267
  search,
268
268
  fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
@@ -1,4 +1,4 @@
1
- import type { SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
1
+ import type { ResearchFetchInput, SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
2
2
  import type { ProviderHealth } from './provider-health.js';
3
3
  export declare const RETRY_BASE_MS = 500;
4
4
  export declare const RETRY_JITTER_MS = 250;
@@ -11,9 +11,7 @@ export type PolicyDeps = {
11
11
  type Search = (input: {
12
12
  query: string;
13
13
  }) => Promise<WebSearchResponse>;
14
- type FetchPage = (input: {
15
- url: string;
16
- }) => Promise<WebFetchResponse>;
14
+ type FetchPage = (input: ResearchFetchInput) => Promise<WebFetchResponse>;
17
15
  /**
18
16
  * One provider under the #55 policy: skip when cooling down or disabled,
19
17
  * retry exactly once on transient, record state. Never falls back itself.
@@ -88,7 +88,6 @@ export function classifyHttpFailure(provider, parts, now = Date.now()) {
88
88
  }
89
89
  else if (provider === 'youcom') {
90
90
  // Source: https://you.com/docs/api-reference/search/v1-search (429 UNVERIFIED -> default)
91
- // Documented for /v1/search; the client currently calls /v1/agents/search (#60).
92
91
  if (status === 402)
93
92
  kind = 'quota_exhausted';
94
93
  else if (status === 403)
@@ -0,0 +1 @@
1
+ export declare function hasBotCheckContent(source: string, format?: 'html' | 'markdown' | 'text'): boolean;
@@ -0,0 +1,10 @@
1
+ const BOT_CHECK_RE = /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i;
2
+ export function hasBotCheckContent(source, format = 'text') {
3
+ const visibleSource = format === 'html'
4
+ ? source
5
+ .replace(/<!--[\s\S]*?-->/g, ' ')
6
+ .replace(/<(script|style|noscript|svg|template)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
7
+ .replace(/<[^>]+>/g, ' ')
8
+ : source;
9
+ return BOT_CHECK_RE.test(visibleSource);
10
+ }
@@ -6,3 +6,7 @@ export type SafeReadableExtraction = {
6
6
  };
7
7
  export declare function extractReadableContent(html: string, maxLength?: number): ExtractedContent;
8
8
  export declare function extractReadableContentSafely(html: string, maxLength?: number): SafeReadableExtraction;
9
+ /** Research-only extraction: choose relevant content before the usual text cap. */
10
+ export declare function extractReadableContentForQuery(html: string, query: string, maxLength?: number): SafeReadableExtraction & {
11
+ omitted: boolean;
12
+ };
@@ -1,5 +1,6 @@
1
1
  import { Readability } from '@mozilla/readability';
2
2
  import { JSDOM, VirtualConsole } from 'jsdom';
3
+ import { selectRelevantContent } from './section-selector.js';
3
4
  export function extractReadableContent(html, maxLength = 4000) {
4
5
  let stylesheetError;
5
6
  const virtualConsole = new VirtualConsole();
@@ -57,14 +58,25 @@ function extractPreferredSection(html) {
57
58
  const mainMatch = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
58
59
  if (mainMatch)
59
60
  return mainMatch[1];
60
- const articleMatch = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
61
- if (articleMatch)
62
- return articleMatch[1];
61
+ const articles = html.match(/<article\b[^>]*>/gi) ?? [];
62
+ if (articles.length === 1) {
63
+ const articleMatch = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
64
+ if (articleMatch)
65
+ return articleMatch[1];
66
+ }
63
67
  const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
64
68
  if (bodyMatch)
65
69
  return bodyMatch[1];
66
70
  return html;
67
71
  }
72
+ function extractPreferredDomSection(document) {
73
+ const main = document.querySelector('main');
74
+ const articles = document.querySelectorAll('article');
75
+ const region = main ?? (articles.length === 1 ? articles[0] : document.body);
76
+ const cleanedRegion = region.cloneNode(true);
77
+ cleanedRegion.querySelectorAll('script, style, noscript, svg, template').forEach((element) => element.remove());
78
+ return cleanedRegion.outerHTML;
79
+ }
68
80
  function extractFallbackText(html, maxLength) {
69
81
  const title = extractTitle(html);
70
82
  let section = extractPreferredSection(html);
@@ -97,3 +109,55 @@ export function extractReadableContentSafely(html, maxLength = 4000) {
97
109
  };
98
110
  }
99
111
  }
112
+ /** Research-only extraction: choose relevant content before the usual text cap. */
113
+ export function extractReadableContentForQuery(html, query, maxLength = 4000) {
114
+ let stylesheetError;
115
+ const virtualConsole = new VirtualConsole();
116
+ virtualConsole.on('jsdomError', (error) => {
117
+ if (!stylesheetError && error.message.includes('Could not parse CSS stylesheet')) {
118
+ stylesheetError = error;
119
+ }
120
+ });
121
+ try {
122
+ const dom = new JSDOM(html, { url: 'https://example.com', virtualConsole });
123
+ if (stylesheetError)
124
+ throw stylesheetError;
125
+ const preferredRegion = extractPreferredDomSection(dom.window.document);
126
+ const article = new Readability(dom.window.document).parse();
127
+ const selected = selectRelevantContent({
128
+ source: article?.content ?? dom.window.document.body.innerHTML,
129
+ format: 'html', query, maxLength
130
+ });
131
+ const preferredSelection = selectRelevantContent({
132
+ source: preferredRegion,
133
+ format: 'html', query, maxLength
134
+ });
135
+ const querySelection = preferredSelection.matched ? preferredSelection : selected;
136
+ return {
137
+ mode: 'readability',
138
+ omitted: querySelection.omitted,
139
+ content: {
140
+ title: article?.title ?? (dom.window.document.title || undefined),
141
+ byline: article?.byline || undefined,
142
+ text: querySelection.text,
143
+ ...(querySelection.anchor ? { sectionAnchor: querySelection.anchor } : {})
144
+ }
145
+ };
146
+ }
147
+ catch {
148
+ let region = extractPreferredSection(html);
149
+ for (const tag of ['script', 'style', 'noscript', 'svg', 'template']) {
150
+ region = stripTagContent(region, tag);
151
+ }
152
+ const selected = selectRelevantContent({ source: region, format: 'html', query, maxLength });
153
+ return {
154
+ mode: 'fallback',
155
+ omitted: selected.omitted,
156
+ content: {
157
+ title: extractTitle(html),
158
+ text: selected.text,
159
+ ...(selected.anchor ? { sectionAnchor: selected.anchor } : {})
160
+ }
161
+ };
162
+ }
163
+ }
@@ -0,0 +1,14 @@
1
+ export type SelectionFormat = 'html' | 'markdown' | 'text';
2
+ export type SectionSelection = {
3
+ text: string;
4
+ anchor?: string;
5
+ omitted: boolean;
6
+ matched: boolean;
7
+ };
8
+ export declare function selectRelevantContent({ source, format, query, maxLength }: {
9
+ source: string;
10
+ format: SelectionFormat;
11
+ query: string;
12
+ maxLength?: number;
13
+ }): SectionSelection;
14
+ export declare function selectRelevantExcerpt(text: string, query: string, maxLength: number): string;
@@ -0,0 +1,233 @@
1
+ import { JSDOM } from 'jsdom';
2
+ const STOPWORDS = new Set([
3
+ 'about', 'after', 'from', 'have', 'into', 'that', 'their', 'there', 'these',
4
+ 'this', 'what', 'when', 'where', 'which', 'with', 'would', 'your', 'https', 'http'
5
+ ]);
6
+ function clean(text) {
7
+ return text.replace(/\s+/g, ' ').trim();
8
+ }
9
+ function queryTerms(query) {
10
+ const withoutUrls = query.replace(/https?:\/\/\S+/gi, ' ');
11
+ return [...new Set((withoutUrls.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [])
12
+ .filter((term) => !STOPWORDS.has(term)))];
13
+ }
14
+ function scoreText(text, terms, allowPartialMatch = false) {
15
+ const normalized = text.toLowerCase();
16
+ const words = new Set(normalized.match(/[\p{L}\p{N}]{3,}/gu) ?? []);
17
+ return terms.reduce((score, term) => score + Number(words.has(term) ||
18
+ (/\p{Script=Han}/u.test(term) && normalized.includes(term)) ||
19
+ (allowPartialMatch && normalized.includes(term))), 0);
20
+ }
21
+ function splitLong(text, maxLength) {
22
+ const normalized = clean(text);
23
+ if (normalized.length <= maxLength)
24
+ return normalized ? [normalized] : [];
25
+ const words = normalized.split(' ');
26
+ const chunks = [];
27
+ let chunk = '';
28
+ for (let word of words) {
29
+ if (word.length > maxLength) {
30
+ if (chunk)
31
+ chunks.push(chunk);
32
+ while (word.length > maxLength) {
33
+ chunks.push(word.slice(0, maxLength));
34
+ word = word.slice(maxLength);
35
+ }
36
+ chunk = word;
37
+ continue;
38
+ }
39
+ if (chunk && chunk.length + word.length + 1 > maxLength) {
40
+ chunks.push(chunk);
41
+ chunk = word;
42
+ }
43
+ else {
44
+ chunk = chunk ? `${chunk} ${word}` : word;
45
+ }
46
+ }
47
+ if (chunk)
48
+ chunks.push(chunk);
49
+ return chunks;
50
+ }
51
+ function htmlSections(source) {
52
+ const document = new JSDOM(source).window.document;
53
+ const main = document.querySelector('main');
54
+ const articles = document.querySelectorAll('article');
55
+ const root = main ?? (articles.length === 1 ? articles[0] : document.body);
56
+ root.querySelectorAll('script, style, noscript, svg, template').forEach((element) => element.remove());
57
+ const chromeTags = new Set(['nav', 'aside', 'header', 'footer']);
58
+ const chromeRoles = new Set(['navigation', 'complementary', 'banner', 'contentinfo']);
59
+ root.querySelectorAll('nav, aside, header, footer, [role], [aria-label]').forEach((element) => {
60
+ const roles = (element.getAttribute('role') ?? '').toLowerCase().split(/\s+/);
61
+ const label = element.getAttribute('aria-label') ?? '';
62
+ if (chromeTags.has(element.tagName.toLowerCase()) || roles.some((role) => chromeRoles.has(role)) || /\bbreadcrumbs?\b/i.test(label)) {
63
+ element.remove();
64
+ }
65
+ });
66
+ const sections = [{ blocks: [] }];
67
+ let current = sections[0];
68
+ let pendingAnchor;
69
+ const contentSelector = 'h1, h2, h3, h4, h5, h6, p, li, pre, blockquote, dt, dd, div, a[name]';
70
+ for (const element of root.querySelectorAll(contentSelector)) {
71
+ const tag = element.tagName.toLowerCase();
72
+ if (tag === 'a') {
73
+ pendingAnchor = element.getAttribute('name') ?? element.id ?? pendingAnchor;
74
+ continue;
75
+ }
76
+ if (/^h[1-6]$/.test(tag)) {
77
+ const heading = clean(element.textContent ?? '');
78
+ if (!heading)
79
+ continue;
80
+ const anchor = element.id || element.querySelector('[id], a[name]')?.getAttribute('id') ||
81
+ element.querySelector('a[name]')?.getAttribute('name') || pendingAnchor || undefined;
82
+ current = { heading, anchor, blocks: [] };
83
+ sections.push(current);
84
+ pendingAnchor = undefined;
85
+ continue;
86
+ }
87
+ if (tag === 'div' && element.querySelector(contentSelector))
88
+ continue;
89
+ if (element.closest('li, blockquote') !== element && element.parentElement?.closest('li, blockquote'))
90
+ continue;
91
+ const text = clean(element.textContent ?? '');
92
+ if (text) {
93
+ if (!current.anchor && pendingAnchor)
94
+ current.anchor = pendingAnchor;
95
+ current.blocks.push(text);
96
+ pendingAnchor = undefined;
97
+ }
98
+ }
99
+ if (sections.every((section) => section.blocks.length === 0)) {
100
+ const text = clean(root.textContent ?? '');
101
+ if (text)
102
+ return [{ blocks: [text] }];
103
+ }
104
+ return sections.filter((section) => section.heading || section.blocks.length);
105
+ }
106
+ function markdownSections(source) {
107
+ const sections = [{ blocks: [] }];
108
+ let current = sections[0];
109
+ let paragraph = [];
110
+ const flush = () => {
111
+ if (paragraph.length)
112
+ current.blocks.push(clean(paragraph.join(' ')));
113
+ paragraph = [];
114
+ };
115
+ for (const line of source.split(/\r?\n/)) {
116
+ const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
117
+ if (heading) {
118
+ flush();
119
+ current = { heading: clean(heading[1]), blocks: [] };
120
+ sections.push(current);
121
+ }
122
+ else if (!line.trim()) {
123
+ flush();
124
+ }
125
+ else {
126
+ paragraph.push(line.trim());
127
+ }
128
+ }
129
+ flush();
130
+ return sections.filter((section) => section.heading || section.blocks.length);
131
+ }
132
+ function plainSections(source) {
133
+ return source.split(/\n\s*\n/).map((text) => ({ blocks: [clean(text)] }))
134
+ .filter((section) => section.blocks[0]);
135
+ }
136
+ function renderSections(sections) {
137
+ return sections.flatMap((section) => [section.heading, ...section.blocks].filter(Boolean)).join('\n\n');
138
+ }
139
+ function makeWindows(sections, terms, maxLength) {
140
+ const windows = [];
141
+ const windowLimit = Math.max(1, Math.min(1200, maxLength));
142
+ for (const section of sections) {
143
+ const fullHeading = section.heading ?? '';
144
+ if (fullHeading.length + 2 > windowLimit) {
145
+ const heading = fullHeading.slice(0, windowLimit);
146
+ windows.push({
147
+ text: heading,
148
+ anchor: section.anchor,
149
+ score: scoreText(heading, terms) * 3,
150
+ index: windows.length
151
+ });
152
+ for (const block of section.blocks) {
153
+ const allowPartialMatch = /^\p{L}[\p{L}\p{N}]*$/u.test(block) && block.length > windowLimit;
154
+ for (const chunk of splitLong(block, windowLimit)) {
155
+ windows.push({
156
+ text: chunk,
157
+ anchor: section.anchor,
158
+ score: scoreText(chunk, terms, allowPartialMatch),
159
+ index: windows.length
160
+ });
161
+ }
162
+ }
163
+ continue;
164
+ }
165
+ const heading = fullHeading;
166
+ const headingLength = heading ? heading.length + 2 : 0;
167
+ const bodyLimit = Math.max(0, windowLimit - headingLength);
168
+ const chunks = bodyLimit > 0 ? section.blocks.flatMap((block) => {
169
+ const allowPartialMatch = /^\p{L}[\p{L}\p{N}]*$/u.test(block) && block.length > bodyLimit;
170
+ return splitLong(block, bodyLimit).map((text) => ({ text, allowPartialMatch }));
171
+ }) : [];
172
+ if (chunks.length === 0 && heading)
173
+ chunks.push({ text: '', allowPartialMatch: false });
174
+ let body = '';
175
+ let bodyAllowsPartialMatch = false;
176
+ const add = () => {
177
+ const text = clean([heading, body].filter(Boolean).join('\n\n'));
178
+ windows.push({ text, anchor: section.anchor, score: scoreText(heading, terms) * 3 + scoreText(body, terms, bodyAllowsPartialMatch), index: windows.length });
179
+ body = '';
180
+ bodyAllowsPartialMatch = false;
181
+ };
182
+ for (const chunk of chunks) {
183
+ if (body && body.length + chunk.text.length + 2 > bodyLimit)
184
+ add();
185
+ body = body ? `${body}\n\n${chunk.text}` : chunk.text;
186
+ bodyAllowsPartialMatch = !body.includes('\n\n') && chunk.allowPartialMatch;
187
+ }
188
+ if (body || heading)
189
+ add();
190
+ }
191
+ return windows;
192
+ }
193
+ export function selectRelevantContent({ source, format, query, maxLength = 4000 }) {
194
+ const sections = format === 'html' ? htmlSections(source) : format === 'markdown'
195
+ ? markdownSections(source) : plainSections(source);
196
+ const fullText = renderSections(sections);
197
+ if (maxLength <= 0) {
198
+ return { text: '', omitted: fullText.length > 0, matched: false };
199
+ }
200
+ const terms = queryTerms(query);
201
+ const windows = makeWindows(sections, terms, maxLength);
202
+ const ranked = windows.filter((window) => window.score > 0)
203
+ .sort((a, b) => b.score - a.score || a.index - b.index);
204
+ if (ranked.length === 0) {
205
+ return { text: fullText.slice(0, maxLength), omitted: fullText.length > maxLength, matched: false };
206
+ }
207
+ const selected = [];
208
+ let remaining = maxLength;
209
+ for (const window of ranked) {
210
+ const separatorLength = selected.length ? 2 : 0;
211
+ if (window.text.length + separatorLength > remaining)
212
+ continue;
213
+ selected.push(window);
214
+ remaining -= window.text.length + separatorLength;
215
+ }
216
+ selected.sort((a, b) => a.index - b.index);
217
+ const text = selected.map((window) => window.text).join('\n\n');
218
+ return {
219
+ text,
220
+ anchor: ranked.find((window) => selected.includes(window))?.anchor,
221
+ omitted: selected.length < windows.length,
222
+ matched: true
223
+ };
224
+ }
225
+ export function selectRelevantExcerpt(text, query, maxLength) {
226
+ const terms = queryTerms(query);
227
+ const chunks = text.split(/\n\s*\n/).flatMap((paragraph) => splitLong(paragraph, maxLength));
228
+ if (chunks.length === 0)
229
+ return '';
230
+ const best = chunks.map((chunk, index) => ({ chunk, index, score: scoreText(chunk, terms) }))
231
+ .sort((a, b) => b.score - a.score || a.index - b.index)[0];
232
+ return best.chunk.slice(0, maxLength);
233
+ }
@@ -5,4 +5,4 @@ export declare function createFirecrawlFetcher({ baseUrl, apiKey, options, fetch
5
5
  apiKey?: string;
6
6
  options?: FirecrawlOptions;
7
7
  fetchImpl?: typeof fetch;
8
- }): (url: string) => Promise<WebFetchResponse>;
8
+ }): (url: string, query?: string) => Promise<WebFetchResponse>;
@@ -1,4 +1,6 @@
1
1
  import { classifyHttpFailure, readResponseParts } from '../backends/provider-failure.js';
2
+ import { selectRelevantContent } from '../extract/section-selector.js';
3
+ import { hasBotCheckContent } from '../extract/bot-check.js';
2
4
  function buildScrapeUrl(baseUrl) {
3
5
  return new URL('/v1/scrape', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
4
6
  }
@@ -6,7 +8,7 @@ function errorMessage(error) {
6
8
  return error instanceof Error ? error.message : String(error);
7
9
  }
8
10
  export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = fetch }) {
9
- return async function firecrawlFetch(url) {
11
+ return async function firecrawlFetch(url, query) {
10
12
  const failed = (message, failure) => ({
11
13
  status: 'error',
12
14
  url,
@@ -56,11 +58,9 @@ export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = f
56
58
  httpStatus: response.status
57
59
  });
58
60
  }
59
- const text = typeof parsed.data?.markdown === 'string'
60
- ? parsed.data.markdown
61
- : typeof parsed.data?.html === 'string'
62
- ? parsed.data.html
63
- : '';
61
+ const markdown = typeof parsed.data?.markdown === 'string' ? parsed.data.markdown : undefined;
62
+ const html = typeof parsed.data?.html === 'string' ? parsed.data.html : undefined;
63
+ const text = markdown ?? html ?? '';
64
64
  const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
65
65
  ? parsed.data.metadata.sourceURL
66
66
  : url;
@@ -75,11 +75,20 @@ export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = f
75
75
  error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
76
76
  };
77
77
  }
78
+ const selection = query
79
+ ? selectRelevantContent({ source: text, format: markdown !== undefined ? 'markdown' : 'html', query })
80
+ : undefined;
81
+ const selectedText = selection?.text ?? text;
78
82
  return {
79
83
  status: 'ok',
80
84
  url: resolvedUrl,
81
- content: { title, text },
82
- metadata: { method: 'firecrawl', cacheHit: false, truncated: text.length >= 4000 }
85
+ content: {
86
+ title,
87
+ text: selectedText,
88
+ ...(hasBotCheckContent(text, markdown !== undefined ? 'markdown' : 'html') ? { botCheck: true } : {}),
89
+ ...(selection?.anchor ? { sectionAnchor: selection.anchor } : {})
90
+ },
91
+ metadata: { method: 'firecrawl', cacheHit: false, truncated: selection?.omitted ?? text.length >= 4000 }
83
92
  };
84
93
  };
85
94
  }
@@ -8,8 +8,9 @@ export type BrowserProxyOptions = {
8
8
  password?: string;
9
9
  bypass?: string;
10
10
  };
11
- export declare function headlessFetch(url: string, { configuredPath, proxy, guard, guardProxy, resolveBrowser, launchBrowser, now }?: {
11
+ export declare function headlessFetch(url: string, { configuredPath, query, proxy, guard, guardProxy, resolveBrowser, launchBrowser, now }?: {
12
12
  configuredPath?: string;
13
+ query?: string;
13
14
  /** Only used without a guard. With a guard, Chromium always goes through the guard proxy, which chains upstream itself. */
14
15
  proxy?: BrowserProxyOptions;
15
16
  guard?: NetworkGuard;
@@ -1,5 +1,6 @@
1
1
  import { chromium } from 'playwright';
2
- import { extractReadableContentSafely } from '../extract/readability.js';
2
+ import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
3
+ import { hasBotCheckContent } from '../extract/bot-check.js';
3
4
  import { resolveBrowserExecutable } from './browser-resolution.js';
4
5
  import { BLOCKED_HEADER } from './guard-proxy.js';
5
6
  import { BLOCKED_PRIVATE_ADDRESS, BlockedAddressError, UPSTREAM_PROXY_REFUSED } from './network-guard.js';
@@ -30,7 +31,7 @@ function hostnameOf(url) {
30
31
  function normalizeHost(host) {
31
32
  return host.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
32
33
  }
33
- export async function headlessFetch(url, { configuredPath, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
34
+ export async function headlessFetch(url, { configuredPath, query, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
34
35
  if (guard) {
35
36
  const hostname = hostnameOf(url);
36
37
  if (!guardProxy) {
@@ -199,12 +200,16 @@ export async function headlessFetch(url, { configuredPath, proxy, guard, guardPr
199
200
  const html = await page.content();
200
201
  const finishedAt = now();
201
202
  const blockedSubresources = subresourceRefusals();
202
- const extraction = extractReadableContentSafely(html);
203
+ const baselineExtraction = extractReadableContentSafely(html);
204
+ const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
205
+ const extraction = queryExtraction ?? baselineExtraction;
206
+ const cleanedBaselineText = cleanupRenderedText(baselineExtraction.content.text);
203
207
  const cleanedContent = {
204
208
  ...extraction.content,
205
- text: cleanupRenderedText(extraction.content.text)
209
+ text: cleanupRenderedText(extraction.content.text),
210
+ ...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
206
211
  };
207
- if (!cleanedContent.text || cleanedContent.text.length < 40) {
212
+ if (!cleanedBaselineText || cleanedBaselineText.length < 40) {
208
213
  return {
209
214
  status: 'blocked',
210
215
  url,
@@ -230,7 +235,7 @@ export async function headlessFetch(url, { configuredPath, proxy, guard, guardPr
230
235
  cacheHit: false,
231
236
  browser: browserName,
232
237
  navigationMs: finishedAt - startedAt,
233
- truncated: cleanedContent.text.length >= 4000,
238
+ truncated: queryExtraction?.omitted ?? cleanedContent.text.length >= 4000,
234
239
  ...(blockedSubresources > 0 ? { blockedSubresources } : {})
235
240
  }
236
241
  };
@@ -1,4 +1,4 @@
1
1
  import type { WebFetchResponse } from '../types.js';
2
2
  export declare function createHttpFetcher({ fetchImpl }?: {
3
3
  fetchImpl?: typeof fetch;
4
- }): (url: string) => Promise<WebFetchResponse>;
4
+ }): (url: string, query?: string) => Promise<WebFetchResponse>;
@@ -1,4 +1,5 @@
1
- import { extractReadableContentSafely } from '../extract/readability.js';
1
+ import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
2
+ import { hasBotCheckContent } from '../extract/bot-check.js';
2
3
  import { findGuardError } from './network-guard.js';
3
4
  function looksLikeScriptShell(html) {
4
5
  const lower = html.toLowerCase();
@@ -15,7 +16,7 @@ function isWeakHttpContent(options) {
15
16
  return veryShortBody && (lowDensity || hasGenericShellMarker);
16
17
  }
17
18
  export function createHttpFetcher({ fetchImpl = fetch } = {}) {
18
- return async function httpFetch(url) {
19
+ return async function httpFetch(url, query) {
19
20
  let response;
20
21
  try {
21
22
  response = await fetchImpl(url);
@@ -40,11 +41,16 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
40
41
  };
41
42
  }
42
43
  const html = await response.text();
43
- const extraction = extractReadableContentSafely(html);
44
- const content = extraction.content;
44
+ const baselineExtraction = extractReadableContentSafely(html);
45
+ const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
46
+ const extraction = queryExtraction ?? baselineExtraction;
47
+ const content = {
48
+ ...extraction.content,
49
+ ...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
50
+ };
45
51
  if (looksLikeScriptShell(html) ||
46
- content.text.length < 40 ||
47
- isWeakHttpContent({ html, title: content.title, text: content.text })) {
52
+ baselineExtraction.content.text.length < 40 ||
53
+ isWeakHttpContent({ html, title: baselineExtraction.content.title, text: baselineExtraction.content.text })) {
48
54
  return {
49
55
  status: 'needs_headless',
50
56
  url: response.url,
@@ -59,7 +65,7 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
59
65
  status: 'ok',
60
66
  url: response.url,
61
67
  content,
62
- metadata: { method: 'http', cacheHit: false, contentType, truncated: content.text.length >= 4000 }
68
+ metadata: { method: 'http', cacheHit: false, contentType, truncated: queryExtraction?.omitted ?? content.text.length >= 4000 }
63
69
  };
64
70
  };
65
71
  }
@@ -1,16 +1,12 @@
1
1
  import type { BackendConfig } from '../backends/config.js';
2
- import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
2
+ import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
3
3
  export declare function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch }?: {
4
4
  backendConfig?: BackendConfig;
5
5
  search?: (input: {
6
6
  query: string;
7
7
  }) => Promise<WebSearchResponse>;
8
- fetchPage?: (input: {
9
- url: string;
10
- }) => Promise<WebFetchResponse>;
11
- headlessFetch?: (input: {
12
- url: string;
13
- }) => Promise<WebFetchHeadlessResponse>;
8
+ fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
9
+ headlessFetch?: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
14
10
  }): {
15
11
  run({ query }: {
16
12
  query: string;
@@ -1,4 +1,4 @@
1
- import type { Attempt, SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
1
+ import type { Attempt, ResearchFetchInput, SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
2
2
  import type { ResearchEvidence, ResearchOrchestratorDecision, ResearchWorkerResult } from './research-types.js';
3
3
  import { type EvidenceCaveatReason } from './evidence-quality.js';
4
4
  export declare function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }: {
@@ -9,12 +9,8 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
9
9
  maxFetches: number;
10
10
  }) => Promise<ResearchWorkerResult>;
11
11
  };
12
- fetchDirect?: (input: {
13
- url: string;
14
- }) => Promise<WebFetchResponse>;
15
- headlessFetch: (input: {
16
- url: string;
17
- }) => Promise<WebFetchHeadlessResponse>;
12
+ fetchDirect?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
13
+ headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
18
14
  }): {
19
15
  run({ query }: {
20
16
  query: string;
@@ -5,25 +5,26 @@ import { classifySourceProfile } from './source-profile.js';
5
5
  import { extractDirectUrls } from './direct-url.js';
6
6
  import { decideNextResearchStep } from './stop-decider.js';
7
7
  import { analyzeEvidenceQuality } from './evidence-quality.js';
8
+ import { selectRelevantExcerpt } from '../extract/section-selector.js';
9
+ import { hasBotCheckContent } from '../extract/bot-check.js';
8
10
  const DEFAULT_MAX_PASSES = 3;
9
11
  const DEFAULT_MAX_FETCHES_PER_PASS = 4;
10
12
  const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
11
13
  function classifyEvidenceUrl(url) {
12
14
  return classifySourceProfile(url).sourceKind;
13
15
  }
14
- function summarizeText(text, maxLength = 180) {
15
- return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
16
- }
17
16
  function isReaderMethod(method) {
18
17
  return method === 'github' || method === 'pdf' || method === 'youtube';
19
18
  }
20
- function isBotCheckContent({ title = '', text }) {
21
- return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
19
+ function isBotCheckContent({ title = '', text, botCheck }) {
20
+ if (botCheck)
21
+ return true;
22
+ return hasBotCheckContent(`${title}\n${text}`);
22
23
  }
23
- function evidenceFromFetch(result) {
24
+ function evidenceFromFetch(result, query) {
24
25
  if (result.status !== 'ok' || !result.content?.text.trim())
25
26
  return null;
26
- 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 }))
27
28
  return null;
28
29
  if (isReaderMethod(result.metadata.method)) {
29
30
  return {
@@ -40,22 +41,22 @@ function evidenceFromFetch(result) {
40
41
  url: result.url,
41
42
  sourceKind: classifyEvidenceUrl(result.url),
42
43
  method: result.metadata.method,
43
- summary: summarizeText(result.content.text),
44
- supports: [summarizeText(result.content.text, 120)]
44
+ summary: selectRelevantExcerpt(result.content.text, query, 180),
45
+ supports: [selectRelevantExcerpt(result.content.text, query, 120)]
45
46
  };
46
47
  }
47
- function evidenceFromHeadless(result) {
48
+ function evidenceFromHeadless(result, query) {
48
49
  if (result.status !== 'ok' || !result.content?.text.trim())
49
50
  return null;
50
- 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 }))
51
52
  return null;
52
53
  return {
53
54
  title: result.content.title ?? result.url,
54
55
  url: result.url,
55
56
  sourceKind: classifyEvidenceUrl(result.url),
56
57
  method: 'headless',
57
- summary: summarizeText(result.content.text),
58
- supports: [summarizeText(result.content.text, 120)]
58
+ summary: selectRelevantExcerpt(result.content.text, query, 180),
59
+ supports: [selectRelevantExcerpt(result.content.text, query, 120)]
59
60
  };
60
61
  }
61
62
  function combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget }) {
@@ -128,10 +129,10 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
128
129
  }
129
130
  if (fetchDirect) {
130
131
  for (const url of extractDirectUrls(query).slice(0, 3)) {
131
- const directResult = await fetchDirect({ url });
132
+ const directResult = await fetchDirect({ url, query });
132
133
  if (directResult.metadata.attempts)
133
134
  runAttempts.push(...directResult.metadata.attempts);
134
- const directEvidence = evidenceFromFetch(directResult);
135
+ const directEvidence = evidenceFromFetch(directResult, query);
135
136
  if (directEvidence) {
136
137
  allEvidence.push(directEvidence);
137
138
  continue;
@@ -146,8 +147,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
146
147
  if (shouldRetryDirectWithHeadless(directResult, directEvidence)) {
147
148
  if (headlessAttempts < DEFAULT_MAX_HEADLESS_ATTEMPTS) {
148
149
  headlessAttempts++;
149
- const headlessResult = await headlessFetch({ url: directResult.url });
150
- const headlessEvidence = evidenceFromHeadless(headlessResult);
150
+ const headlessResult = await headlessFetch({ url: directResult.url, query });
151
+ const headlessEvidence = evidenceFromHeadless(headlessResult, query);
151
152
  if (headlessEvidence) {
152
153
  allEvidence.push(headlessEvidence);
153
154
  }
@@ -255,8 +256,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
255
256
  });
256
257
  if (decision.action === 'headless') {
257
258
  headlessAttempts++;
258
- const headlessResult = await headlessFetch({ url: decision.url });
259
- const headlessEvidence = evidenceFromHeadless(headlessResult);
259
+ const headlessResult = await headlessFetch({ url: decision.url, query });
260
+ const headlessEvidence = evidenceFromHeadless(headlessResult, query);
260
261
  if (headlessEvidence) {
261
262
  allEvidence.push(headlessEvidence);
262
263
  const updatedRanked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
@@ -1,12 +1,10 @@
1
- import type { WebFetchResponse, WebSearchResponse } from '../types.js';
1
+ import type { ResearchFetchInput, WebFetchResponse, WebSearchResponse } from '../types.js';
2
2
  import type { ResearchWorkerResult } from './research-types.js';
3
3
  export declare function createResearchWorker({ search, fetchPage }: {
4
4
  search: (input: {
5
5
  query: string;
6
6
  }) => Promise<WebSearchResponse>;
7
- fetchPage: (input: {
8
- url: string;
9
- }) => Promise<WebFetchResponse>;
7
+ fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
10
8
  }): {
11
9
  run({ query, maxSearchRounds, maxFetches }: {
12
10
  query: string;
@@ -1,23 +1,24 @@
1
1
  import { failureOf, isTerminalFailure } from '../backends/failure.js';
2
2
  import { selectCandidates } from './candidate-selector.js';
3
3
  import { classifySourceProfile } from './source-profile.js';
4
+ import { selectRelevantExcerpt } from '../extract/section-selector.js';
5
+ import { hasBotCheckContent } from '../extract/bot-check.js';
4
6
  function classifySource(url) {
5
7
  return classifySourceProfile(url).sourceKind;
6
8
  }
7
- function summarizeText(text, maxLength = 180) {
8
- return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
9
- }
10
9
  function isReaderMethod(method) {
11
10
  return method === 'github' || method === 'pdf' || method === 'youtube';
12
11
  }
13
- function isBotCheckContent({ title = '', text }) {
14
- return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
12
+ function isBotCheckContent({ title = '', text, botCheck }) {
13
+ if (botCheck)
14
+ return true;
15
+ return hasBotCheckContent(`${title}\n${text}`);
15
16
  }
16
- function evidenceFromFetch(fetched, fallbackTitle) {
17
+ function evidenceFromFetch(fetched, fallbackTitle, query) {
17
18
  const content = fetched.content;
18
19
  if (fetched.status !== 'ok' || !content)
19
20
  return null;
20
- if (isBotCheckContent({ title: content.title, text: content.text }))
21
+ if (isBotCheckContent({ title: content.title, text: content.text, botCheck: content.botCheck }))
21
22
  return null;
22
23
  // A successful reader read with usable text is primary content, exempt from the
23
24
  // package-page filter below.
@@ -40,14 +41,14 @@ function evidenceFromFetch(fetched, fallbackTitle) {
40
41
  url: fetched.url,
41
42
  sourceKind,
42
43
  method: fetched.metadata.method,
43
- summary: summarizeText(content.text),
44
- supports: [summarizeText(content.text, 120)]
44
+ summary: selectRelevantExcerpt(content.text, query, 180),
45
+ supports: [selectRelevantExcerpt(content.text, query, 120)]
45
46
  };
46
47
  }
47
48
  function lowValueOutcomeFromFetch(fetched) {
48
49
  if (fetched.status !== 'ok' || !fetched.content)
49
50
  return null;
50
- if (isBotCheckContent({ title: fetched.content.title, text: fetched.content.text })) {
51
+ if (isBotCheckContent({ title: fetched.content.title, text: fetched.content.text, botCheck: fetched.content.botCheck })) {
51
52
  return {
52
53
  kind: 'bot-check',
53
54
  url: fetched.url,
@@ -145,11 +146,11 @@ export function createResearchWorker({ search, fetchPage }) {
145
146
  });
146
147
  const fetchAttempts = [];
147
148
  for (const candidate of candidates) {
148
- const fetched = await fetchPage({ url: candidate.url });
149
+ const fetched = await fetchPage({ url: candidate.url, query });
149
150
  if (fetched.metadata.attempts)
150
151
  fetchAttempts.push(...fetched.metadata.attempts);
151
152
  if (fetched.status === 'ok') {
152
- const parsedEvidence = evidenceFromFetch(fetched, candidate.title);
153
+ const parsedEvidence = evidenceFromFetch(fetched, candidate.title, query);
153
154
  if (parsedEvidence) {
154
155
  evidence.push(parsedEvidence);
155
156
  continue;
@@ -1,12 +1,8 @@
1
- import type { WebFetchResponse } from '../types.js';
1
+ import type { ResearchFetchInput, WebFetchResponse } from '../types.js';
2
2
  import type { SpecialContentReader } from './types.js';
3
3
  type ResolverDeps = {
4
4
  readers: SpecialContentReader[];
5
- fallback: (input: {
6
- url: string;
7
- }) => Promise<WebFetchResponse>;
5
+ fallback: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
8
6
  };
9
- export declare function createSpecialContentResolver({ readers, fallback }: ResolverDeps): (input: {
10
- url: string;
11
- }) => Promise<WebFetchResponse>;
7
+ export declare function createSpecialContentResolver({ readers, fallback }: ResolverDeps): (input: ResearchFetchInput) => Promise<WebFetchResponse>;
12
8
  export {};
@@ -1,3 +1,6 @@
1
+ import { type Normalized } from './json-provider.js';
2
+ export declare const YOUCOM_SEARCH_URL = "https://ydc-index.io/v1/search";
3
+ export declare function normalizeYouComResults(json: unknown): Normalized | undefined;
1
4
  export declare function createYouComSearchTool({ apiKey, fetchImpl }: {
2
5
  apiKey?: string;
3
6
  fetchImpl?: typeof fetch;
@@ -1,5 +1,40 @@
1
- import { createJsonSearchProvider, normalizeResultsArray } from './json-provider.js';
2
- const YOUCOM_SEARCH_URL = 'https://api.you.com/v1/agents/search';
1
+ import { createJsonSearchProvider } from './json-provider.js';
2
+ export const YOUCOM_SEARCH_URL = 'https://ydc-index.io/v1/search';
3
+ function normalizeResult(item, snippet) {
4
+ if (!item || typeof item !== 'object')
5
+ return [];
6
+ const result = item;
7
+ return typeof result.title === 'string' && typeof result.url === 'string'
8
+ ? [{ title: result.title, url: result.url, snippet: typeof result.description === 'string' ? result.description : snippet }]
9
+ : [];
10
+ }
11
+ export function normalizeYouComResults(json) {
12
+ if (!json || typeof json !== 'object')
13
+ return undefined;
14
+ const response = json;
15
+ if (!response.results || typeof response.results !== 'object' || Array.isArray(response.results))
16
+ return undefined;
17
+ const sections = response.results;
18
+ if (sections.web === undefined && sections.news === undefined)
19
+ return undefined;
20
+ if (sections.web !== undefined && !Array.isArray(sections.web))
21
+ return undefined;
22
+ if (sections.news !== undefined && !Array.isArray(sections.news))
23
+ return undefined;
24
+ const web = sections.web ?? [];
25
+ const news = sections.news ?? [];
26
+ return {
27
+ rawCount: web.length + news.length,
28
+ results: [
29
+ ...web.flatMap((item) => {
30
+ const snippets = item && typeof item === 'object' ? item.snippets : undefined;
31
+ const snippet = Array.isArray(snippets) ? snippets.find((value) => typeof value === 'string') ?? '' : '';
32
+ return normalizeResult(item, snippet);
33
+ }),
34
+ ...news.flatMap((item) => normalizeResult(item, ''))
35
+ ]
36
+ };
37
+ }
3
38
  export function createYouComSearchTool({ apiKey, fetchImpl = fetch }) {
4
39
  return createJsonSearchProvider({
5
40
  name: 'youcom',
@@ -12,9 +47,9 @@ export function createYouComSearchTool({ apiKey, fetchImpl = fetch }) {
12
47
  init: {
13
48
  method: 'POST',
14
49
  headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-API-Key': apiKey ?? '' },
15
- body: JSON.stringify({ query, max_results: 10 })
50
+ body: JSON.stringify({ query, count: 10 })
16
51
  }
17
52
  }),
18
- normalize: (json) => normalizeResultsArray(json, (body) => body.results, 'snippet')
53
+ normalize: normalizeYouComResults
19
54
  });
20
55
  }
@@ -1,6 +1,4 @@
1
- import type { WebFetchHeadlessResponse } from '../types.js';
1
+ import type { ResearchFetchInput, WebFetchHeadlessResponse } from '../types.js';
2
2
  export declare function createWebFetchHeadlessTool({ fetchPage }?: {
3
- fetchPage?: (url: string) => Promise<WebFetchHeadlessResponse>;
4
- }): ({ url }: {
5
- url: string;
6
- }) => Promise<WebFetchHeadlessResponse>;
3
+ fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
4
+ }): ({ url, query }: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
@@ -1,7 +1,7 @@
1
1
  import { headlessFetch } from '../fetch/headless-fetch.js';
2
2
  import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
3
- export function createWebFetchHeadlessTool({ fetchPage = headlessFetch } = {}) {
4
- return async function webFetchHeadless({ url }) {
3
+ export function createWebFetchHeadlessTool({ fetchPage = ({ url, query }) => headlessFetch(url, { query }) } = {}) {
4
+ return async function webFetchHeadless({ url, query }) {
5
5
  if (!/^https?:\/\//.test(url)) {
6
6
  const result = {
7
7
  status: 'unsupported',
@@ -14,7 +14,7 @@ export function createWebFetchHeadlessTool({ fetchPage = headlessFetch } = {}) {
14
14
  presentation: buildFetchPresentation(result)
15
15
  };
16
16
  }
17
- const result = await fetchPage(url);
17
+ const result = await fetchPage({ url, ...(query ? { query } : {}) });
18
18
  return {
19
19
  ...result,
20
20
  presentation: buildFetchPresentation(result)
@@ -1,6 +1,4 @@
1
- import type { WebFetchResponse } from '../types.js';
1
+ import type { ResearchFetchInput, WebFetchResponse } from '../types.js';
2
2
  export declare function createWebFetchTool({ fetchPage }?: {
3
- fetchPage?: (url: string) => Promise<WebFetchResponse>;
4
- }): ({ url }: {
5
- url: string;
6
- }) => Promise<WebFetchResponse>;
3
+ fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
4
+ }): ({ url, query }: ResearchFetchInput) => Promise<WebFetchResponse>;
@@ -1,7 +1,7 @@
1
1
  import { createHttpFetcher } from '../fetch/http-fetch.js';
2
2
  import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
3
- export function createWebFetchTool({ fetchPage = createHttpFetcher() } = {}) {
4
- return async function webFetch({ url }) {
3
+ export function createWebFetchTool({ fetchPage = ({ url, query }) => createHttpFetcher()(url, query) } = {}) {
4
+ return async function webFetch({ url, query }) {
5
5
  if (!/^https?:\/\//.test(url)) {
6
6
  const result = {
7
7
  status: 'unsupported',
@@ -14,7 +14,7 @@ export function createWebFetchTool({ fetchPage = createHttpFetcher() } = {}) {
14
14
  presentation: buildFetchPresentation(result)
15
15
  };
16
16
  }
17
- const result = await fetchPage(url);
17
+ const result = await fetchPage({ url, ...(query ? { query } : {}) });
18
18
  return {
19
19
  ...result,
20
20
  presentation: buildFetchPresentation(result)
package/dist/types.d.ts CHANGED
@@ -61,6 +61,10 @@ export type SearchMetadata = {
61
61
  coverage?: SearchCoverage;
62
62
  };
63
63
  export type FetchMethod = 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
64
+ export type ResearchFetchInput = {
65
+ url: string;
66
+ query?: string;
67
+ };
64
68
  export type FetchMetadata = {
65
69
  method: FetchMethod;
66
70
  cacheHit: boolean;
@@ -78,6 +82,10 @@ export type ExtractedContent = {
78
82
  title?: string;
79
83
  byline?: string;
80
84
  text: string;
85
+ /** A bot or security verification marker appeared in the source before query selection. */
86
+ botCheck?: boolean;
87
+ /** Anchor of the section chosen for research; direct fetches do not set this. */
88
+ sectionAnchor?: string;
81
89
  };
82
90
  export type WebSearchResponse = {
83
91
  status: 'ok' | 'error';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",