@demigodmode/pi-web-agent 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -0
  3. package/dist/backends/config.d.ts +13 -0
  4. package/dist/backends/config.js +44 -1
  5. package/dist/backends/doctor.js +27 -29
  6. package/dist/backends/factory.d.ts +18 -7
  7. package/dist/backends/factory.js +126 -99
  8. package/dist/backends/failure.d.ts +11 -0
  9. package/dist/backends/failure.js +34 -0
  10. package/dist/backends/fallback-policy.d.ts +31 -0
  11. package/dist/backends/fallback-policy.js +239 -0
  12. package/dist/backends/provider-failure.d.ts +21 -0
  13. package/dist/backends/provider-failure.js +110 -0
  14. package/dist/backends/provider-health.d.ts +29 -0
  15. package/dist/backends/provider-health.js +49 -0
  16. package/dist/commands/web-agent-config.d.ts +14 -1
  17. package/dist/commands/web-agent-config.js +75 -3
  18. package/dist/extension.js +47 -3
  19. package/dist/extract/bot-check.d.ts +1 -0
  20. package/dist/extract/bot-check.js +10 -0
  21. package/dist/extract/readability.d.ts +4 -0
  22. package/dist/extract/readability.js +67 -3
  23. package/dist/extract/section-selector.d.ts +14 -0
  24. package/dist/extract/section-selector.js +233 -0
  25. package/dist/fetch/destination-policy.d.ts +32 -0
  26. package/dist/fetch/destination-policy.js +24 -0
  27. package/dist/fetch/firecrawl-fetch.d.ts +1 -1
  28. package/dist/fetch/firecrawl-fetch.js +74 -46
  29. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  30. package/dist/fetch/guard-proxy-fetch.js +82 -0
  31. package/dist/fetch/guard-proxy.d.ts +58 -0
  32. package/dist/fetch/guard-proxy.js +420 -0
  33. package/dist/fetch/guarded-fetch.d.ts +7 -0
  34. package/dist/fetch/guarded-fetch.js +75 -0
  35. package/dist/fetch/headless-fetch.d.ts +11 -2
  36. package/dist/fetch/headless-fetch.js +190 -13
  37. package/dist/fetch/http-fetch.d.ts +1 -1
  38. package/dist/fetch/http-fetch.js +29 -8
  39. package/dist/fetch/network-guard.d.ts +82 -0
  40. package/dist/fetch/network-guard.js +275 -0
  41. package/dist/orchestration/answer-synthesizer.js +2 -0
  42. package/dist/orchestration/evidence-quality.d.ts +3 -2
  43. package/dist/orchestration/evidence-quality.js +2 -1
  44. package/dist/orchestration/index.d.ts +26 -7
  45. package/dist/orchestration/index.js +9 -2
  46. package/dist/orchestration/research-orchestrator.d.ts +23 -7
  47. package/dist/orchestration/research-orchestrator.js +60 -26
  48. package/dist/orchestration/research-types.d.ts +13 -1
  49. package/dist/orchestration/research-worker.d.ts +2 -4
  50. package/dist/orchestration/research-worker.js +51 -15
  51. package/dist/orchestration/stop-decider.js +3 -1
  52. package/dist/presentation/config-store.js +6 -0
  53. package/dist/presentation/explore-presentation.js +3 -1
  54. package/dist/presentation/fetch-presentation.js +16 -9
  55. package/dist/presentation/search-presentation.d.ts +2 -1
  56. package/dist/presentation/search-presentation.js +13 -1
  57. package/dist/readers/resolver.d.ts +3 -7
  58. package/dist/search/brave.d.ts +1 -2
  59. package/dist/search/brave.js +23 -80
  60. package/dist/search/duckduckgo.d.ts +7 -3
  61. package/dist/search/duckduckgo.js +17 -18
  62. package/dist/search/exa.d.ts +1 -2
  63. package/dist/search/exa.js +15 -76
  64. package/dist/search/fanout.d.ts +12 -0
  65. package/dist/search/fanout.js +86 -47
  66. package/dist/search/json-provider.d.ts +32 -0
  67. package/dist/search/json-provider.js +76 -0
  68. package/dist/search/searxng.d.ts +1 -2
  69. package/dist/search/searxng.js +15 -57
  70. package/dist/search/tavily.d.ts +1 -2
  71. package/dist/search/tavily.js +17 -74
  72. package/dist/search/youcom.d.ts +4 -2
  73. package/dist/search/youcom.js +49 -75
  74. package/dist/tools/web-explore.d.ts +9 -0
  75. package/dist/tools/web-explore.js +16 -2
  76. package/dist/tools/web-fetch-headless.d.ts +3 -5
  77. package/dist/tools/web-fetch-headless.js +3 -3
  78. package/dist/tools/web-fetch.d.ts +3 -5
  79. package/dist/tools/web-fetch.js +3 -3
  80. package/dist/tools/web-search.js +41 -103
  81. package/dist/types.d.ts +48 -0
  82. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, mergeBackendConfigLayers, stripProxyCredentials, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
