@demigodmode/pi-web-agent 1.3.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 CHANGED
@@ -18,6 +18,20 @@ 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
+
21
35
  ## [1.3.0] - 2026-06-04
22
36
  ### Added
23
37
  - Added direct URL handling in `web_explore` so linked pages are read before search results.
@@ -1,7 +1,9 @@
1
1
  import type { ResearchEvidence } from './research-types.js';
2
- export declare function synthesizeAnswer({ evidence, partial }: {
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
- export function synthesizeAnswer({ evidence, partial }) {
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
  }
@@ -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
+ }
@@ -23,6 +23,7 @@ export declare function createResearchWorkflow({ backendConfig, search, fetchPag
23
23
  fetchedPages: number;
24
24
  headlessAttempts: number;
25
25
  exhaustedBudget: boolean;
26
+ caveatReasons: import("./evidence-quality.js").EvidenceCaveatReason[];
26
27
  };
27
28
  }>;
28
29
  };
@@ -1,5 +1,6 @@
1
1
  import type { WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
2
2
  import type { ResearchEvidence, ResearchOrchestratorDecision, ResearchWorkerResult } from './research-types.js';
3
+ import { type EvidenceCaveatReason } from './evidence-quality.js';
3
4
  export declare function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }: {
4
5
  worker: {
5
6
  run: (input: {
@@ -26,6 +27,7 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
26
27
  fetchedPages: number;
27
28
  headlessAttempts: number;
28
29
  exhaustedBudget: boolean;
30
+ caveatReasons: EvidenceCaveatReason[];
29
31
  };
30
32
  }>;
31
33
  };
@@ -3,6 +3,7 @@ import { planSearchQueries } from './query-planner.js';
3
3
  import { classifySourceProfile } from './source-profile.js';
4
4
  import { extractDirectUrls } from './direct-url.js';
5
5
  import { decideNextResearchStep } from './stop-decider.js';
6
+ import { analyzeEvidenceQuality } from './evidence-quality.js';
6
7
  const DEFAULT_MAX_PASSES = 3;
7
8
  const DEFAULT_MAX_FETCHES_PER_PASS = 4;
8
9
  const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
@@ -65,15 +66,16 @@ function shouldRetryDirectWithHeadless(result, evidence) {
65
66
  return false;
66
67
  return classifySourceProfile(result.url).shouldPreferHeadlessWhenWeak;
67
68
  }
68
- function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget }) {
69
+ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget, caveatReasons = [] }) {
69
70
  return {
70
71
  searchPasses: previousQueries.length,
71
72
  fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
72
73
  headlessAttempts,
73
- exhaustedBudget
74
+ exhaustedBudget,
75
+ caveatReasons
74
76
  };
75
77
  }
76
- function decisionForAnswer(action, query, ranked) {
78
+ function decisionForAnswer({ action, query, ranked, exhaustedBudget }) {
77
79
  if (action === 'answer') {
78
80
  return {
79
81
  action: 'answer',
@@ -83,7 +85,7 @@ function decisionForAnswer(action, query, ranked) {
83
85
  }
84
86
  return {
85
87
  action: 'research-again',
86
- rationale: 'Research budget exhausted; answer with caveat.',
88
+ rationale: exhaustedBudget ? 'Research budget exhausted; answer with caveat.' : 'Evidence has quality caveats; answer with caveat.',
87
89
  followupQuery: query
88
90
  };
89
91
  }
@@ -153,13 +155,19 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
153
155
  if (pass.suggestedHeadlessUrl)
154
156
  suggestedHeadlessUrls.push(pass.suggestedHeadlessUrl);
155
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
+ });
156
163
  const decision = decideNextResearchStep({
157
164
  evidence: ranked,
158
165
  suggestedHeadlessUrls,
159
166
  passIndex,
160
167
  maxPasses: DEFAULT_MAX_PASSES,
161
168
  headlessAttempts,
162
- maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
169
+ maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS,
170
+ quality
163
171
  });
164
172
  if (decision.action === 'headless') {
165
173
  headlessAttempts++;
@@ -168,23 +176,35 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
168
176
  if (headlessEvidence) {
169
177
  allEvidence.push(headlessEvidence);
170
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
+ });
171
184
  const updatedDecision = decideNextResearchStep({
172
185
  evidence: updatedRanked,
173
186
  suggestedHeadlessUrls: [],
174
187
  passIndex,
175
188
  maxPasses: DEFAULT_MAX_PASSES,
176
189
  headlessAttempts,
177
- maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
190
+ maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS,
191
+ quality: updatedQuality
178
192
  });
193
+ const exhaustedBudget = updatedDecision.action !== 'answer' && passIndex + 1 >= DEFAULT_MAX_PASSES;
179
194
  return {
180
- decision: decisionForAnswer(updatedDecision.action === 'answer' ? 'answer' : 'answer-with-caveat', query, updatedRanked),
195
+ decision: decisionForAnswer({
196
+ action: updatedDecision.action === 'answer' ? 'answer' : 'answer-with-caveat',
197
+ query,
198
+ ranked: updatedRanked,
199
+ exhaustedBudget
200
+ }),
181
201
  evidence: updatedRanked,
182
202
  workerPass: combinedWorkerPass({
183
203
  lastPass,
184
204
  previousQueries,
185
205
  allGaps,
186
206
  allLowValueOutcomes,
187
- exhaustedBudget: updatedDecision.action !== 'answer'
207
+ exhaustedBudget
188
208
  }),
189
209
  metadata: buildMetadata({
190
210
  previousQueries,
@@ -192,7 +212,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
192
212
  allGaps,
193
213
  allLowValueOutcomes,
194
214
  headlessAttempts,
195
- exhaustedBudget: updatedDecision.action !== 'answer'
215
+ exhaustedBudget,
216
+ caveatReasons: updatedQuality.caveatReasons
196
217
  })
197
218
  };
198
219
  }
