@demigodmode/pi-web-agent 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/doctor.js +27 -29
- package/dist/backends/factory.d.ts +18 -7
- package/dist/backends/factory.js +126 -99
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +31 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +110 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +14 -1
- package/dist/commands/web-agent-config.js +75 -3
- package/dist/extension.js +47 -3
- package/dist/extract/bot-check.d.ts +1 -0
- package/dist/extract/bot-check.js +10 -0
- package/dist/extract/readability.d.ts +4 -0
- package/dist/extract/readability.js +67 -3
- package/dist/extract/section-selector.d.ts +14 -0
- package/dist/extract/section-selector.js +233 -0
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.d.ts +1 -1
- package/dist/fetch/firecrawl-fetch.js +74 -46
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +11 -2
- package/dist/fetch/headless-fetch.js +190 -13
- package/dist/fetch/http-fetch.d.ts +1 -1
- package/dist/fetch/http-fetch.js +29 -8
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +26 -7
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +23 -7
- package/dist/orchestration/research-orchestrator.js +60 -26
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.d.ts +2 -4
- package/dist/orchestration/research-worker.js +51 -15
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +6 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/readers/resolver.d.ts +3 -7
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +4 -2
- package/dist/search/youcom.js +49 -75
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-fetch-headless.d.ts +3 -5
- package/dist/tools/web-fetch-headless.js +3 -3
- package/dist/tools/web-fetch.d.ts +3 -5
- package/dist/tools/web-fetch.js +3 -3
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +48 -0
- package/package.json +3 -3
|
@@ -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 =
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
61
|
-
if (
|
|
62
|
-
|
|
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
|
+
}
|