@demigodmode/pi-web-agent 1.2.0 → 1.4.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 +30 -0
- package/README.md +4 -4
- package/dist/backends/doctor.js +3 -0
- package/dist/fetch/headless-fetch.d.ts +2 -1
- package/dist/fetch/headless-fetch.js +11 -7
- package/dist/orchestration/answer-synthesizer.d.ts +3 -1
- package/dist/orchestration/answer-synthesizer.js +34 -4
- package/dist/orchestration/candidate-selector.d.ts +2 -1
- package/dist/orchestration/candidate-selector.js +28 -9
- package/dist/orchestration/direct-url.d.ts +1 -0
- package/dist/orchestration/direct-url.js +47 -0
- package/dist/orchestration/evidence-quality.d.ts +26 -0
- package/dist/orchestration/evidence-quality.js +62 -0
- package/dist/orchestration/index.d.ts +1 -0
- package/dist/orchestration/index.js +5 -1
- package/dist/orchestration/research-orchestrator.d.ts +7 -2
- package/dist/orchestration/research-orchestrator.js +140 -40
- package/dist/orchestration/research-worker.js +15 -13
- package/dist/orchestration/source-profile.d.ts +8 -0
- package/dist/orchestration/source-profile.js +60 -0
- package/dist/orchestration/stop-decider.d.ts +3 -1
- package/dist/orchestration/stop-decider.js +31 -3
- package/dist/tools/web-explore.d.ts +1 -0
- package/dist/tools/web-explore.js +2 -1
- package/dist/types.d.ts +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,36 @@ The format is intentionally simple and release-oriented.
|
|
|
18
18
|
### Breaking
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [1.4.0] - 2026-06-09
|
|
22
|
+
### Added
|
|
23
|
+
- Added evidence quality analysis for `web_explore`, including source diversity, unreadable source, bot-check, and possible conflict signals.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `web_explore` now uses source quality signals before deciding whether to answer, search again, or answer with a caveat.
|
|
27
|
+
- Partial research caveats are now more specific when evidence is community-only, low-diversity, blocked, or cautionary.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Nothing yet.
|
|
31
|
+
|
|
32
|
+
### Breaking
|
|
33
|
+
- None.
|
|
34
|
+
|
|
35
|
+
## [1.3.0] - 2026-06-04
|
|
36
|
+
### Added
|
|
37
|
+
- Added direct URL handling in `web_explore` so linked pages are read before search results.
|
|
38
|
+
- Added forum/thread source classification for Reddit-style discussions, forums, Stack Overflow, and GitHub issues/discussions.
|
|
39
|
+
- Added Playwright-managed Chromium fallback when no local Chromium-family browser is detected.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- Discussion-oriented queries now prefer forum/thread results over generic pages.
|
|
43
|
+
- `/web-agent doctor` now reports the local-browser headless backend and managed Chromium fallback.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- Preserved direct/thread fetch gaps in bounded research results so unreadable thread sources get explicit caveats.
|
|
47
|
+
|
|
48
|
+
### Breaking
|
|
49
|
+
- None.
|
|
50
|
+
|
|
21
51
|
## [1.2.0] - 2026-06-01
|
|
22
52
|
### Added
|
|
23
53
|
- Added backend provider and fallback editing to `/web-agent settings`.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Most agent web tools blur search, fetch, browser rendering, and research into one vague thing. `pi-web-agent` exposes one public research tool, `web_explore`, and keeps search/fetch/headless work inside that bounded workflow.
|
|
10
10
|
|
|
11
|
-
The point is keeping the model-facing boundary simple: ask `web_explore` to research a question, and it handles discovery, HTTP reads, targeted browser rendering, source ranking, and caveats internally.
|
|
11
|
+
The point is keeping the model-facing boundary simple: ask `web_explore` to research a question, and it handles direct links, discovery, HTTP reads, targeted browser rendering, source ranking, and caveats internally.
|
|
12
12
|
|
|
13
13
|
That sounds obvious, but a lot of agent tooling gets fuzzy right there. This package is meant to be stricter about what it actually did and more willing to say when a read was not good enough to trust.
|
|
14
14
|
|
|
@@ -20,9 +20,9 @@ Compatibility notice: current `pi-web-agent` requires Pi 0.74+ because Pi packag
|
|
|
20
20
|
pi install npm:@demigodmode/pi-web-agent
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
After installing, reload or restart Pi. Run `/web-agent` for the action menu, or `/web-agent doctor` to check whether the package loaded cleanly and
|
|
23
|
+
After installing, reload or restart Pi. Run `/web-agent` for the action menu, or `/web-agent doctor` to check whether the package loaded cleanly and which web backends are configured.
|
|
24
24
|
|
|
25
|
-
Headless rendering
|
|
25
|
+
Headless rendering first tries a detectable Chromium-family browser: Chrome, Chromium, Edge, or Brave. If none is found, it falls back to Playwright-managed Chromium and still launches headless. Firefox/Safari-only systems can still use search and plain HTTP reads; browser-rendered fallback uses Chromium.
|
|
26
26
|
|
|
27
27
|
Later on, update installed packages with:
|
|
28
28
|
|
|
@@ -109,7 +109,7 @@ Example:
|
|
|
109
109
|
}
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local
|
|
112
|
+
Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local-browser headless fallback with managed Chromium fallback configured.
|
|
113
113
|
|
|
114
114
|
Backend settings can be changed from:
|
|
115
115
|
|
package/dist/backends/doctor.js
CHANGED
|
@@ -83,5 +83,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
83
83
|
if (config.fetch.fallback) {
|
|
84
84
|
lines.push(`fetch fallback: ${config.fetch.fallback}`);
|
|
85
85
|
}
|
|
86
|
+
if (config.headless.provider === 'local-browser') {
|
|
87
|
+
lines.push('headless backend: local-browser (managed Chromium fallback configured)');
|
|
88
|
+
}
|
|
86
89
|
return lines;
|
|
87
90
|
}
|
|
@@ -6,7 +6,8 @@ export declare function headlessFetch(url: string, { configuredPath, resolveBrow
|
|
|
6
6
|
configuredPath?: string;
|
|
7
7
|
}) => Promise<BrowserResolutionResult>;
|
|
8
8
|
launchBrowser?: (options: {
|
|
9
|
-
executablePath
|
|
9
|
+
executablePath?: string;
|
|
10
|
+
headless: true;
|
|
10
11
|
}) => Promise<{
|
|
11
12
|
newContext: () => Promise<{
|
|
12
13
|
newPage: () => Promise<any>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chromium } from 'playwright
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
2
|
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
3
3
|
import { resolveBrowserExecutable } from './browser-resolution.js';
|
|
4
4
|
function cleanupRenderedText(text) {
|
|
@@ -7,9 +7,9 @@ function cleanupRenderedText(text) {
|
|
|
7
7
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
8
8
|
return cleaned;
|
|
9
9
|
}
|
|
10
|
-
export async function headlessFetch(url, { configuredPath, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath }) => chromium.launch({ executablePath, headless:
|
|
10
|
+
export async function headlessFetch(url, { configuredPath, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless }) => chromium.launch(executablePath ? { executablePath, headless } : { headless }), now = () => Date.now() } = {}) {
|
|
11
11
|
const resolved = await resolveBrowser({ configuredPath });
|
|
12
|
-
if (!resolved.ok) {
|
|
12
|
+
if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
|
|
13
13
|
return {
|
|
14
14
|
status: 'error',
|
|
15
15
|
url,
|
|
@@ -17,11 +17,15 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
17
17
|
error: resolved.error
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
const browserName = resolved.ok ? resolved.browser : 'chromium';
|
|
21
|
+
const launchOptions = resolved.ok
|
|
22
|
+
? { executablePath: resolved.executablePath, headless: true }
|
|
23
|
+
: { headless: true };
|
|
20
24
|
let browser;
|
|
21
25
|
let context;
|
|
22
26
|
let page;
|
|
23
27
|
try {
|
|
24
|
-
browser = await launchBrowser(
|
|
28
|
+
browser = await launchBrowser(launchOptions);
|
|
25
29
|
context = await browser.newContext();
|
|
26
30
|
page = await context.newPage();
|
|
27
31
|
const startedAt = now();
|
|
@@ -42,7 +46,7 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
42
46
|
metadata: {
|
|
43
47
|
method: 'headless',
|
|
44
48
|
cacheHit: false,
|
|
45
|
-
browser:
|
|
49
|
+
browser: browserName,
|
|
46
50
|
navigationMs: finishedAt - startedAt
|
|
47
51
|
},
|
|
48
52
|
error: {
|
|
@@ -58,7 +62,7 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
58
62
|
metadata: {
|
|
59
63
|
method: 'headless',
|
|
60
64
|
cacheHit: false,
|
|
61
|
-
browser:
|
|
65
|
+
browser: browserName,
|
|
62
66
|
navigationMs: finishedAt - startedAt,
|
|
63
67
|
truncated: cleanedContent.text.length >= 4000
|
|
64
68
|
}
|
|
@@ -71,7 +75,7 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
71
75
|
metadata: {
|
|
72
76
|
method: 'headless',
|
|
73
77
|
cacheHit: false,
|
|
74
|
-
browser:
|
|
78
|
+
browser: browserName
|
|
75
79
|
},
|
|
76
80
|
error: {
|
|
77
81
|
code: 'HEADLESS_NAVIGATION_FAILED',
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ResearchEvidence } from './research-types.js';
|
|
2
|
-
|
|
2
|
+
import type { EvidenceCaveatReason } from './evidence-quality.js';
|
|
3
|
+
export declare function synthesizeAnswer({ evidence, partial, caveatReasons }: {
|
|
3
4
|
evidence: ResearchEvidence[];
|
|
4
5
|
partial: boolean;
|
|
6
|
+
caveatReasons?: EvidenceCaveatReason[];
|
|
5
7
|
}): {
|
|
6
8
|
findings: string[];
|
|
7
9
|
caveat: string | undefined;
|
|
@@ -1,7 +1,39 @@
|
|
|
1
1
|
function normalizeSummary(summary) {
|
|
2
2
|
return summary.replace(/\s+/g, ' ').trim();
|
|
3
3
|
}
|
|
4
|
-
|
|
4
|
+
function sentenceForReason(reason) {
|
|
5
|
+
switch (reason) {
|
|
6
|
+
case 'community-only':
|
|
7
|
+
return 'the strongest readable sources were mostly community/practical context';
|
|
8
|
+
case 'low-diversity':
|
|
9
|
+
return 'the source set was narrow';
|
|
10
|
+
case 'unreadable-direct-source':
|
|
11
|
+
return 'one or more linked sources could not be read reliably';
|
|
12
|
+
case 'unreadable-thread-source':
|
|
13
|
+
return 'one or more thread sources could not be read reliably';
|
|
14
|
+
case 'possible-conflict':
|
|
15
|
+
return 'readable sources include cautionary or possibly conflicting guidance';
|
|
16
|
+
case 'bot-check':
|
|
17
|
+
return 'some candidate sources showed bot-check or security verification pages';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function joinReasons(reasons) {
|
|
21
|
+
if (reasons.length === 0)
|
|
22
|
+
return '';
|
|
23
|
+
if (reasons.length === 1)
|
|
24
|
+
return reasons[0];
|
|
25
|
+
return `${reasons.slice(0, -1).join(', ')}, and ${reasons.at(-1)}`;
|
|
26
|
+
}
|
|
27
|
+
function caveatText(partial, caveatReasons = []) {
|
|
28
|
+
if (!partial)
|
|
29
|
+
return undefined;
|
|
30
|
+
const specificReasons = caveatReasons.map(sentenceForReason);
|
|
31
|
+
if (specificReasons.length > 0) {
|
|
32
|
+
return `Evidence is partial: ${joinReasons(specificReasons)}.`;
|
|
33
|
+
}
|
|
34
|
+
return 'Evidence is partial, so this answer is based on the strongest source found within the bounded research budget.';
|
|
35
|
+
}
|
|
36
|
+
export function synthesizeAnswer({ evidence, partial, caveatReasons = [] }) {
|
|
5
37
|
const findings = evidence.slice(0, 5).map((item) => {
|
|
6
38
|
const summary = normalizeSummary(item.summary);
|
|
7
39
|
return item.sourceKind === 'community' || item.sourceKind === 'issue-thread'
|
|
@@ -10,8 +42,6 @@ export function synthesizeAnswer({ evidence, partial }) {
|
|
|
10
42
|
});
|
|
11
43
|
return {
|
|
12
44
|
findings,
|
|
13
|
-
caveat: partial
|
|
14
|
-
? 'Evidence is partial, so this answer is based on the strongest source found within the bounded research budget.'
|
|
15
|
-
: undefined
|
|
45
|
+
caveat: caveatText(partial, caveatReasons)
|
|
16
46
|
};
|
|
17
47
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SearchResult } from '../types.js';
|
|
2
|
-
export declare function selectCandidates({ results, seenUrls, maxCandidates }: {
|
|
2
|
+
export declare function selectCandidates({ query, results, seenUrls, maxCandidates }: {
|
|
3
|
+
query?: string;
|
|
3
4
|
results: SearchResult[];
|
|
4
5
|
seenUrls: Set<string>;
|
|
5
6
|
maxCandidates: number;
|
|
@@ -1,16 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
import { classifySourceProfile } from './source-profile.js';
|
|
2
|
+
function wantsDiscussionSources(query = '') {
|
|
3
|
+
return /reddit|forum|forums|discussion|thread|comments|community|user experience|people recommend/i.test(query);
|
|
4
|
+
}
|
|
5
|
+
function candidateScore(result, query) {
|
|
2
6
|
const url = result.url.toLowerCase();
|
|
3
|
-
|
|
7
|
+
const profile = classifySourceProfile(result.url);
|
|
8
|
+
const wantsThreads = wantsDiscussionSources(query);
|
|
9
|
+
if (profile.kind === 'official-docs')
|
|
4
10
|
return 0;
|
|
5
|
-
if (
|
|
11
|
+
if (profile.kind === 'official-api')
|
|
6
12
|
return 1;
|
|
7
|
-
if (
|
|
13
|
+
if (wantsThreads) {
|
|
14
|
+
if (profile.kind === 'forum-thread')
|
|
15
|
+
return 2;
|
|
16
|
+
if (profile.kind === 'issue-thread')
|
|
17
|
+
return 3;
|
|
18
|
+
if (url.includes('github.com/'))
|
|
19
|
+
return 4;
|
|
20
|
+
if (profile.kind === 'package-page')
|
|
21
|
+
return 6;
|
|
22
|
+
return 5;
|
|
23
|
+
}
|
|
24
|
+
if (profile.kind === 'issue-thread')
|
|
8
25
|
return 2;
|
|
9
|
-
if (url.includes('
|
|
10
|
-
return
|
|
11
|
-
|
|
26
|
+
if (url.includes('github.com/'))
|
|
27
|
+
return 3;
|
|
28
|
+
if (profile.kind === 'package-page')
|
|
29
|
+
return 5;
|
|
30
|
+
return 4;
|
|
12
31
|
}
|
|
13
|
-
export function selectCandidates({ results, seenUrls, maxCandidates }) {
|
|
32
|
+
export function selectCandidates({ query, results, seenUrls, maxCandidates }) {
|
|
14
33
|
const deduped = new Map();
|
|
15
34
|
for (const result of results) {
|
|
16
35
|
if (seenUrls.has(result.url))
|
|
@@ -19,6 +38,6 @@ export function selectCandidates({ results, seenUrls, maxCandidates }) {
|
|
|
19
38
|
deduped.set(result.url, result);
|
|
20
39
|
}
|
|
21
40
|
return [...deduped.values()]
|
|
22
|
-
.sort((left, right) => candidateScore(left) - candidateScore(right))
|
|
41
|
+
.sort((left, right) => candidateScore(left, query) - candidateScore(right, query))
|
|
23
42
|
.slice(0, maxCandidates);
|
|
24
43
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function extractDirectUrls(query: string): string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
function stripTrailingPunctuation(raw) {
|
|
12
|
+
let next = raw.trim();
|
|
13
|
+
while (/[),.;!?\]]$/.test(next)) {
|
|
14
|
+
const last = next.at(-1);
|
|
15
|
+
if (last === ')' && next.includes('(') && next.lastIndexOf('(') > next.lastIndexOf(')'))
|
|
16
|
+
break;
|
|
17
|
+
next = next.slice(0, -1);
|
|
18
|
+
}
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
function normalizeDirectUrl(raw) {
|
|
22
|
+
try {
|
|
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
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function extractDirectUrls(query) {
|
|
39
|
+
const matches = query.match(/https?:\/\/\S+/gi) ?? [];
|
|
40
|
+
const urls = new Set();
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
const normalized = normalizeDirectUrl(match);
|
|
43
|
+
if (normalized)
|
|
44
|
+
urls.add(normalized);
|
|
45
|
+
}
|
|
46
|
+
return [...urls];
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ResearchEvidence, ResearchGap, ResearchLowValueOutcome } from './research-types.js';
|
|
2
|
+
export type EvidenceCaveatReason = 'community-only' | 'low-diversity' | 'unreadable-direct-source' | 'unreadable-thread-source' | 'possible-conflict' | 'bot-check';
|
|
3
|
+
export type EvidenceQualityReport = {
|
|
4
|
+
counts: {
|
|
5
|
+
total: number;
|
|
6
|
+
official: number;
|
|
7
|
+
community: number;
|
|
8
|
+
thread: number;
|
|
9
|
+
packagePage: number;
|
|
10
|
+
distinctHosts: number;
|
|
11
|
+
};
|
|
12
|
+
flags: {
|
|
13
|
+
hasOfficialEvidence: boolean;
|
|
14
|
+
hasOnlyCommunityEvidence: boolean;
|
|
15
|
+
hasLowDiversity: boolean;
|
|
16
|
+
hasUnreadableDirectSource: boolean;
|
|
17
|
+
hasUnreadableThreadSource: boolean;
|
|
18
|
+
hasPossibleConflict: boolean;
|
|
19
|
+
};
|
|
20
|
+
caveatReasons: EvidenceCaveatReason[];
|
|
21
|
+
};
|
|
22
|
+
export declare function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }: {
|
|
23
|
+
evidence: ResearchEvidence[];
|
|
24
|
+
gaps: ResearchGap[];
|
|
25
|
+
lowValueOutcomes: ResearchLowValueOutcome[];
|
|
26
|
+
}): EvidenceQualityReport;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function hostname(url) {
|
|
2
|
+
try {
|
|
3
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return url.toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function hasConflictMarkers(evidence) {
|
|
10
|
+
const combined = evidence
|
|
11
|
+
.flatMap((item) => [item.summary, ...item.supports])
|
|
12
|
+
.join('\n')
|
|
13
|
+
.toLowerCase();
|
|
14
|
+
const caution = /\bdeprecated\b|not recommended|use at your own risk|should not/.test(combined);
|
|
15
|
+
const positiveText = combined.replace(/not recommended/g, '');
|
|
16
|
+
const positive = /\brecommended\b/.test(positiveText);
|
|
17
|
+
return positive && caution;
|
|
18
|
+
}
|
|
19
|
+
function addReason(reasons, reason, enabled) {
|
|
20
|
+
if (enabled && !reasons.includes(reason))
|
|
21
|
+
reasons.push(reason);
|
|
22
|
+
}
|
|
23
|
+
export function analyzeEvidenceQuality({ evidence, gaps, lowValueOutcomes }) {
|
|
24
|
+
const official = evidence.filter((item) => item.sourceKind === 'official-docs' || item.sourceKind === 'official-api').length;
|
|
25
|
+
const community = evidence.filter((item) => item.sourceKind === 'community').length;
|
|
26
|
+
const thread = evidence.filter((item) => item.sourceKind === 'issue-thread' || item.sourceKind === 'official-discussion').length;
|
|
27
|
+
const packagePage = evidence.filter((item) => item.sourceKind === 'package-page').length;
|
|
28
|
+
const distinctHosts = new Set(evidence.map((item) => hostname(item.url))).size;
|
|
29
|
+
const hasOfficialEvidence = official > 0;
|
|
30
|
+
const hasOnlyCommunityEvidence = evidence.length > 0 && official === 0;
|
|
31
|
+
const hasLowDiversity = evidence.length > 1 && distinctHosts <= 1;
|
|
32
|
+
const hasUnreadableDirectSource = gaps.some((gap) => /Direct URL could not be read reliably/i.test(gap.message));
|
|
33
|
+
const hasUnreadableThreadSource = gaps.some((gap) => /Thread source could not be read reliably/i.test(gap.message));
|
|
34
|
+
const hasPossibleConflict = hasConflictMarkers(evidence);
|
|
35
|
+
const hasBotCheck = lowValueOutcomes.some((outcome) => outcome.kind === 'bot-check');
|
|
36
|
+
const caveatReasons = [];
|
|
37
|
+
addReason(caveatReasons, 'community-only', hasOnlyCommunityEvidence);
|
|
38
|
+
addReason(caveatReasons, 'low-diversity', hasLowDiversity);
|
|
39
|
+
addReason(caveatReasons, 'unreadable-direct-source', hasUnreadableDirectSource);
|
|
40
|
+
addReason(caveatReasons, 'unreadable-thread-source', hasUnreadableThreadSource);
|
|
41
|
+
addReason(caveatReasons, 'possible-conflict', hasPossibleConflict);
|
|
42
|
+
addReason(caveatReasons, 'bot-check', hasBotCheck);
|
|
43
|
+
return {
|
|
44
|
+
counts: {
|
|
45
|
+
total: evidence.length,
|
|
46
|
+
official,
|
|
47
|
+
community,
|
|
48
|
+
thread,
|
|
49
|
+
packagePage,
|
|
50
|
+
distinctHosts
|
|
51
|
+
},
|
|
52
|
+
flags: {
|
|
53
|
+
hasOfficialEvidence,
|
|
54
|
+
hasOnlyCommunityEvidence,
|
|
55
|
+
hasLowDiversity,
|
|
56
|
+
hasUnreadableDirectSource,
|
|
57
|
+
hasUnreadableThreadSource,
|
|
58
|
+
hasPossibleConflict
|
|
59
|
+
},
|
|
60
|
+
caveatReasons
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -7,5 +7,9 @@ export function createResearchWorkflow({ backendConfig, search, fetchPage, headl
|
|
|
7
7
|
const resolvedFetchPage = fetchPage ?? backends.fetchPage;
|
|
8
8
|
const resolvedHeadlessFetch = headlessFetch ?? backends.headlessFetch;
|
|
9
9
|
const worker = createResearchWorker({ search: resolvedSearch, fetchPage: resolvedFetchPage });
|
|
10
|
-
return createResearchOrchestrator({
|
|
10
|
+
return createResearchOrchestrator({
|
|
11
|
+
worker,
|
|
12
|
+
fetchDirect: resolvedFetchPage,
|
|
13
|
+
headlessFetch: resolvedHeadlessFetch
|
|
14
|
+
});
|
|
11
15
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { WebFetchHeadlessResponse } from '../types.js';
|
|
1
|
+
import type { 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
|
+
export declare function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }: {
|
|
4
5
|
worker: {
|
|
5
6
|
run: (input: {
|
|
6
7
|
query: string;
|
|
@@ -8,6 +9,9 @@ export declare function createResearchOrchestrator({ worker, headlessFetch }: {
|
|
|
8
9
|
maxFetches: number;
|
|
9
10
|
}) => Promise<ResearchWorkerResult>;
|
|
10
11
|
};
|
|
12
|
+
fetchDirect?: (input: {
|
|
13
|
+
url: string;
|
|
14
|
+
}) => Promise<WebFetchResponse>;
|
|
11
15
|
headlessFetch: (input: {
|
|
12
16
|
url: string;
|
|
13
17
|
}) => Promise<WebFetchHeadlessResponse>;
|
|
@@ -23,6 +27,7 @@ export declare function createResearchOrchestrator({ worker, headlessFetch }: {
|
|
|
23
27
|
fetchedPages: number;
|
|
24
28
|
headlessAttempts: number;
|
|
25
29
|
exhaustedBudget: boolean;
|
|
30
|
+
caveatReasons: EvidenceCaveatReason[];
|
|
26
31
|
};
|
|
27
32
|
}>;
|
|
28
33
|
};
|
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
import { rankEvidence } from './evidence-ranker.js';
|
|
2
2
|
import { planSearchQueries } from './query-planner.js';
|
|
3
|
+
import { classifySourceProfile } from './source-profile.js';
|
|
4
|
+
import { extractDirectUrls } from './direct-url.js';
|
|
3
5
|
import { decideNextResearchStep } from './stop-decider.js';
|
|
6
|
+
import { analyzeEvidenceQuality } from './evidence-quality.js';
|
|
4
7
|
const DEFAULT_MAX_PASSES = 3;
|
|
5
8
|
const DEFAULT_MAX_FETCHES_PER_PASS = 4;
|
|
6
9
|
const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
|
|
7
10
|
function classifyEvidenceUrl(url) {
|
|
8
|
-
|
|
9
|
-
return 'official-api';
|
|
10
|
-
if (url.includes('playwright.dev/docs') || url.includes('vitest.dev/guide/'))
|
|
11
|
-
return 'official-docs';
|
|
12
|
-
if (url.includes('github.com/vitest-dev/vitest') && url.includes('/docs/'))
|
|
13
|
-
return 'official-docs';
|
|
14
|
-
if (url.includes('learn.microsoft.com'))
|
|
15
|
-
return 'official-docs';
|
|
16
|
-
if (url.includes('github.com/') && url.includes('/issues/'))
|
|
17
|
-
return 'issue-thread';
|
|
18
|
-
if (url.includes('npmjs.com/package/'))
|
|
19
|
-
return 'package-page';
|
|
20
|
-
return 'community';
|
|
11
|
+
return classifySourceProfile(url).sourceKind;
|
|
21
12
|
}
|
|
22
13
|
function summarizeText(text, maxLength = 180) {
|
|
23
14
|
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
@@ -25,6 +16,20 @@ function summarizeText(text, maxLength = 180) {
|
|
|
25
16
|
function isBotCheckContent({ title = '', text }) {
|
|
26
17
|
return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
|
|
27
18
|
}
|
|
19
|
+
function evidenceFromFetch(result) {
|
|
20
|
+
if (result.status !== 'ok' || !result.content?.text.trim())
|
|
21
|
+
return null;
|
|
22
|
+
if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
title: result.content.title ?? result.url,
|
|
26
|
+
url: result.url,
|
|
27
|
+
sourceKind: classifyEvidenceUrl(result.url),
|
|
28
|
+
method: result.metadata.method,
|
|
29
|
+
summary: summarizeText(result.content.text),
|
|
30
|
+
supports: [summarizeText(result.content.text, 120)]
|
|
31
|
+
};
|
|
32
|
+
}
|
|
28
33
|
function evidenceFromHeadless(result) {
|
|
29
34
|
if (result.status !== 'ok' || !result.content?.text.trim())
|
|
30
35
|
return null;
|
|
@@ -39,24 +44,38 @@ function evidenceFromHeadless(result) {
|
|
|
39
44
|
supports: [summarizeText(result.content.text, 120)]
|
|
40
45
|
};
|
|
41
46
|
}
|
|
42
|
-
function
|
|
47
|
+
function combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget }) {
|
|
43
48
|
return {
|
|
44
|
-
searchQueries: previousQueries,
|
|
45
|
-
evidence: [],
|
|
49
|
+
searchQueries: lastPass?.searchQueries ?? previousQueries,
|
|
50
|
+
evidence: lastPass?.evidence ?? [],
|
|
46
51
|
gaps: allGaps,
|
|
47
52
|
lowValueOutcomes: allLowValueOutcomes,
|
|
53
|
+
suggestedHeadlessUrl: lastPass?.suggestedHeadlessUrl,
|
|
48
54
|
exhaustedBudget
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
|
-
function
|
|
57
|
+
function directUnreadableMessage(url) {
|
|
58
|
+
return classifySourceProfile(url).kind === 'forum-thread'
|
|
59
|
+
? `Thread source could not be read reliably: ${url}`
|
|
60
|
+
: `Direct URL could not be read reliably: ${url}`;
|
|
61
|
+
}
|
|
62
|
+
function shouldRetryDirectWithHeadless(result, evidence) {
|
|
63
|
+
if (result.status === 'needs_headless')
|
|
64
|
+
return true;
|
|
65
|
+
if (result.status !== 'ok' || evidence)
|
|
66
|
+
return false;
|
|
67
|
+
return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
|
|
68
|
+
}
|
|
69
|
+
function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [] }) {
|
|
52
70
|
return {
|
|
53
71
|
searchPasses: previousQueries.length,
|
|
54
72
|
fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
|
|
55
73
|
headlessAttempts,
|
|
56
|
-
exhaustedBudget
|
|
74
|
+
exhaustedBudget,
|
|
75
|
+
caveatReasons
|
|
57
76
|
};
|
|
58
77
|
}
|
|
59
|
-
function decisionForAnswer(action, query, ranked) {
|
|
78
|
+
function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
|
|
60
79
|
if (action === 'answer') {
|
|
61
80
|
return {
|
|
62
81
|
action: 'answer',
|
|
@@ -66,11 +85,11 @@ function decisionForAnswer(action, query, ranked) {
|
|
|
66
85
|
}
|
|
67
86
|
return {
|
|
68
87
|
action: 'research-again',
|
|
69
|
-
rationale: 'Research budget exhausted; answer with caveat.',
|
|
88
|
+
rationale: exhaustedBudget ? 'Research budget exhausted; answer with caveat.' : 'Evidence has quality caveats; answer with caveat.',
|
|
70
89
|
followupQuery: query
|
|
71
90
|
};
|
|
72
91
|
}
|
|
73
|
-
export function createResearchOrchestrator({ worker, headlessFetch }) {
|
|
92
|
+
export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }) {
|
|
74
93
|
return {
|
|
75
94
|
async run({ query }) {
|
|
76
95
|
const allEvidence = [];
|
|
@@ -80,6 +99,41 @@ export function createResearchOrchestrator({ worker, headlessFetch }) {
|
|
|
80
99
|
const suggestedHeadlessUrls = [];
|
|
81
100
|
let headlessAttempts = 0;
|
|
82
101
|
let lastPass;
|
|
102
|
+
if (fetchDirect) {
|
|
103
|
+
for (const url of extractDirectUrls(query).slice(0, 3)) {
|
|
104
|
+
const directResult = await fetchDirect({ url });
|
|
105
|
+
const directEvidence = evidenceFromFetch(directResult);
|
|
106
|
+
if (directEvidence) {
|
|
107
|
+
allEvidence.push(directEvidence);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (shouldRetryDirectWithHeadless(directResult, directEvidence)) {
|
|
111
|
+
if (headlessAttempts < DEFAULT_MAX_HEADLESS_ATTEMPTS) {
|
|
112
|
+
headlessAttempts++;
|
|
113
|
+
const headlessResult = await headlessFetch({ url: directResult.url });
|
|
114
|
+
const headlessEvidence = evidenceFromHeadless(headlessResult);
|
|
115
|
+
if (headlessEvidence) {
|
|
116
|
+
allEvidence.push(headlessEvidence);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
allGaps.push({ kind: 'fetch-failed', message: directUnreadableMessage(directResult.url) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
allGaps.push({ kind: 'fetch-failed', message: directUnreadableMessage(directResult.url) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (directResult.status !== 'ok') {
|
|
127
|
+
allGaps.push({
|
|
128
|
+
kind: 'fetch-failed',
|
|
129
|
+
message: directResult.error?.message ?? `Direct URL fetch failed for ${directResult.url}`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
allGaps.push({ kind: 'fetch-failed', message: directUnreadableMessage(directResult.url) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
83
137
|
for (let passIndex = 0; passIndex < DEFAULT_MAX_PASSES; passIndex++) {
|
|
84
138
|
const queries = planSearchQueries({
|
|
85
139
|
originalQuery: query,
|
|
@@ -101,13 +155,19 @@ export function createResearchOrchestrator({ worker, headlessFetch }) {
|
|
|
101
155
|
if (pass.suggestedHeadlessUrl)
|
|
102
156
|
suggestedHeadlessUrls.push(pass.suggestedHeadlessUrl);
|
|
103
157
|
const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
|
|
158
|
+
const quality = analyzeEvidenceQuality({
|
|
159
|
+
evidence: ranked,
|
|
160
|
+
gaps: allGaps,
|
|
161
|
+
lowValueOutcomes: allLowValueOutcomes
|
|
162
|
+
});
|
|
104
163
|
const decision = decideNextResearchStep({
|
|
105
164
|
evidence: ranked,
|
|
106
165
|
suggestedHeadlessUrls,
|
|
107
166
|
passIndex,
|
|
108
167
|
maxPasses: DEFAULT_MAX_PASSES,
|
|
109
168
|
headlessAttempts,
|
|
110
|
-
maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
|
|
169
|
+
maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS,
|
|
170
|
+
quality
|
|
111
171
|
});
|
|
112
172
|
if (decision.action === 'headless') {
|
|
113
173
|
headlessAttempts++;
|
|
@@ -116,25 +176,44 @@ export function createResearchOrchestrator({ worker, headlessFetch }) {
|
|
|
116
176
|
if (headlessEvidence) {
|
|
117
177
|
allEvidence.push(headlessEvidence);
|
|
118
178
|
const updatedRanked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
|
|
179
|
+
const updatedQuality = analyzeEvidenceQuality({
|
|
180
|
+
evidence: updatedRanked,
|
|
181
|
+
gaps: allGaps,
|
|
182
|
+
lowValueOutcomes: allLowValueOutcomes
|
|
183
|
+
});
|
|
119
184
|
const updatedDecision = decideNextResearchStep({
|
|
120
185
|
evidence: updatedRanked,
|
|
121
186
|
suggestedHeadlessUrls: [],
|
|
122
187
|
passIndex,
|
|
123
188
|
maxPasses: DEFAULT_MAX_PASSES,
|
|
124
189
|
headlessAttempts,
|
|
125
|
-
maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
|
|
190
|
+
maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS,
|
|
191
|
+
quality: updatedQuality
|
|
126
192
|
});
|
|
193
|
+
const exhaustedBudget = updatedDecision.action !== 'answer' && passIndex + 1 >= DEFAULT_MAX_PASSES;
|
|
127
194
|
return {
|
|
128
|
-
decision: decisionForAnswer(
|
|
195
|
+
decision: decisionForAnswer({
|
|
196
|
+
action: updatedDecision.action === 'answer' ? 'answer' : 'answer-with-caveat',
|
|
197
|
+
query,
|
|
198
|
+
ranked: updatedRanked,
|
|
199
|
+
exhaustedBudget
|
|
200
|
+
}),
|
|
129
201
|
evidence: updatedRanked,
|
|
130
|
-
workerPass:
|
|
202
|
+
workerPass: combinedWorkerPass({
|
|
203
|
+
lastPass,
|
|
204
|
+
previousQueries,
|
|
205
|
+
allGaps,
|
|
206
|
+
allLowValueOutcomes,
|
|
207
|
+
exhaustedBudget
|
|
208
|
+
}),
|
|
131
209
|
metadata: buildMetadata({
|
|
132
210
|
previousQueries,
|
|
133
211
|
allEvidence,
|
|
134
212
|
allGaps,
|
|
135
213
|
allLowValueOutcomes,
|
|
136
214
|
headlessAttempts,
|
|
137
|
-
exhaustedBudget
|
|
215
|
+
exhaustedBudget,
|
|
216
|
+
caveatReasons: updatedQuality.caveatReasons
|
|
138
217
|
})
|
|
139
218
|
};
|
|
140
219
|
}
|
|
@@ -146,52 +225,73 @@ export function createResearchOrchestrator({ worker, headlessFetch }) {
|
|
|
146
225
|
approvedEvidence: ranked
|
|
147
226
|
},
|
|
148
227
|
evidence: ranked,
|
|
149
|
-
workerPass:
|
|
228
|
+
workerPass: combinedWorkerPass({
|
|
229
|
+
lastPass,
|
|
230
|
+
previousQueries,
|
|
231
|
+
allGaps,
|
|
232
|
+
allLowValueOutcomes,
|
|
233
|
+
exhaustedBudget: false
|
|
234
|
+
}),
|
|
150
235
|
metadata: buildMetadata({
|
|
151
236
|
previousQueries,
|
|
152
237
|
allEvidence,
|
|
153
238
|
allGaps,
|
|
154
239
|
allLowValueOutcomes,
|
|
155
240
|
headlessAttempts,
|
|
156
|
-
exhaustedBudget: false
|
|
241
|
+
exhaustedBudget: false,
|
|
242
|
+
caveatReasons: quality.caveatReasons
|
|
157
243
|
})
|
|
158
244
|
};
|
|
159
245
|
}
|
|
160
246
|
if (decision.action === 'answer' || decision.action === 'answer-with-caveat') {
|
|
247
|
+
const exhaustedBudget = decision.action === 'answer-with-caveat' && passIndex + 1 >= DEFAULT_MAX_PASSES;
|
|
161
248
|
return {
|
|
162
|
-
decision: decisionForAnswer(decision.action, query, ranked),
|
|
249
|
+
decision: decisionForAnswer({ action: decision.action, query, ranked, exhaustedBudget }),
|
|
163
250
|
evidence: ranked,
|
|
164
|
-
workerPass:
|
|
251
|
+
workerPass: combinedWorkerPass({
|
|
252
|
+
lastPass,
|
|
253
|
+
previousQueries,
|
|
254
|
+
allGaps,
|
|
255
|
+
allLowValueOutcomes,
|
|
256
|
+
exhaustedBudget
|
|
257
|
+
}),
|
|
165
258
|
metadata: buildMetadata({
|
|
166
259
|
previousQueries,
|
|
167
260
|
allEvidence,
|
|
168
261
|
allGaps,
|
|
169
262
|
allLowValueOutcomes,
|
|
170
263
|
headlessAttempts,
|
|
171
|
-
exhaustedBudget
|
|
264
|
+
exhaustedBudget,
|
|
265
|
+
caveatReasons: quality.caveatReasons
|
|
172
266
|
})
|
|
173
267
|
};
|
|
174
268
|
}
|
|
175
269
|
}
|
|
176
270
|
}
|
|
177
271
|
const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
|
|
272
|
+
const quality = analyzeEvidenceQuality({
|
|
273
|
+
evidence: ranked,
|
|
274
|
+
gaps: allGaps,
|
|
275
|
+
lowValueOutcomes: allLowValueOutcomes
|
|
276
|
+
});
|
|
178
277
|
return {
|
|
179
|
-
decision: decisionForAnswer('answer-with-caveat', query, ranked),
|
|
278
|
+
decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked, exhaustedBudget: true }),
|
|
180
279
|
evidence: ranked,
|
|
181
|
-
workerPass:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
280
|
+
workerPass: combinedWorkerPass({
|
|
281
|
+
lastPass,
|
|
282
|
+
previousQueries,
|
|
283
|
+
allGaps,
|
|
284
|
+
allLowValueOutcomes,
|
|
285
|
+
exhaustedBudget: true
|
|
286
|
+
}),
|
|
188
287
|
metadata: buildMetadata({
|
|
189
288
|
previousQueries,
|
|
190
289
|
allEvidence,
|
|
191
290
|
allGaps,
|
|
192
291
|
allLowValueOutcomes,
|
|
193
292
|
headlessAttempts,
|
|
194
|
-
exhaustedBudget: true
|
|
293
|
+
exhaustedBudget: true,
|
|
294
|
+
caveatReasons: quality.caveatReasons
|
|
195
295
|
})
|
|
196
296
|
};
|
|
197
297
|
}
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import { selectCandidates } from './candidate-selector.js';
|
|
2
|
+
import { classifySourceProfile } from './source-profile.js';
|
|
2
3
|
function classifySource(url) {
|
|
3
|
-
|
|
4
|
-
return 'official-api';
|
|
5
|
-
if (url.includes('playwright.dev/docs') || url.includes('vitest.dev/guide/'))
|
|
6
|
-
return 'official-docs';
|
|
7
|
-
if (url.includes('github.com/vitest-dev/vitest') && url.includes('/docs/'))
|
|
8
|
-
return 'official-docs';
|
|
9
|
-
if (url.includes('learn.microsoft.com'))
|
|
10
|
-
return 'official-docs';
|
|
11
|
-
if (url.includes('github.com/') && url.includes('/issues/'))
|
|
12
|
-
return 'issue-thread';
|
|
13
|
-
if (url.includes('npmjs.com/package/'))
|
|
14
|
-
return 'package-page';
|
|
15
|
-
return 'community';
|
|
4
|
+
return classifySourceProfile(url).sourceKind;
|
|
16
5
|
}
|
|
17
6
|
function summarizeText(text, maxLength = 180) {
|
|
18
7
|
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
19
8
|
}
|
|
9
|
+
function isBotCheckContent({ title = '', text }) {
|
|
10
|
+
return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
|
|
11
|
+
}
|
|
20
12
|
function evidenceFromFetch(fetched, fallbackTitle) {
|
|
21
13
|
const content = fetched.content;
|
|
22
14
|
if (fetched.status !== 'ok' || !content)
|
|
23
15
|
return null;
|
|
16
|
+
if (isBotCheckContent({ title: content.title, text: content.text }))
|
|
17
|
+
return null;
|
|
24
18
|
const sourceKind = classifySource(fetched.url);
|
|
25
19
|
if (sourceKind === 'package-page') {
|
|
26
20
|
return null;
|
|
@@ -37,6 +31,13 @@ function evidenceFromFetch(fetched, fallbackTitle) {
|
|
|
37
31
|
function lowValueOutcomeFromFetch(fetched) {
|
|
38
32
|
if (fetched.status !== 'ok' || !fetched.content)
|
|
39
33
|
return null;
|
|
34
|
+
if (isBotCheckContent({ title: fetched.content.title, text: fetched.content.text })) {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'bot-check',
|
|
37
|
+
url: fetched.url,
|
|
38
|
+
message: 'Fetched page showed a bot-check or security verification page.'
|
|
39
|
+
};
|
|
40
|
+
}
|
|
40
41
|
if (classifySource(fetched.url) !== 'package-page')
|
|
41
42
|
return null;
|
|
42
43
|
return {
|
|
@@ -95,6 +96,7 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
const candidates = selectCandidates({
|
|
99
|
+
query,
|
|
98
100
|
results: searchResult.results,
|
|
99
101
|
seenUrls: new Set(evidence.map((item) => item.url)),
|
|
100
102
|
maxCandidates: maxFetches
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResearchSourceKind } from './research-types.js';
|
|
2
|
+
export type SourceProfileKind = 'official-docs' | 'official-api' | 'issue-thread' | 'forum-thread' | 'package-page' | 'community';
|
|
3
|
+
export type SourceProfile = {
|
|
4
|
+
kind: SourceProfileKind;
|
|
5
|
+
sourceKind: ResearchSourceKind;
|
|
6
|
+
shouldPreferHeadlessWhenWeak: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function classifySourceProfile(rawUrl: string): SourceProfile;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const COMMUNITY_FORUM_HOST_RE = /(^|\.)(community|forum|forums|discuss|discourse)\./;
|
|
2
|
+
function profile(kind, sourceKind, shouldPreferHeadlessWhenWeak) {
|
|
3
|
+
return { kind, sourceKind, shouldPreferHeadlessWhenWeak };
|
|
4
|
+
}
|
|
5
|
+
function parseUrl(rawUrl) {
|
|
6
|
+
try {
|
|
7
|
+
return new URL(rawUrl);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function isOfficialApi(host, path) {
|
|
14
|
+
return ((host === 'playwright.dev' && path.startsWith('/docs/api/')) ||
|
|
15
|
+
(host === 'vitest.dev' && path.startsWith('/config/')));
|
|
16
|
+
}
|
|
17
|
+
function isOfficialDocs(host, path) {
|
|
18
|
+
return ((host === 'playwright.dev' && path.startsWith('/docs/')) ||
|
|
19
|
+
(host === 'vitest.dev' && path.startsWith('/guide/')) ||
|
|
20
|
+
(host === 'github.com' && path.startsWith('/vitest-dev/vitest/') && path.includes('/docs/')) ||
|
|
21
|
+
host === 'learn.microsoft.com');
|
|
22
|
+
}
|
|
23
|
+
function isIssueThread(host, path) {
|
|
24
|
+
return host === 'github.com' && (path.includes('/issues/') || path.includes('/discussions/'));
|
|
25
|
+
}
|
|
26
|
+
function hasForumThreadPath(path) {
|
|
27
|
+
return (path.includes('/forum/') ||
|
|
28
|
+
path.includes('/forums/') ||
|
|
29
|
+
path.includes('/t/') ||
|
|
30
|
+
path.includes('/topic/') ||
|
|
31
|
+
path.includes('/threads/'));
|
|
32
|
+
}
|
|
33
|
+
function isForumThread(host, path) {
|
|
34
|
+
return ((host === 'reddit.com' && path.includes('/comments/')) ||
|
|
35
|
+
(host === 'stackoverflow.com' && path.startsWith('/questions/')) ||
|
|
36
|
+
(COMMUNITY_FORUM_HOST_RE.test(`${host}.`) && hasForumThreadPath(path)));
|
|
37
|
+
}
|
|
38
|
+
export function classifySourceProfile(rawUrl) {
|
|
39
|
+
const parsed = parseUrl(rawUrl);
|
|
40
|
+
if (!parsed)
|
|
41
|
+
return profile('community', 'community', false);
|
|
42
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
|
|
43
|
+
const path = parsed.pathname.toLowerCase();
|
|
44
|
+
if (isOfficialApi(host, path)) {
|
|
45
|
+
return profile('official-api', 'official-api', false);
|
|
46
|
+
}
|
|
47
|
+
if (isOfficialDocs(host, path)) {
|
|
48
|
+
return profile('official-docs', 'official-docs', false);
|
|
49
|
+
}
|
|
50
|
+
if (isIssueThread(host, path)) {
|
|
51
|
+
return profile('issue-thread', 'issue-thread', true);
|
|
52
|
+
}
|
|
53
|
+
if (isForumThread(host, path)) {
|
|
54
|
+
return profile('forum-thread', 'community', true);
|
|
55
|
+
}
|
|
56
|
+
if (host === 'npmjs.com' && path.startsWith('/package/')) {
|
|
57
|
+
return profile('package-page', 'package-page', false);
|
|
58
|
+
}
|
|
59
|
+
return profile('community', 'community', false);
|
|
60
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResearchEvidence } from './research-types.js';
|
|
2
|
+
import type { EvidenceQualityReport } from './evidence-quality.js';
|
|
2
3
|
export type ResearchStepDecision = {
|
|
3
4
|
action: 'answer';
|
|
4
5
|
} | {
|
|
@@ -9,11 +10,12 @@ export type ResearchStepDecision = {
|
|
|
9
10
|
action: 'headless';
|
|
10
11
|
url: string;
|
|
11
12
|
};
|
|
12
|
-
export declare function decideNextResearchStep({ evidence, suggestedHeadlessUrls, passIndex, maxPasses, headlessAttempts, maxHeadlessAttempts }: {
|
|
13
|
+
export declare function decideNextResearchStep({ evidence, suggestedHeadlessUrls, passIndex, maxPasses, headlessAttempts, maxHeadlessAttempts, quality }: {
|
|
13
14
|
evidence: ResearchEvidence[];
|
|
14
15
|
suggestedHeadlessUrls: string[];
|
|
15
16
|
passIndex: number;
|
|
16
17
|
maxPasses: number;
|
|
17
18
|
headlessAttempts: number;
|
|
18
19
|
maxHeadlessAttempts: number;
|
|
20
|
+
quality?: EvidenceQualityReport;
|
|
19
21
|
}): ResearchStepDecision;
|
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { hasOfficialEvidence, strongEvidenceCount } from './evidence-ranker.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
function activeCaveatReasons(evidence, quality) {
|
|
3
|
+
const reasons = quality?.caveatReasons ?? [];
|
|
4
|
+
if (!hasOfficialDocsAndApi(evidence))
|
|
5
|
+
return reasons;
|
|
6
|
+
return reasons.filter((reason) => reason !== 'low-diversity');
|
|
7
|
+
}
|
|
8
|
+
function hasQualityConcern(evidence, quality) {
|
|
9
|
+
return activeCaveatReasons(evidence, quality).length > 0;
|
|
10
|
+
}
|
|
11
|
+
function hasOfficialDocsAndApi(evidence) {
|
|
12
|
+
return evidence.some((item) => item.sourceKind === 'official-docs') &&
|
|
13
|
+
evidence.some((item) => item.sourceKind === 'official-api');
|
|
14
|
+
}
|
|
15
|
+
function shouldSearchForBetterQuality({ evidence, quality, passIndex, maxPasses }) {
|
|
16
|
+
if (!quality)
|
|
17
|
+
return false;
|
|
18
|
+
if (passIndex + 1 >= maxPasses)
|
|
19
|
+
return false;
|
|
20
|
+
if (quality.flags.hasOnlyCommunityEvidence)
|
|
21
|
+
return true;
|
|
22
|
+
if (quality.flags.hasLowDiversity && !hasOfficialDocsAndApi(evidence))
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
export function decideNextResearchStep({ evidence, suggestedHeadlessUrls, passIndex, maxPasses, headlessAttempts, maxHeadlessAttempts, quality }) {
|
|
27
|
+
const strongEnough = strongEvidenceCount(evidence) >= 2 && hasOfficialEvidence(evidence);
|
|
28
|
+
if (strongEnough && shouldSearchForBetterQuality({ evidence, quality, passIndex, maxPasses })) {
|
|
29
|
+
return { action: 'search-again' };
|
|
30
|
+
}
|
|
31
|
+
if (strongEnough) {
|
|
32
|
+
return hasQualityConcern(evidence, quality) ? { action: 'answer-with-caveat' } : { action: 'answer' };
|
|
5
33
|
}
|
|
6
34
|
const headlessUrl = suggestedHeadlessUrls.find((url) => !url.includes('npmjs.com/package/'));
|
|
7
35
|
if (headlessUrl && headlessAttempts < maxHeadlessAttempts) {
|
|
@@ -25,7 +25,8 @@ export function createWebExploreTool({ explore = createResearchWorkflow() } = {}
|
|
|
25
25
|
}));
|
|
26
26
|
const synthesized = synthesizeAnswer({
|
|
27
27
|
evidence: result.evidence,
|
|
28
|
-
partial: result.decision.action !== 'answer'
|
|
28
|
+
partial: result.decision.action !== 'answer',
|
|
29
|
+
caveatReasons: result.metadata?.caveatReasons
|
|
29
30
|
});
|
|
30
31
|
const shaped = {
|
|
31
32
|
status: 'ok',
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demigodmode/pi-web-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/extension.js",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@mozilla/readability": "^0.6.0",
|
|
58
58
|
"cheerio": "^1.1.0",
|
|
59
59
|
"jsdom": "^26.0.0",
|
|
60
|
-
"playwright
|
|
60
|
+
"playwright": "^1.60.0",
|
|
61
61
|
"typebox": "^1.1.37"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|