@@ -217,20 +238,22 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
217
238
  allGaps,
218
239
  allLowValueOutcomes,
219
240
  headlessAttempts,
220
- exhaustedBudget: false
241
+ exhaustedBudget: false,
242
+ caveatReasons: quality.caveatReasons
221
243
  })
222
244
  };
223
245
  }
224
246
  if (decision.action === 'answer' || decision.action === 'answer-with-caveat') {
247
+ const exhaustedBudget = decision.action === 'answer-with-caveat' && passIndex + 1 >= DEFAULT_MAX_PASSES;
225
248
  return {
226
- decision: decisionForAnswer(decision.action, query, ranked),
249
+ decision: decisionForAnswer({ action: decision.action, query, ranked, exhaustedBudget }),
227
250
  evidence: ranked,
228
251
  workerPass: combinedWorkerPass({
229
252
  lastPass,
230
253
  previousQueries,
231
254
  allGaps,
232
255
  allLowValueOutcomes,
233
- exhaustedBudget: decision.action === 'answer-with-caveat'
256
+ exhaustedBudget
234
257
  }),
235
258
  metadata: buildMetadata({
236
259
  previousQueries,
@@ -238,15 +261,21 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
238
261
  allGaps,
239
262
  allLowValueOutcomes,
240
263
  headlessAttempts,
241
- exhaustedBudget: decision.action === 'answer-with-caveat'
264
+ exhaustedBudget,
265
+ caveatReasons: quality.caveatReasons
242
266
  })
243
267
  };
244
268
  }
245
269
  }
246
270
  }
247
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
+ });
248
277
  return {
249
- decision: decisionForAnswer('answer-with-caveat', query, ranked),
278
+ decision: decisionForAnswer({ action: 'answer-with-caveat', query, ranked, exhaustedBudget: true }),
250
279
  evidence: ranked,
251
280
  workerPass: combinedWorkerPass({
252
281
  lastPass,
@@ -261,7 +290,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
261
290
  allGaps,
262
291
  allLowValueOutcomes,
263
292
  headlessAttempts,
264
- exhaustedBudget: true
293
+ exhaustedBudget: true,
294
+ caveatReasons: quality.caveatReasons
265
295
  })
266
296
  };
267
297
  }
@@ -6,10 +6,15 @@ 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 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
+ }
9
12
  function evidenceFromFetch(fetched, fallbackTitle) {
10
13
  const content = fetched.content;
11
14
  if (fetched.status !== 'ok' || !content)
12
15
  return null;
16
+ if (isBotCheckContent({ title: content.title, text: content.text }))
17
+ return null;
13
18
  const sourceKind = classifySource(fetched.url);
14
19
  if (sourceKind === 'package-page') {
15
20
  return null;
@@ -26,6 +31,13 @@ function evidenceFromFetch(fetched, fallbackTitle) {
26
31
  function lowValueOutcomeFromFetch(fetched) {
27
32
  if (fetched.status !== 'ok' || !fetched.content)
28
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
+ }
29
41
  if (classifySource(fetched.url) !== 'package-page')
30
42
  return null;
31
43
  return {
@@ -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
- export function decideNextResearchStep({ evidence, suggestedHeadlessUrls, passIndex, maxPasses, headlessAttempts, maxHeadlessAttempts }) {
3
- if (strongEvidenceCount(evidence) >= 2 && hasOfficialEvidence(evidence)) {
4
- return { action: 'answer' };
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) {
@@ -39,6 +39,7 @@ export declare function createWebExploreTool({ explore }?: {
39
39
  fetchedPages: number;
40
40
  headlessAttempts: number;
41
41
  exhaustedBudget: boolean;
42
+ caveatReasons?: string[];
42
43
  };
43
44
  error?: import("../types.js").ToolError;
44
45
  }>;
@@ -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
@@ -68,6 +68,7 @@ export type WebExploreResponse = {
68
68
  fetchedPages: number;
69
69
  headlessAttempts: number;
70
70
  exhaustedBudget: boolean;
71
+ caveatReasons?: string[];
71
72
  };
72
73
  presentation?: PresentationEnvelope;
73
74
  error?: ToolError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.3.0",
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",