@demigodmode/pi-web-agent 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/backends/config.d.ts +13 -0
  3. package/dist/backends/config.js +44 -1
  4. package/dist/backends/factory.d.ts +15 -0
  5. package/dist/backends/factory.js +118 -91
  6. package/dist/backends/failure.d.ts +11 -0
  7. package/dist/backends/failure.js +34 -0
  8. package/dist/backends/fallback-policy.d.ts +33 -0
  9. package/dist/backends/fallback-policy.js +239 -0
  10. package/dist/backends/provider-failure.d.ts +21 -0
  11. package/dist/backends/provider-failure.js +111 -0
  12. package/dist/backends/provider-health.d.ts +29 -0
  13. package/dist/backends/provider-health.js +49 -0
  14. package/dist/commands/web-agent-config.d.ts +14 -1
  15. package/dist/commands/web-agent-config.js +75 -3
  16. package/dist/extension.js +47 -3
  17. package/dist/fetch/destination-policy.d.ts +32 -0
  18. package/dist/fetch/destination-policy.js +24 -0
  19. package/dist/fetch/firecrawl-fetch.js +64 -45
  20. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  21. package/dist/fetch/guard-proxy-fetch.js +82 -0
  22. package/dist/fetch/guard-proxy.d.ts +58 -0
  23. package/dist/fetch/guard-proxy.js +420 -0
  24. package/dist/fetch/guarded-fetch.d.ts +7 -0
  25. package/dist/fetch/guarded-fetch.js +75 -0
  26. package/dist/fetch/headless-fetch.d.ts +10 -2
  27. package/dist/fetch/headless-fetch.js +181 -9
  28. package/dist/fetch/http-fetch.js +16 -1
  29. package/dist/fetch/network-guard.d.ts +82 -0
  30. package/dist/fetch/network-guard.js +275 -0
  31. package/dist/orchestration/answer-synthesizer.js +2 -0
  32. package/dist/orchestration/evidence-quality.d.ts +3 -2
  33. package/dist/orchestration/evidence-quality.js +2 -1
  34. package/dist/orchestration/index.d.ts +23 -0
  35. package/dist/orchestration/index.js +9 -2
  36. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  37. package/dist/orchestration/research-orchestrator.js +40 -7
  38. package/dist/orchestration/research-types.d.ts +13 -1
  39. package/dist/orchestration/research-worker.js +38 -3
  40. package/dist/orchestration/stop-decider.js +3 -1
  41. package/dist/presentation/config-store.js +6 -0
  42. package/dist/presentation/explore-presentation.js +3 -1
  43. package/dist/presentation/fetch-presentation.js +16 -9
  44. package/dist/presentation/search-presentation.d.ts +2 -1
  45. package/dist/presentation/search-presentation.js +13 -1
  46. package/dist/search/brave.d.ts +1 -2
  47. package/dist/search/brave.js +23 -80
  48. package/dist/search/duckduckgo.d.ts +7 -3
  49. package/dist/search/duckduckgo.js +17 -18
  50. package/dist/search/exa.d.ts +1 -2
  51. package/dist/search/exa.js +15 -76
  52. package/dist/search/fanout.d.ts +12 -0
  53. package/dist/search/fanout.js +86 -47
  54. package/dist/search/json-provider.d.ts +32 -0
  55. package/dist/search/json-provider.js +76 -0
  56. package/dist/search/searxng.d.ts +1 -2
  57. package/dist/search/searxng.js +15 -57
  58. package/dist/search/tavily.d.ts +1 -2
  59. package/dist/search/tavily.js +17 -74
  60. package/dist/search/youcom.d.ts +1 -2
  61. package/dist/search/youcom.js +15 -76
  62. package/dist/tools/web-explore.d.ts +9 -0
  63. package/dist/tools/web-explore.js +16 -2
  64. package/dist/tools/web-search.js +41 -103
  65. package/dist/types.d.ts +40 -0
  66. package/package.json +3 -3
@@ -1,81 +1,20 @@
1
- import { buildSearchPresentation } from '../presentation/search-presentation.js';
1
+ import { createJsonSearchProvider, normalizeResultsArray } from './json-provider.js';
2
2
  const YOUCOM_SEARCH_URL = 'https://api.you.com/v1/agents/search';
