@demigodmode/pi-web-agent 1.8.0 → 1.10.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 -1
- package/README.md +64 -76
- package/dist/backends/config.d.ts +8 -0
- package/dist/backends/config.js +45 -0
- package/dist/backends/doctor.js +33 -0
- package/dist/backends/factory.js +48 -2
- package/dist/commands/web-agent-config.js +63 -6
- package/dist/extension.js +53 -12
- package/dist/orchestration/direct-url.js +2 -25
- package/dist/orchestration/evidence-quality.d.ts +1 -0
- package/dist/orchestration/evidence-quality.js +4 -2
- package/dist/orchestration/evidence-ranker.js +2 -0
- package/dist/orchestration/index.d.ts +2 -0
- package/dist/orchestration/research-orchestrator.d.ts +3 -1
- package/dist/orchestration/research-orchestrator.js +63 -6
- package/dist/orchestration/research-types.d.ts +6 -1
- package/dist/orchestration/research-worker.js +26 -3
- package/dist/orchestration/url.d.ts +6 -0
- package/dist/orchestration/url.js +34 -0
- package/dist/presentation/explore-presentation.js +12 -4
- package/dist/presentation/search-presentation.js +14 -2
- package/dist/readers/github-reader.js +3 -2
- package/dist/readers/limits.d.ts +3 -0
- package/dist/readers/limits.js +3 -0
- package/dist/readers/pdf-reader.js +3 -2
- package/dist/readers/youtube-reader.js +3 -2
- package/dist/search/duckduckgo.d.ts +10 -1
- package/dist/search/duckduckgo.js +23 -5
- package/dist/search/fanout.d.ts +14 -0
- package/dist/search/fanout.js +118 -0
- package/dist/search/tavily.d.ts +2 -1
- package/dist/search/tavily.js +5 -3
- package/dist/tools/web-explore.d.ts +2 -0
- package/dist/tools/web-search.js +21 -9
- package/dist/types.d.ts +11 -1
- package/package.json +1 -1
package/dist/extension.js
CHANGED
|
@@ -7,6 +7,30 @@ import { loadPresentationConfigLayers } from './presentation/config-store.js';
|
|
|
7
7
|
import { selectPresentationView } from './presentation/select-view.js';
|
|
8
8
|
import { createWebExploreTool } from './tools/web-explore.js';
|
|
9
9
|
import { getUpdateChangelogNotice } from './changelog-notice.js';
|
|
10
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
11
|
+
/**
|
|
12
|
+
* What the model receives as the tool result: the actual synthesized findings (which for a
|
|
13
|
+
* direct GitHub/PDF/YouTube read is the full extracted content), plus source citations and any
|
|
14
|
+
* caveat. This is separate from the terminal display (see renderResult), so the model always
|
|
15
|
+
* gets the substance regardless of the user's compact/preview/verbose presentation setting.
|
|
16
|
+
*/
|
|
17
|
+
function serializeForModel(result) {
|
|
18
|
+
if (result.status === 'error') {
|
|
19
|
+
return `Research failed: ${result.error?.message ?? 'Unknown research failure.'}`;
|
|
20
|
+
}
|
|
21
|
+
if (result.findings.length === 0 && result.sources.length === 0) {
|
|
22
|
+
return 'No usable evidence found.';
|
|
23
|
+
}
|
|
24
|
+
return [
|
|
25
|
+
result.findings.join('\n\n'),
|
|
26
|
+
result.sources.length
|
|
27
|
+
? `Sources:\n${result.sources.map((source) => `- ${source.title}: ${source.url}`).join('\n')}`
|
|
28
|
+
: undefined,
|
|
29
|
+
result.caveat
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join('\n\n');
|
|
33
|
+
}
|
|
10
34
|
async function loadWebAgentConfig(pi) {
|
|
11
35
|
const store = pi.__presentationConfigStore;
|
|
12
36
|
return store?.load?.() ?? loadPresentationConfigLayers();
|
|
@@ -29,11 +53,6 @@ async function getEffectiveBackendConfig(pi) {
|
|
|
29
53
|
return DEFAULT_BACKEND_CONFIG;
|
|
30
54
|
}
|
|
31
55
|
}
|
|
32
|
-
async function renderToolText(pi, toolName, details) {
|
|
33
|
-
const config = await getEffectivePresentationConfig(pi);
|
|
34
|
-
const mode = resolvePresentationMode(toolName, config);
|
|
35
|
-
return selectPresentationView(details.presentation, mode) ?? JSON.stringify(details, null, 2);
|
|
36
|
-
}
|
|
37
56
|
export default function extension(pi) {
|
|
38
57
|
registerWebAgentConfigCommands(pi);
|
|
39
58
|
const injectedWebExplore = pi.__webExploreTool;
|
|
@@ -79,16 +98,38 @@ export default function extension(pi) {
|
|
|
79
98
|
async execute(_toolCallId, params) {
|
|
80
99
|
const webExplore = await getConfiguredWebExplore();
|
|
81
100
|
const result = await webExplore({ query: params.query });
|
|
101
|
+
// Terminal display honors the user's presentation mode; the model gets the full findings.
|
|
102
|
+
// The fallback must stay terse: never fall back to serializeForModel here, or a missing
|
|
103
|
+
// presentation would dump the full findings into the terminal.
|
|
104
|
+
const mode = resolvePresentationMode('web_explore', await getEffectivePresentationConfig(pi));
|
|
105
|
+
const terseFallback = 'web_explore result';
|
|
106
|
+
const terminalText = selectPresentationView(result.presentation, mode) ?? terseFallback;
|
|
107
|
+
const terminalTextExpanded = selectPresentationView(result.presentation, 'verbose') ?? terminalText;
|
|
82
108
|
return {
|
|
83
|
-
content: [
|
|
84
|
-
|
|
85
|
-
type: 'text',
|
|
86
|
-
text: await renderToolText(pi, 'web_explore', result)
|
|
87
|
-
}
|
|
88
|
-
],
|
|
89
|
-
details: result,
|
|
109
|
+
content: [{ type: 'text', text: serializeForModel(result) }],
|
|
110
|
+
details: { ...result, terminalText, terminalTextExpanded },
|
|
90
111
|
isError: result.status === 'error'
|
|
91
112
|
};
|
|
113
|
+
},
|
|
114
|
+
renderResult(toolResult, options) {
|
|
115
|
+
const details = toolResult.details;
|
|
116
|
+
try {
|
|
117
|
+
// Legacy fallback: results persisted before this change carry `presentation` but no
|
|
118
|
+
// `terminalText`, so old sessions stay readable instead of rendering blank.
|
|
119
|
+
const legacy = options.expanded
|
|
120
|
+
? details?.presentation?.views?.verbose ?? details?.presentation?.views?.compact
|
|
121
|
+
: details?.presentation?.views?.compact;
|
|
122
|
+
const text = (options.expanded ? details?.terminalTextExpanded : details?.terminalText) ??
|
|
123
|
+
details?.terminalText ??
|
|
124
|
+
legacy ??
|
|
125
|
+
'web_explore result';
|
|
126
|
+
return new Text(text, 0, 0);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Never throw: a thrown renderer makes Pi fall back to raw content, which would dump
|
|
130
|
+
// the full model-facing findings into the terminal.
|
|
131
|
+
return new Text('web_explore result', 0, 0);
|
|
132
|
+
}
|
|
92
133
|
}
|
|
93
134
|
});
|
|
94
135
|
}
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
'utm_source',
|
|
3
|
-
'utm_medium',
|
|
4
|
-
'utm_campaign',
|
|
5
|
-
'utm_term',
|
|
6
|
-
'utm_content',
|
|
7
|
-
'utm_name',
|
|
8
|
-
'fbclid',
|
|
9
|
-
'gclid'
|
|
10
|
-
]);
|
|
1
|
+
import { canonicalizeUrl } from './url.js';
|
|
11
2
|
function stripTrailingPunctuation(raw) {
|
|
12
3
|
let next = raw.trim();
|
|
13
4
|
while (/[),.;!?\]]$/.test(next)) {
|
|
@@ -19,21 +10,7 @@ function stripTrailingPunctuation(raw) {
|
|
|
19
10
|
return next;
|
|
20
11
|
}
|
|
21
12
|
function normalizeDirectUrl(raw) {
|
|
22
|
-
|
|
23
|
-
const url = new URL(stripTrailingPunctuation(raw));
|
|
24
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
25
|
-
return undefined;
|
|
26
|
-
for (const key of [...url.searchParams.keys()]) {
|
|
27
|
-
if (TRACKING_PARAMS.has(key.toLowerCase())) {
|
|
28
|
-
url.searchParams.delete(key);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
url.hash = '';
|
|
32
|
-
return url.toString().replace(/\/$/, '');
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
13
|
+
return canonicalizeUrl(stripTrailingPunctuation(raw), { canonicalizeHost: false });
|
|
37
14
|
}
|
|
38
15
|
export function extractDirectUrls(query) {
|
|
39
16
|
const matches = query.match(/https?:\/\/\S+/gi) ?? [];
|
|
@@ -25,10 +25,11 @@ export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }) {
|
|
|
25
25
|
const community = evidence.filter((item) => item.sourceKind === 'community').length;
|
|
26
26
|
const thread = evidence.filter((item) => item.sourceKind === 'issue-thread' || item.sourceKind === 'official-discussion').length;
|
|
27
27
|
const packagePage = evidence.filter((item) => item.sourceKind === 'package-page').length;
|
|
28
|
+
const primaryContent = evidence.filter((item) => item.sourceKind === 'primary-content').length;
|
|
28
29
|
const distinctHosts = new Set(evidence.map((item) => hostname(item.url))).size;
|
|
29
30
|
const hasOfficialEvidence = official > 0;
|
|
30
|
-
const hasOnlyCommunityEvidence = evidence.length > 0 && official === 0;
|
|
31
|
-
const hasLowDiversity = evidence.length > 1 && distinctHosts <= 1;
|
|
31
|
+
const hasOnlyCommunityEvidence = evidence.length > 0 && official === 0 && primaryContent === 0;
|
|
32
|
+
const hasLowDiversity = evidence.length > 1 && distinctHosts <= 1 && primaryContent === 0;
|
|
32
33
|
const hasUnreadableDirectSource = gaps.some((gap) => /Direct URL could not be read reliably/i.test(gap.message));
|
|
33
34
|
const hasUnreadableThreadSource = gaps.some((gap) => /Thread source could not be read reliably/i.test(gap.message));
|
|
34
35
|
const hasPossibleConflict = hasConflictMarkers(evidence);
|
|
@@ -47,6 +48,7 @@ export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }) {
|
|
|
47
48
|
community,
|
|
48
49
|
thread,
|
|
49
50
|
packagePage,
|
|
51
|
+
primaryContent,
|
|
50
52
|
distinctHosts
|
|
51
53
|
},
|
|
52
54
|
flags: {
|
|
@@ -24,6 +24,8 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
|
|
|
24
24
|
headlessAttempts: number;
|
|
25
25
|
exhaustedBudget: boolean;
|
|
26
26
|
caveatReasons: import("./evidence-quality.js").EvidenceCaveatReason[];
|
|
27
|
+
fanoutProviders: import("../types.js").SearchProviderName[] | undefined;
|
|
28
|
+
fanoutSkipped: import("../types.js").SearchProviderName[] | undefined;
|
|
27
29
|
};
|
|
28
30
|
}>;
|
|
29
31
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
|
|
1
|
+
import type { 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 }: {
|
|
@@ -28,6 +28,8 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
|
|
|
28
28
|
headlessAttempts: number;
|
|
29
29
|
exhaustedBudget: boolean;
|
|
30
30
|
caveatReasons: EvidenceCaveatReason[];
|
|
31
|
+
fanoutProviders: SearchProviderName[] | undefined;
|
|
32
|
+
fanoutSkipped: SearchProviderName[] | undefined;
|
|
31
33
|
};
|
|
32
34
|
}>;
|
|
33
35
|
};
|
|
@@ -13,6 +13,9 @@ function classifyEvidenceUrl(url) {
|
|
|
13
13
|
function summarizeText(text, maxLength = 180) {
|
|
14
14
|
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
15
15
|
}
|
|
16
|
+
function isReaderMethod(method) {
|
|
17
|
+
return method === 'github' || method === 'pdf' || method === 'youtube';
|
|
18
|
+
}
|
|
16
19
|
function isBotCheckContent({ title = '', text }) {
|
|
17
20
|
return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
|
|
18
21
|
}
|
|
@@ -21,6 +24,16 @@ function evidenceFromFetch(result) {
|
|
|
21
24
|
return null;
|
|
22
25
|
if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
|
|
23
26
|
return null;
|
|
27
|
+
if (isReaderMethod(result.metadata.method)) {
|
|
28
|
+
return {
|
|
29
|
+
title: result.content.title ?? result.url,
|
|
30
|
+
url: result.url,
|
|
31
|
+
sourceKind: 'primary-content',
|
|
32
|
+
method: result.metadata.method,
|
|
33
|
+
summary: result.content.text,
|
|
34
|
+
supports: [result.content.text]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
24
37
|
return {
|
|
25
38
|
title: result.content.title ?? result.url,
|
|
26
39
|
url: result.url,
|
|
@@ -66,13 +79,15 @@ function shouldRetryDirectWithHeadless(result, evidence) {
|
|
|
66
79
|
return false;
|
|
67
80
|
return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
|
|
68
81
|
}
|
|
69
|
-
function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [] }) {
|
|
82
|
+
function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [], fanoutProviders, fanoutSkipped }) {
|
|
70
83
|
return {
|
|
71
84
|
searchPasses: previousQueries.length,
|
|
72
85
|
fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
|
|
73
86
|
headlessAttempts,
|
|
74
87
|
exhaustedBudget,
|
|
75
|
-
caveatReasons
|
|
88
|
+
caveatReasons,
|
|
89
|
+
fanoutProviders,
|
|
90
|
+
fanoutSkipped
|
|
76
91
|
};
|
|
77
92
|
}
|
|
78
93
|
function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
|
|
@@ -99,6 +114,13 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
99
114
|
const suggestedHeadlessUrls = [];
|
|
100
115
|
let headlessAttempts = 0;
|
|
101
116
|
let lastPass;
|
|
117
|
+
const fanoutProvidersSeen = new Set();
|
|
118
|
+
const fanoutSkippedSeen = new Set();
|
|
119
|
+
function fanoutSnapshot() {
|
|
120
|
+
const providers = fanoutProvidersSeen.size ? [...fanoutProvidersSeen] : undefined;
|
|
121
|
+
const skipped = [...fanoutSkippedSeen].filter((p) => !fanoutProvidersSeen.has(p));
|
|
122
|
+
return { fanoutProviders: providers, fanoutSkipped: skipped.length ? skipped : undefined };
|
|
123
|
+
}
|
|
102
124
|
if (fetchDirect) {
|
|
103
125
|
for (const url of extractDirectUrls(query).slice(0, 3)) {
|
|
104
126
|
const directResult = await fetchDirect({ url });
|
|
@@ -134,6 +156,35 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
134
156
|
}
|
|
135
157
|
}
|
|
136
158
|
}
|
|
159
|
+
if (allEvidence.some((item) => item.sourceKind === 'primary-content')) {
|
|
160
|
+
const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
|
|
161
|
+
const quality = analyzeEvidenceQuality({
|
|
162
|
+
evidence: ranked,
|
|
163
|
+
gaps: allGaps,
|
|
164
|
+
lowValueOutcomes: allLowValueOutcomes
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
decision: decisionForAnswer({ action: 'answer', query, ranked, exhaustedBudget: false }),
|
|
168
|
+
evidence: ranked,
|
|
169
|
+
workerPass: combinedWorkerPass({
|
|
170
|
+
lastPass,
|
|
171
|
+
previousQueries,
|
|
172
|
+
allGaps,
|
|
173
|
+
allLowValueOutcomes,
|
|
174
|
+
exhaustedBudget: false
|
|
175
|
+
}),
|
|
176
|
+
metadata: buildMetadata({
|
|
177
|
+
previousQueries,
|
|
178
|
+
allEvidence,
|
|
179
|
+
allGaps,
|
|
180
|
+
allLowValueOutcomes,
|
|
181
|
+
headlessAttempts,
|
|
182
|
+
exhaustedBudget: false,
|
|
183
|
+
caveatReasons: quality.caveatReasons,
|
|
184
|
+
...fanoutSnapshot()
|
|
185
|
+
})
|
|
186
|
+
};
|
|
187
|
+
}
|
|
137
188
|
for (let passIndex = 0; passIndex < DEFAULT_MAX_PASSES; passIndex++) {
|
|
138
189
|
const queries = planSearchQueries({
|
|
139
190
|
originalQuery: query,
|
|
@@ -149,6 +200,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
149
200
|
maxFetches: DEFAULT_MAX_FETCHES_PER_PASS
|
|
150
201
|
});
|
|
151
202
|
lastPass = pass;
|
|
203
|
+
pass.fanoutProviders?.forEach((p) => fanoutProvidersSeen.add(p));
|
|
204
|
+
pass.fanoutSkipped?.forEach((p) => fanoutSkippedSeen.add(p));
|
|
152
205
|
allEvidence.push(...pass.evidence);
|
|
153
206
|
allGaps.push(...pass.gaps);
|
|
154
207
|
allLowValueOutcomes.push(...pass.lowValueOutcomes);
|
|
@@ -213,7 +266,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
213
266
|
allLowValueOutcomes,
|
|
214
267
|
headlessAttempts,
|
|
215
268
|
exhaustedBudget,
|
|
216
|
-
caveatReasons: updatedQuality.caveatReasons
|
|
269
|
+
caveatReasons: updatedQuality.caveatReasons,
|
|
270
|
+
...fanoutSnapshot()
|
|
217
271
|
})
|
|
218
272
|
};
|
|
219
273
|
}
|
|
@@ -239,7 +293,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
239
293
|
allLowValueOutcomes,
|
|
240
294
|
headlessAttempts,
|
|
241
295
|
exhaustedBudget: false,
|
|
242
|
-
caveatReasons: quality.caveatReasons
|
|
296
|
+
caveatReasons: quality.caveatReasons,
|
|
297
|
+
...fanoutSnapshot()
|
|
243
298
|
})
|
|
244
299
|
};
|
|
245
300
|
}
|
|
@@ -262,7 +317,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
262
317
|
allLowValueOutcomes,
|
|
263
318
|
headlessAttempts,
|
|
264
319
|
exhaustedBudget,
|
|
265
|
-
caveatReasons: quality.caveatReasons
|
|
320
|
+
caveatReasons: quality.caveatReasons,
|
|
321
|
+
...fanoutSnapshot()
|
|
266
322
|
})
|
|
267
323
|
};
|
|
268
324
|
}
|
|
@@ -291,7 +347,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
291
347
|
allLowValueOutcomes,
|
|
292
348
|
headlessAttempts,
|
|
293
349
|
exhaustedBudget: true,
|
|
294
|
-
caveatReasons: quality.caveatReasons
|
|
350
|
+
caveatReasons: quality.caveatReasons,
|
|
351
|
+
...fanoutSnapshot()
|
|
295
352
|
})
|
|
296
353
|
};
|
|
297
354
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { SearchProviderName } from '../types.js';
|
|
2
|
+
export type ResearchSourceKind = 'primary-content' | 'official-docs' | 'official-api' | 'official-discussion' | 'community' | 'issue-thread' | 'package-page' | 'other';
|
|
2
3
|
export type ResearchMethod = 'search' | 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
|
|
3
4
|
export type ResearchEvidence = {
|
|
4
5
|
title: string;
|
|
@@ -24,12 +25,16 @@ export type ResearchWorkerResult = {
|
|
|
24
25
|
lowValueOutcomes: ResearchLowValueOutcome[];
|
|
25
26
|
suggestedHeadlessUrl?: string;
|
|
26
27
|
exhaustedBudget: boolean;
|
|
28
|
+
fanoutProviders?: SearchProviderName[];
|
|
29
|
+
fanoutSkipped?: SearchProviderName[];
|
|
27
30
|
};
|
|
28
31
|
export type ResearchRunMetadata = {
|
|
29
32
|
searchPasses: number;
|
|
30
33
|
fetchedPages: number;
|
|
31
34
|
headlessAttempts: number;
|
|
32
35
|
exhaustedBudget: boolean;
|
|
36
|
+
fanoutProviders?: SearchProviderName[];
|
|
37
|
+
fanoutSkipped?: SearchProviderName[];
|
|
33
38
|
};
|
|
34
39
|
export type ResearchOrchestratorDecision = {
|
|
35
40
|
action: 'answer';
|
|
@@ -6,6 +6,9 @@ function classifySource(url) {
|
|
|
6
6
|
function summarizeText(text, maxLength = 180) {
|
|
7
7
|
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
8
8
|
}
|
|
9
|
+
function isReaderMethod(method) {
|
|
10
|
+
return method === 'github' || method === 'pdf' || method === 'youtube';
|
|
11
|
+
}
|
|
9
12
|
function isBotCheckContent({ title = '', text }) {
|
|
10
13
|
return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
|
|
11
14
|
}
|
|
@@ -15,6 +18,18 @@ function evidenceFromFetch(fetched, fallbackTitle) {
|
|
|
15
18
|
return null;
|
|
16
19
|
if (isBotCheckContent({ title: content.title, text: content.text }))
|
|
17
20
|
return null;
|
|
21
|
+
// A successful reader read with usable text is primary content, exempt from the
|
|
22
|
+
// package-page filter below.
|
|
23
|
+
if (isReaderMethod(fetched.metadata.method) && content.text.trim()) {
|
|
24
|
+
return {
|
|
25
|
+
title: content.title ?? fallbackTitle,
|
|
26
|
+
url: fetched.url,
|
|
27
|
+
sourceKind: 'primary-content',
|
|
28
|
+
method: fetched.metadata.method,
|
|
29
|
+
summary: content.text,
|
|
30
|
+
supports: [content.text]
|
|
31
|
+
};
|
|
32
|
+
}
|
|
18
33
|
const sourceKind = classifySource(fetched.url);
|
|
19
34
|
if (sourceKind === 'package-page') {
|
|
20
35
|
return null;
|
|
@@ -65,6 +80,8 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
65
80
|
};
|
|
66
81
|
}
|
|
67
82
|
const searchResult = await search({ query });
|
|
83
|
+
const fanoutProviders = searchResult.metadata.fanout?.providers;
|
|
84
|
+
const fanoutSkipped = searchResult.metadata.fanout?.skipped;
|
|
68
85
|
if (searchResult.status !== 'ok') {
|
|
69
86
|
return {
|
|
70
87
|
searchQueries,
|
|
@@ -77,7 +94,9 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
77
94
|
],
|
|
78
95
|
lowValueOutcomes,
|
|
79
96
|
suggestedHeadlessUrl,
|
|
80
|
-
exhaustedBudget: false
|
|
97
|
+
exhaustedBudget: false,
|
|
98
|
+
fanoutProviders,
|
|
99
|
+
fanoutSkipped
|
|
81
100
|
};
|
|
82
101
|
}
|
|
83
102
|
if (searchResult.results.length === 0) {
|
|
@@ -92,7 +111,9 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
92
111
|
}
|
|
93
112
|
],
|
|
94
113
|
suggestedHeadlessUrl,
|
|
95
|
-
exhaustedBudget: false
|
|
114
|
+
exhaustedBudget: false,
|
|
115
|
+
fanoutProviders,
|
|
116
|
+
fanoutSkipped
|
|
96
117
|
};
|
|
97
118
|
}
|
|
98
119
|
const candidates = selectCandidates({
|
|
@@ -133,7 +154,9 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
133
154
|
gaps,
|
|
134
155
|
lowValueOutcomes,
|
|
135
156
|
suggestedHeadlessUrl,
|
|
136
|
-
exhaustedBudget: false
|
|
157
|
+
exhaustedBudget: false,
|
|
158
|
+
fanoutProviders,
|
|
159
|
+
fanoutSkipped
|
|
137
160
|
};
|
|
138
161
|
}
|
|
139
162
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
declare const TRACKING_PARAMS: Set<string>;
|
|
2
|
+
/** Canonical form for dedupe/comparison. Returns undefined for non-http(s) or unparseable input. */
|
|
3
|
+
export declare function canonicalizeUrl(raw: string, options?: {
|
|
4
|
+
canonicalizeHost?: boolean;
|
|
5
|
+
}): string | undefined;
|
|
6
|
+
export { TRACKING_PARAMS };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TRACKING_PARAMS = new Set([
|
|
2
|
+
'utm_source',
|
|
3
|
+
'utm_medium',
|
|
4
|
+
'utm_campaign',
|
|
5
|
+
'utm_term',
|
|
6
|
+
'utm_content',
|
|
7
|
+
'utm_name',
|
|
8
|
+
'fbclid',
|
|
9
|
+
'gclid'
|
|
10
|
+
]);
|
|
11
|
+
/** Canonical form for dedupe/comparison. Returns undefined for non-http(s) or unparseable input. */
|
|
12
|
+
export function canonicalizeUrl(raw, options = {}) {
|
|
13
|
+
const { canonicalizeHost = true } = options;
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
22
|
+
return undefined;
|
|
23
|
+
if (canonicalizeHost) {
|
|
24
|
+
url.hostname = url.hostname.replace(/^www\./, '');
|
|
25
|
+
}
|
|
26
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
27
|
+
if (TRACKING_PARAMS.has(key.toLowerCase())) {
|
|
28
|
+
url.searchParams.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
url.hash = '';
|
|
32
|
+
return url.toString().replace(/\/$/, '');
|
|
33
|
+
}
|
|
34
|
+
export { TRACKING_PARAMS };
|
|
@@ -22,8 +22,15 @@ export function buildExplorePresentation(result) {
|
|
|
22
22
|
}
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
const fanoutProviders = result.metadata?.fanoutProviders;
|
|
26
|
+
const fanoutSkipped = result.metadata?.fanoutSkipped;
|
|
27
|
+
const fanoutNote = fanoutProviders?.length
|
|
28
|
+
? ` (fanout: ${fanoutProviders.join(', ')}${fanoutSkipped?.length ? `; skipped: ${fanoutSkipped.join(', ')}` : ''})`
|
|
29
|
+
: fanoutSkipped?.length
|
|
30
|
+
? ` (fanout; skipped: ${fanoutSkipped.join(', ')})`
|
|
31
|
+
: '';
|
|
25
32
|
const internalSummary = result.metadata
|
|
26
|
-
? `Internal research: web_search ×${result.metadata.searchPasses}, web_fetch ×${result.metadata.fetchedPages}, web_fetch_headless ×${result.metadata.headlessAttempts}`
|
|
33
|
+
? `Internal research: web_search ×${result.metadata.searchPasses}${fanoutNote}, web_fetch ×${result.metadata.fetchedPages}, web_fetch_headless ×${result.metadata.headlessAttempts}`
|
|
27
34
|
: undefined;
|
|
28
35
|
const hasEvidence = result.findings.length > 0 || result.sources.length > 0;
|
|
29
36
|
const evidenceLines = hasEvidence
|
|
@@ -46,12 +53,13 @@ export function buildExplorePresentation(result) {
|
|
|
46
53
|
]
|
|
47
54
|
.filter((line) => line !== undefined)
|
|
48
55
|
.join('\n');
|
|
56
|
+
const compact = hasEvidence
|
|
57
|
+
? `Reviewed ${result.sources.length} sources · synthesized answer with ${result.findings.length} findings`
|
|
58
|
+
: 'No usable evidence found';
|
|
49
59
|
return {
|
|
50
60
|
mode: 'compact',
|
|
51
61
|
views: {
|
|
52
|
-
compact
|
|
53
|
-
? `Reviewed ${result.sources.length} sources · synthesized answer with ${result.findings.length} findings`
|
|
54
|
-
: 'No usable evidence found',
|
|
62
|
+
compact,
|
|
55
63
|
preview,
|
|
56
64
|
verbose
|
|
57
65
|
},
|
|
@@ -1,12 +1,24 @@
|
|
|
1
|
+
function fanoutNote(result) {
|
|
2
|
+
const f = result.metadata.fanout;
|
|
3
|
+
if (!f)
|
|
4
|
+
return '';
|
|
5
|
+
if (f.providers.length) {
|
|
6
|
+
return ` (fanout: ${f.providers.join(', ')}${f.skipped?.length ? `; skipped: ${f.skipped.join(', ')}` : ''})`;
|
|
7
|
+
}
|
|
8
|
+
if (f.skipped?.length) {
|
|
9
|
+
return ` (fanout; skipped: ${f.skipped.join(', ')})`;
|
|
10
|
+
}
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
1
13
|
function formatCompact(result) {
|
|
2
14
|
const fallbackPrefix = result.metadata.fallbackFrom
|
|
3
15
|
? `${result.metadata.fallbackFrom} failed; used ${result.metadata.backend} fallback. `
|
|
4
16
|
: '';
|
|
5
17
|
if (result.status === 'error') {
|
|
6
|
-
return `${fallbackPrefix}Search failed: ${result.error?.message ?? 'Unknown search failure.'}`;
|
|
18
|
+
return `${fallbackPrefix}Search failed: ${result.error?.message ?? 'Unknown search failure.'}${fanoutNote(result)}`;
|
|
7
19
|
}
|
|
8
20
|
const suffix = result.results.length === 1 ? 'result' : 'results';
|
|
9
|
-
return `${fallbackPrefix}Found ${result.results.length} ${suffix}`;
|
|
21
|
+
return `${fallbackPrefix}Found ${result.results.length} ${suffix}${fanoutNote(result)}`;
|
|
10
22
|
}
|
|
11
23
|
export function buildSearchPresentation(result) {
|
|
12
24
|
const preview = result.results
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { READER_TEXT_CAP } from './limits.js';
|
|
1
2
|
function parseGithub(url) {
|
|
2
3
|
try {
|
|
3
4
|
return new URL(url);
|
|
@@ -51,12 +52,12 @@ function fail(url, message) {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
function okResponse(url, title, text) {
|
|
54
|
-
const capped = text.slice(0,
|
|
55
|
+
const capped = text.slice(0, READER_TEXT_CAP);
|
|
55
56
|
return {
|
|
56
57
|
status: 'ok',
|
|
57
58
|
url,
|
|
58
59
|
content: { title, text: capped },
|
|
59
|
-
metadata: { method: 'github', cacheHit: false, truncated: text.length >=
|
|
60
|
+
metadata: { method: 'github', cacheHit: false, truncated: text.length >= READER_TEXT_CAP }
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
export function createGithubReader({ fetchImpl = fetch, token = process.env.GITHUB_TOKEN } = {}) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { extractText, getDocumentProxy, getMeta } from 'unpdf';
|
|
2
|
+
import { READER_TEXT_CAP } from './limits.js';
|
|
2
3
|
async function defaultExtract(bytes) {
|
|
3
4
|
const pdf = await getDocumentProxy(bytes);
|
|
4
5
|
const [{ text }, meta] = await Promise.all([
|
|
@@ -58,8 +59,8 @@ export function createPdfReader({ fetchImpl = fetch, extractPdfText = defaultExt
|
|
|
58
59
|
return {
|
|
59
60
|
status: 'ok',
|
|
60
61
|
url,
|
|
61
|
-
content: { title: extracted.title ?? filenameFromUrl(url), text: text.slice(0,
|
|
62
|
-
metadata: { method: 'pdf', cacheHit: false, contentType: 'application/pdf', truncated: text.length >=
|
|
62
|
+
content: { title: extracted.title ?? filenameFromUrl(url), text: text.slice(0, READER_TEXT_CAP) },
|
|
63
|
+
metadata: { method: 'pdf', cacheHit: false, contentType: 'application/pdf', truncated: text.length >= READER_TEXT_CAP }
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
catch (err) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSubtitles, getVideoDetails } from 'youtube-caption-extractor';
|
|
2
|
+
import { READER_TEXT_CAP } from './limits.js';
|
|
2
3
|
function extractVideoId(url) {
|
|
3
4
|
let parsed;
|
|
4
5
|
try {
|
|
@@ -53,8 +54,8 @@ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetail
|
|
|
53
54
|
return {
|
|
54
55
|
status: 'ok',
|
|
55
56
|
url,
|
|
56
|
-
content: { title: details.title ?? `YouTube ${videoID}`, text: transcript.slice(0,
|
|
57
|
-
metadata: { method: 'youtube', cacheHit: false, truncated: transcript.length >=
|
|
57
|
+
content: { title: details.title ?? `YouTube ${videoID}`, text: transcript.slice(0, READER_TEXT_CAP) },
|
|
58
|
+
metadata: { method: 'youtube', cacheHit: false, truncated: transcript.length >= READER_TEXT_CAP }
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
catch (err) {
|
|
@@ -5,5 +5,14 @@ export type ParsedDuckDuckGoResults = {
|
|
|
5
5
|
hasResultContainers: boolean;
|
|
6
6
|
};
|
|
7
7
|
export declare function buildSearchUrl(query: string): string;
|
|
8
|
-
export declare
|
|
8
|
+
export declare const DUCKDUCKGO_HEADERS: {
|
|
9
|
+
readonly 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
|
10
|
+
readonly Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
11
|
+
readonly 'Accept-Language': "en-US,en;q=0.9";
|
|
12
|
+
};
|
|
13
|
+
export declare function fetchDuckDuckGoHtml(query: string, { fetchImpl, retries, sleep }?: {
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
retries?: number;
|
|
16
|
+
sleep?: (ms: number) => Promise<void>;
|
|
17
|
+
}): Promise<string>;
|
|
9
18
|
export declare function parseDuckDuckGoResults(html: string): ParsedDuckDuckGoResults;
|
|
@@ -21,12 +21,30 @@ export function buildSearchUrl(query) {
|
|
|
21
21
|
const params = new URLSearchParams({ q: query });
|
|
22
22
|
return `https://html.duckduckgo.com/html/?${params.toString()}`;
|
|
23
23
|
}
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
export const DUCKDUCKGO_HEADERS = {
|
|
25
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
|
26
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
27
|
+
'Accept-Language': 'en-US,en;q=0.9'
|
|
28
|
+
};
|
|
29
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
export async function fetchDuckDuckGoHtml(query, { fetchImpl = fetch, retries = 1, sleep = defaultSleep } = {}) {
|
|
31
|
+
let lastError;
|
|
32
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchImpl(buildSearchUrl(query), { headers: { ...DUCKDUCKGO_HEADERS } });
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`DuckDuckGo request failed with ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return response.text();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
lastError = error;
|
|
42
|
+
if (attempt < retries) {
|
|
43
|
+
await sleep(500);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
|
-
|
|
47
|
+
throw lastError instanceof Error ? lastError : new Error('DuckDuckGo request failed');
|
|
30
48
|
}
|
|
31
49
|
export function parseDuckDuckGoResults(html) {
|
|
32
50
|
const $ = cheerio.load(html);
|