2
2
  import { checkBackendHealth } from '../backends/doctor.js';
3
+ import { parseCidr } from '../fetch/network-guard.js';
3
4
  import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
4
5
  import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
5
6
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
@@ -32,7 +33,10 @@ function cloneBackendConfig(config) {
32
33
  options: config.fetch.options ? { ...config.fetch.options } : undefined
33
34
  },
34
35
  headless: { ...config.headless },
35
- proxy: config.proxy ? { ...config.proxy } : undefined
36
+ proxy: config.proxy ? { ...config.proxy } : undefined,
37
+ network: config.network
38
+ ? { ...config.network, ...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {}) }
39
+ : undefined
36
40
  };
37
41
  }
38
42
  function sameJson(left, right) {
@@ -50,6 +54,22 @@ export function validateBackendUrl(value) {
50
54
  return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
51
55
  }
52
56
  }
57
+ function splitRanges(value) {
58
+ return value
59
+ .split(',')
60
+ .map((range) => range.trim())
61
+ .filter(Boolean);
62
+ }
63
+ export function validateAllowRanges(value) {
64
+ for (const range of splitRanges(value)) {
65
+ const cidr = parseCidr(range);
66
+ if (!cidr)
67
+ return { ok: false, message: `Not a valid CIDR range: ${range}` };
68
+ if (cidr.prefix === 0)
69
+ return { ok: false, message: `${range} allows every address. List specific ranges instead.` };
70
+ }
71
+ return { ok: true, value: splitRanges(value).join(', ') };
72
+ }
53
73
  /**
54
74
  * The extension re-applies this patch on load, so `pending` here means the
55
75
  * patch could not be written (read-only install, permissions, or a dependency
@@ -140,7 +160,7 @@ function buildPresentationSettingsItems(scope, config) {
140
160
  }))
141
161
  ];
142
162
  }
143
- export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange) {
163
+ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange, validate = validateBackendUrl) {
144
164
  return (currentValue, done) => {
145
165
  onOpenChange?.(true);
146
166
  const initialValue = currentValue && currentValue !== 'not set' ? currentValue : placeholderUrl;
@@ -164,7 +184,7 @@ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChang
164
184
  finish('');
165
185
  return;
166
186
  }
167
- const validated = validateBackendUrl(value);
187
+ const validated = validate(value);
168
188
  if (!validated.ok) {
169
189
  showError(validated.message);
170
190
  return;
@@ -276,6 +296,20 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
276
296
  label: 'Proxy URL',
277
297
  currentValue: backends.proxy ? stripProxyCredentials(backends.proxy.url) : 'not set',
278
298
  submenu: createBackendUrlEditor(theme, 'HTTP proxy URL', 'http://127.0.0.1:7890', onUrlEditorOpenChange)
299
+ },
300
+ {
301
+ id: 'backend:network:allowRanges',
302
+ label: 'Network allow list',
303
+ currentValue: backends.network?.allowRanges?.length ? backends.network.allowRanges.join(', ') : 'not set',
304
+ submenu: createBackendUrlEditor(theme, 'Private ranges to allow, comma separated CIDRs, e.g. 198.18.0.0/15',
305
+ // No prefilled example: pressing enter on an empty list must not quietly add an exception.
306
+ '', onUrlEditorOpenChange, validateAllowRanges)
307
+ },
308
+ {
309
+ id: 'backend:network:trustProxyDns',
310
+ label: 'Trust the upstream proxy to enforce private-address restrictions',
311
+ currentValue: backends.network?.trustProxyDns ? 'on' : 'off',
312
+ values: ['off', 'on']
279
313
  }
280
314
  ];
281
315
  }
@@ -456,6 +490,25 @@ export function applySettingsValue(state, id, newValue) {
456
490
  delete currentBackends.proxy;
457
491
  }
458
492
  }
493
+ if (id === 'backend:network:allowRanges') {
494
+ const ranges = splitRanges(newValue);
495
+ const next = { ...currentBackends.network };
496
+ if (ranges.length > 0) {
497
+ next.allowRanges = ranges;
498
+ }
499
+ else {
500
+ delete next.allowRanges;
501
+ }
502
+ if (Object.keys(next).length > 0) {
503
+ currentBackends.network = next;
504
+ }
505
+ else {
506
+ delete currentBackends.network;
507
+ }
508
+ }
509
+ if (id === 'backend:network:trustProxyDns') {
510
+ currentBackends.network = { ...currentBackends.network, trustProxyDns: newValue === 'on' };
511
+ }
459
512
  nextDrafts[nextScope] = currentDraft;
460
513
  nextBackendDrafts[nextScope] = currentBackends;
461
514
  return {
@@ -531,6 +584,23 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
531
584
  override.proxy = { url: '' };
532
585
  }
533
586
  }
587
+ if (!sameJson(config.network, inheritedConfig.network)) {
588
+ if (config.network) {
589
+ override.network = {
590
+ ...config.network,
591
+ ...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {})
592
+ };
593
+ // Cleared at this scope: an explicit empty list overrides the parent's.
594
+ if (!config.network.allowRanges && inheritedConfig.network?.allowRanges)
595
+ override.network.allowRanges = [];
596
+ }
597
+ else if (inheritedConfig.network) {
598
+ override.network = {
599
+ ...(inheritedConfig.network.allowRanges ? { allowRanges: [] } : {}),
600
+ ...(inheritedConfig.network.trustProxyDns ? { trustProxyDns: false } : {})
601
+ };
602
+ }
603
+ }
534
604
  return override;
535
605
  }
536
606
  export function handleSettingsShortcut(data) {
@@ -753,6 +823,8 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
753
823
  `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
754
824
  formatJitiCompatLine(checkCompat()),
755
825
  formatBackendSummary(backendConfig),
826
+ `network allow list: ${backendConfig.network?.allowRanges?.length ? backendConfig.network.allowRanges.join(', ') : 'none'}`,
827
+ `trust upstream proxy for private addresses: ${backendConfig.network?.trustProxyDns ? 'on' : 'off'}`,
756
828
  backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
757
829
  ...backendHealth
758
830
  ];
package/dist/extension.js CHANGED
@@ -59,20 +59,64 @@ export default function extension(pi) {
59
59
  registerWebAgentConfigCommands(pi);
60
60
  const injectedWebExplore = pi.__webExploreTool;
61
61
  let cachedBackendKey;
62
+ let cachedWorkflow;
62
63
  let cachedWebExplore;
64
+ // Runs in flight per workflow, so a replaced workflow is never closed mid-run.
65
+ const activeRuns = new Map();
66
+ const retiring = new Set();
67
+ const closeWorkflow = (workflow) => {
68
+ retiring.delete(workflow);
69
+ void Promise.resolve(workflow.close?.()).catch(() => undefined);
70
+ };
71
+ const retire = (workflow) => {
72
+ if ((activeRuns.get(workflow) ?? 0) === 0) {
73
+ closeWorkflow(workflow);
74
+ }
75
+ else {
76
+ retiring.add(workflow);
77
+ }
78
+ };
79
+ const leased = (workflow) => ({
80
+ run: async (input) => {
81
+ activeRuns.set(workflow, (activeRuns.get(workflow) ?? 0) + 1);
82
+ try {
83
+ return await workflow.run(input);
84
+ }
85
+ finally {
86
+ const remaining = (activeRuns.get(workflow) ?? 1) - 1;
87
+ if (remaining > 0) {
88
+ activeRuns.set(workflow, remaining);
89
+ }
90
+ else {
91
+ activeRuns.delete(workflow);
92
+ if (retiring.has(workflow))
93
+ closeWorkflow(workflow);
94
+ }
95
+ }
96
+ }
97
+ });
63
98
  async function getConfiguredWebExplore() {
64
99
  if (injectedWebExplore)
65
100
  return injectedWebExplore;
66
101
  const backendConfig = await getEffectiveBackendConfig(pi);
67
102
  const backendKey = JSON.stringify(backendConfig);
68
103
  if (!cachedWebExplore || cachedBackendKey !== backendKey) {
104
+ if (cachedWorkflow)
105
+ retire(cachedWorkflow);
69
106
  cachedBackendKey = backendKey;
70
- cachedWebExplore = createWebExploreTool({
71
- explore: createResearchWorkflow({ backendConfig })
72
- });
107
+ cachedWorkflow = createResearchWorkflow({ backendConfig });
108
+ cachedWebExplore = createWebExploreTool({ explore: leased(cachedWorkflow) });
73
109
  }
74
110
  return cachedWebExplore;
75
111
  }
112
+ pi.on('session_shutdown', async () => {
113
+ const workflow = cachedWorkflow;
114
+ cachedWorkflow = undefined;
115
+ cachedWebExplore = undefined;
116
+ cachedBackendKey = undefined;
117
+ if (workflow)
118
+ retire(workflow);
119
+ });
76
120
  pi.on('session_start', async (_event, ctx) => {
77
121
  try {
78
122
  const notice = await getUpdateChangelogNotice();
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { BlockedAddressError, UnverifiedDestinationError, type NetworkGuard } from './network-guard.js';
2
+ export type Destination = {
3
+ action: 'connect';
4
+ host: string;
5
+ address: string;
6
+ } | {
7
+ action: 'delegate';
8
+ host: string;
9
+ } | {
10
+ action: 'refuse';
11
+ error: BlockedAddressError | UnverifiedDestinationError;
12
+ };
13
+ export type DestinationMode = {
14
+ /** An upstream proxy is configured. */
15
+ upstream: boolean;
16
+ /**
17
+ * The user explicitly trusts the upstream proxy to enforce private-address
18
+ * restrictions. Only meaningful with an upstream.
19
+ */
20
+ trustProxyDns?: boolean;
21
+ };
22
+ /**
23
+ * Turns one resolution into where a connection may go (spec revision 2).
24
+ *
25
+ * - blocked answers are refused in every mode
26
+ * - by default the connection goes to an address we checked, never a hostname
27
+ * something else will resolve
28
+ * - with a trusted upstream the hostname is delegated, which also covers names
29
+ * only that proxy can resolve
30
+ * - anything else unresolvable is refused
31
+ */
32
+ export declare function decideDestination(host: string, guard: NetworkGuard, mode: DestinationMode): Promise<Destination>;
@@ -0,0 +1,24 @@
1
+ import { BlockedAddressError, UnverifiedDestinationError } from './network-guard.js';
2
+ /**
3
+ * Turns one resolution into where a connection may go (spec revision 2).
4
+ *
5
+ * - blocked answers are refused in every mode
6
+ * - by default the connection goes to an address we checked, never a hostname
7
+ * something else will resolve
8
+ * - with a trusted upstream the hostname is delegated, which also covers names
9
+ * only that proxy can resolve
10
+ * - anything else unresolvable is refused
11
+ */
12
+ export async function decideDestination(host, guard, mode) {
13
+ const resolution = await guard.resolveHost(host);
14
+ if (resolution.status === 'blocked') {
15
+ return { action: 'refuse', error: new BlockedAddressError(resolution.host, resolution.address) };
16
+ }
17
+ if (mode.upstream && mode.trustProxyDns) {
18
+ return { action: 'delegate', host: resolution.host };
19
+ }
20
+ if (resolution.status === 'unresolved') {
21
+ return { action: 'refuse', error: new UnverifiedDestinationError(resolution.host) };
22
+ }
23
+ return { action: 'connect', host: resolution.host, address: resolution.addresses[0] };
24
+ }
@@ -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>;