3
- function resultWithPresentation(result) {
4
- return { ...result, presentation: buildSearchPresentation(result) };
5
- }
6
- function normalizeResults(response) {
7
- return (response.results ?? []).flatMap((item) => {
8
- if (typeof item.title !== 'string' || typeof item.url !== 'string') {
9
- return [];
10
- }
11
- return [
12
- {
13
- title: item.title,
14
- url: item.url,
15
- snippet: typeof item.snippet === 'string' ? item.snippet : ''
16
- }
17
- ];
18
- });
19
- }
20
3
  export function createYouComSearchTool({ apiKey, fetchImpl = fetch }) {
21
- return async function youComSearch({ query }) {
22
- const normalizedQuery = query.trim();
23
- if (!normalizedQuery) {
24
- return resultWithPresentation({
25
- status: 'error',
26
- results: [],
27
- metadata: { backend: 'youcom', cacheHit: false },
28
- error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
29
- });
30
- }
31
- if (!apiKey?.trim()) {
32
- return resultWithPresentation({
33
- status: 'error',
34
- results: [],
35
- metadata: { backend: 'youcom', cacheHit: false },
36
- error: {
37
- code: 'BACKEND_CONFIG_INVALID',
38
- message: 'You.com search requires YDC_API_KEY.'
39
- }
40
- });
41
- }
42
- try {
43
- const response = await fetchImpl(YOUCOM_SEARCH_URL, {
4
+ return createJsonSearchProvider({
5
+ name: 'youcom',
6
+ label: 'You.com',
7
+ apiKey,
8
+ missingKeyMessage: 'You.com search requires YDC_API_KEY.',
9
+ fetchImpl,
10
+ request: (query) => ({
11
+ url: YOUCOM_SEARCH_URL,
12
+ init: {
44
13
  method: 'POST',
45
- headers: {
46
- Accept: 'application/json',
47
- 'Content-Type': 'application/json',
48
- 'X-API-Key': apiKey
49
- },
50
- body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
51
- });
52
- if (!response.ok) {
53
- throw new Error(`HTTP ${response.status}`);
54
- }
55
- const parsed = (await response.json());
56
- const results = normalizeResults(parsed);
57
- if (results.length === 0) {
58
- return resultWithPresentation({
59
- status: 'error',
60
- results: [],
61
- metadata: { backend: 'youcom', cacheHit: false },
62
- error: { code: 'NO_RESULTS', message: 'You.com returned no usable results for this query.' }
63
- });
14
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-API-Key': apiKey ?? '' },
15
+ body: JSON.stringify({ query, max_results: 10 })
64
16
  }
65
- return resultWithPresentation({
66
- status: 'ok',
67
- results,
68
- metadata: { backend: 'youcom', cacheHit: false }
69
- });
70
- }
71
- catch (error) {
72
- const rawMessage = error instanceof Error ? error.message : String(error);
73
- return resultWithPresentation({
74
- status: 'error',
75
- results: [],
76
- metadata: { backend: 'youcom', cacheHit: false },
77
- error: { code: 'FETCH_FAILED', message: `You.com search request failed: ${rawMessage}` }
78
- });
79
- }
80
- };
17
+ }),
18
+ normalize: (json) => normalizeResultsArray(json, (body) => body.results, 'snippet')
19
+ });
81
20
  }
@@ -11,6 +11,10 @@ export declare function createWebExploreTool({ explore }?: {
11
11
  evidence: ResearchEvidence[];
12
12
  workerPass: unknown;
13
13
  metadata?: WebExploreResponse['metadata'];
14
+ terminalFailure?: {
15
+ code: string;
16
+ message: string;
17
+ };
14
18
  }>;
15
19
  } | ((input: {
16
20
  query: string;
@@ -21,6 +25,10 @@ export declare function createWebExploreTool({ explore }?: {
21
25
  evidence: ResearchEvidence[];
22
26
  workerPass: unknown;
23
27
  metadata?: WebExploreResponse['metadata'];
28
+ terminalFailure?: {
29
+ code: string;
30
+ message: string;
31
+ };
24
32
  }>);
25
33
  }): ({ query }: {
26
34
  query: string;
@@ -42,6 +50,7 @@ export declare function createWebExploreTool({ explore }?: {
42
50
  caveatReasons?: string[];
43
51
  fanoutProviders?: import("../types.js").SearchProviderName[];
44
52
  fanoutSkipped?: import("../types.js").SearchProviderName[];
53
+ attempts?: import("../types.js").Attempt[];
45
54
  };
46
55
  error?: import("../types.js").ToolError;
47
56
  }>;
@@ -18,15 +18,29 @@ export function createWebExploreTool({ explore = createResearchWorkflow() } = {}
18
18
  };
19
19
  }
20
20
  const result = await runExplore({ query: normalizedQuery });
21
+ if (result.terminalFailure) {
22
+ const failed = {
23
+ status: 'error',
24
+ findings: [],
25
+ sources: [],
26
+ error: result.terminalFailure,
27
+ metadata: result.metadata
28
+ };
29
+ return { ...failed, presentation: buildExplorePresentation(failed) };
30
+ }
21
31
  const sources = result.evidence.slice(0, 4).map((item) => ({
22
32
  title: item.title,
23
33
  url: item.url,
24
34
  method: item.method
25
35
  }));
36
+ const reasons = (result.metadata?.caveatReasons ?? []);
37
+ const decisionPartial = result.decision.action !== 'answer';
38
+ const coveragePartial = reasons.includes('partial-search-coverage');
26
39
  const synthesized = synthesizeAnswer({
27
40
  evidence: result.evidence,
28
- partial: result.decision.action !== 'answer',
29
- caveatReasons: result.metadata?.caveatReasons
41
+ partial: decisionPartial || coveragePartial,
42
+ // A confident answer with partial coverage mentions only the coverage, not unrelated quality notes.
43
+ caveatReasons: decisionPartial ? reasons : ['partial-search-coverage']
30
44
  });
31
45
  const shaped = {
32
46
  status: 'ok',
@@ -1,25 +1,9 @@
1
1
  import { createCacheKey, createTtlCache } from '../cache/ttl-cache.js';
2
2
  import { buildSearchPresentation } from '../presentation/search-presentation.js';
3
- import { fetchDuckDuckGoHtml, parseDuckDuckGoResults } from '../search/duckduckgo.js';
4
- function classifySearchFailure(error) {
5
- const rawMessage = error instanceof Error ? error.message : 'Unknown search failure.';
6
- const normalized = rawMessage.toLowerCase();
7
- if (normalized.includes('blocked') ||
8
- normalized.includes('rate limit') ||
9
- normalized.includes('rate-limit') ||
10
- normalized.includes('403') ||
11
- normalized.includes('429') ||
12
- normalized.includes('captcha') ||
13
- normalized.includes('challenge')) {
14
- return {
15
- code: 'BLOCKED',
16
- message: 'DuckDuckGo search appears to be blocked or rate limited.'
17
- };
18
- }
19
- return {
20
- code: 'FETCH_FAILED',
21
- message: `DuckDuckGo search request failed: ${rawMessage}`
22
- };
3
+ import { classifyHttpFailure } from '../backends/provider-failure.js';
4
+ import { DuckDuckGoHttpError, fetchDuckDuckGoHtml, parseDuckDuckGoResults } from '../search/duckduckgo.js';
5
+ function respond(result) {
6
+ return { ...result, presentation: buildSearchPresentation(result) };
23
7
  }
24
8
  function htmlLooksBlocked(html) {
25
9
  const normalized = html.toLowerCase();
@@ -37,107 +21,61 @@ export function createWebSearchTool({ searchHtml = fetchDuckDuckGoHtml, cache =
37
21
  return async function webSearch({ query }) {
38
22
  const normalizedQuery = query.trim();
39
23
  if (!normalizedQuery) {
40
- const result = {
24
+ return respond({
41
25
  status: 'error',
42
26
  results: [],
43
27
  metadata: { backend: 'duckduckgo', cacheHit: false },
44
- error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
45
- };
46
- return {
47
- ...result,
48
- presentation: buildSearchPresentation(result)
49
- };
28
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.', failure: { kind: 'bad_request' } }
29
+ });
50
30
  }
51
31
  const cacheKey = createCacheKey(['web_search', normalizedQuery]);
52
32
  const cached = cache.get(cacheKey);
53
33
  if (cached) {
54
- const result = {
55
- ...cached,
56
- metadata: { ...cached.metadata, cacheHit: true }
57
- };
58
- return {
59
- ...result,
60
- presentation: buildSearchPresentation(result)
61
- };
34
+ return respond({ ...cached, metadata: { ...cached.metadata, cacheHit: true } });
62
35
  }
36
+ let html;
63
37
  try {
64
- let html = await searchHtml(normalizedQuery);
65
- let parsed = parseDuckDuckGoResults(html);
66
- // A 200-OK bot-wall reads as a successful fetch, so the fetch-layer retry never sees it.
67
- // Give a page that looks blocked one more shot here before we classify it.
68
- if (parsed.results.length === 0 && htmlLooksBlocked(html)) {
69
- html = await searchHtml(normalizedQuery);
70
- parsed = parseDuckDuckGoResults(html);
71
- }
72
- if (parsed.results.length > 0) {
73
- const result = {
74
- status: 'ok',
75
- results: parsed.results,
76
- metadata: { backend: 'duckduckgo', cacheHit: false }
77
- };
78
- cache.set(cacheKey, result);
79
- return {
80
- ...result,
81
- presentation: buildSearchPresentation(result)
82
- };
83
- }
84
- // Check for a bot-wall before "no results": a page can carry both markers, and BLOCKED is
85
- // the honest call since it routes to the fallback instead of a dead end.
86
- if (htmlLooksBlocked(html)) {
87
- const result = {
88
- status: 'error',
89
- results: [],
90
- metadata: { backend: 'duckduckgo', cacheHit: false },
91
- error: {
92
- code: 'BLOCKED',
93
- message: 'DuckDuckGo search appears to be blocked or rate limited.'
94
- }
95
- };
96
- return {
97
- ...result,
98
- presentation: buildSearchPresentation(result)
99
- };
100
- }
101
- if (parsed.noResults) {
102
- const result = {
103
- status: 'error',
104
- results: [],
105
- metadata: { backend: 'duckduckgo', cacheHit: false },
106
- error: {
107
- code: 'NO_RESULTS',
108
- message: 'DuckDuckGo returned no usable results for this query.'
109
- }
110
- };
111
- return {
112
- ...result,
113
- presentation: buildSearchPresentation(result)
114
- };
115
- }
116
- const result = {
38
+ html = await searchHtml(normalizedQuery);
39
+ }
40
+ catch (error) {
41
+ const failure = error instanceof DuckDuckGoHttpError
42
+ ? classifyHttpFailure('duckduckgo', { status: error.status, headers: error.headers })
43
+ : { kind: 'transient' };
44
+ const blockedLike = failure.kind === 'blocked' || failure.kind === 'rate_limited';
45
+ return respond({
117
46
  status: 'error',
118
47
  results: [],
119
48
  metadata: { backend: 'duckduckgo', cacheHit: false },
120
49
  error: {
121
- code: 'PARSE_FAILED',
122
- message: 'DuckDuckGo returned a page, but it did not match the expected results format.'
50
+ code: blockedLike ? 'BLOCKED' : 'FETCH_FAILED',
51
+ message: blockedLike
52
+ ? 'DuckDuckGo search appears to be blocked or rate limited.'
53
+ : `DuckDuckGo search request failed: ${error instanceof Error ? error.message : String(error)}`,
54
+ failure
123
55
  }
124
- };
125
- return {
126
- ...result,
127
- presentation: buildSearchPresentation(result)
128
- };
56
+ });
129
57
  }
130
- catch (error) {
131
- const result = {
58
+ const parsed = parseDuckDuckGoResults(html);
59
+ if (parsed.results.length > 0) {
60
+ const result = { status: 'ok', results: parsed.results, metadata: { backend: 'duckduckgo', cacheHit: false } };
61
+ cache.set(cacheKey, result);
62
+ return respond(result);
63
+ }
64
+ // Bot-wall check first: a page can carry both markers, and blocked routes to fallback.
65
+ const walled = htmlLooksBlocked(html) || (!parsed.hasResultContainers && !parsed.noResults);
66
+ if (walled) {
67
+ return respond({
132
68
  status: 'error',
133
69
  results: [],
134
70
  metadata: { backend: 'duckduckgo', cacheHit: false },
135
- error: classifySearchFailure(error)
136
- };
137
- return {
138
- ...result,
139
- presentation: buildSearchPresentation(result)
140
- };
71
+ error: {
72
+ code: 'BLOCKED',
73
+ message: 'DuckDuckGo search appears to be blocked or rate limited.',
74
+ failure: { kind: 'blocked' }
75
+ }
76
+ });
141
77
  }
78
+ // DuckDuckGo said there are no results, or every result was filtered out: a valid empty search.
79
+ return respond({ status: 'ok', results: [], metadata: { backend: 'duckduckgo', cacheHit: false } });
142
80
  };
143
81
  }
package/dist/types.d.ts CHANGED
@@ -12,10 +12,44 @@ export type FanoutMetadata = {
12
12
  mode: Exclude<FanoutMode, 'off'>;
13
13
  providers: SearchProviderName[];
14
14
  skipped?: SearchProviderName[];
15
+ outcomes?: FanoutOutcome[];
16
+ };
17
+ export type FailureKind = 'rate_limited' | 'quota_exhausted' | 'auth_failed' | 'not_configured' | 'transient' | 'blocked' | 'bad_response' | 'bad_request' | 'config_global' | 'guard_refused';
18
+ export type FailureInfo = {
19
+ kind: FailureKind;
20
+ httpStatus?: number;
21
+ /** Documented provider body code or tag, e.g. Exa `NO_MORE_CREDITS`. */
22
+ providerCode?: string;
23
+ /** Exactly what the provider reported, uncapped. */
24
+ providerRetryAfterMs?: number;
25
+ };
26
+ export type Attempt = {
27
+ backend: string;
28
+ outcome: 'results' | 'empty' | 'failed' | 'skipped' | 'retried';
29
+ failure?: FailureInfo;
30
+ skipReason?: 'cooling_down' | 'disabled';
31
+ cooldownUntil?: number;
32
+ /** The provider's own message for a user-fixable failure, e.g. a missing key or base URL. */
33
+ detail?: string;
34
+ };
35
+ export type SearchCoverage = {
36
+ partial: true;
37
+ unavailable: Array<{
38
+ provider: string;
39
+ kind: FailureKind;
40
+ }>;
41
+ };
42
+ export type FanoutOutcome = {
43
+ provider: SearchProviderName;
44
+ outcome: 'results' | 'empty' | 'failed' | 'skipped';
45
+ count?: number;
46
+ failure?: FailureInfo;
47
+ skipReason?: 'cooling_down' | 'disabled';
15
48
  };
16
49
  export type ToolError = {
17
50
  code: string;
18
51
  message: string;
52
+ failure?: FailureInfo;
19
53
  };
20
54
  export type SearchMetadata = {
21
55
  backend: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
@@ -23,6 +57,8 @@ export type SearchMetadata = {
23
57
  fallbackFrom?: 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily' | 'duckduckgo';
24
58
  fallbackReason?: string;
25
59
  fanout?: FanoutMetadata;
60
+ attempts?: Attempt[];
61
+ coverage?: SearchCoverage;
26
62
  };
27
63
  export type FetchMethod = 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
28
64
  export type FetchMetadata = {
@@ -34,6 +70,9 @@ export type FetchMetadata = {
34
70
  truncated?: boolean;
35
71
  browser?: 'configured' | 'chrome' | 'edge' | 'brave' | 'chromium';
36
72
  navigationMs?: number;
73
+ /** Headless only: browser requests refused by the private-address guard (#53). */
74
+ blockedSubresources?: number;
75
+ attempts?: Attempt[];
37
76
  };
38
77
  export type ExtractedContent = {
39
78
  title?: string;
@@ -80,6 +119,7 @@ export type WebExploreResponse = {
80
119
  caveatReasons?: string[];
81
120
  fanoutProviders?: SearchProviderName[];
82
121
  fanoutSkipped?: SearchProviderName[];
122
+ attempts?: Attempt[];
83
123
  };
84
124
  presentation?: PresentationEnvelope;
85
125
  error?: ToolError;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",
@@ -77,10 +77,10 @@
77
77
  "@earendil-works/pi-tui": "^0.80.10",
78
78
  "@types/jsdom": "^21.1.7",
79
79
  "@types/node": "^24.0.0",
80
- "@vitest/coverage-v8": "^3.2.6",
80
+ "@vitest/coverage-v8": "^4.1.11",
81
81
  "typescript": "^5.8.0",
82
82
  "vitepress": "^1.6.4",
83
- "vitest": "^3.2.6"
83
+ "vitest": "^4.1.11"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "@earendil-works/pi-coding-agent": "*",