@selesai/code 0.13.3 → 0.13.4

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 (119) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/defaults/models.json +17 -1
  3. package/dist/extensions/cost-reconcile.test.ts +24 -5
  4. package/dist/extensions/cost-reconcile.ts +14 -7
  5. package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
  6. package/dist/extensions/pi-intercom/README.md +4 -5
  7. package/dist/extensions/pi-intercom/config.test.ts +3 -31
  8. package/dist/extensions/pi-intercom/config.ts +0 -15
  9. package/dist/extensions/pi-intercom/index.ts +9 -46
  10. package/dist/extensions/pi-intercom/intercom.integration.test.ts +49 -57
  11. package/dist/extensions/pi-intercom/package.json +1 -1
  12. package/dist/extensions/pi-intercom/reply-tracker.test.ts +20 -0
  13. package/dist/extensions/pi-intercom/reply-tracker.ts +8 -0
  14. package/dist/extensions/pi-subagents/CHANGELOG.md +27 -0
  15. package/dist/extensions/pi-subagents/docs/tool-reference.md +4 -1
  16. package/dist/extensions/pi-subagents/docs/workflows.md +2 -2
  17. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  18. package/dist/extensions/pi-subagents/package.json +1 -1
  19. package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +48 -243
  20. package/dist/extensions/pi-subagents/skills/council-mode/references/pass-contracts.md +150 -0
  21. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +87 -37
  22. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  23. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +49 -8
  24. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  25. package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +34 -27
  27. package/dist/extensions/pi-subagents/skills/pi-subagents/references/review-and-validation.md +73 -0
  28. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +157 -28
  29. package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -0
  30. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +1 -0
  31. package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -0
  32. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +4 -1
  33. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +3 -0
  35. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +45 -2
  36. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +3 -2
  37. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +13 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +5 -1
  39. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +10 -2
  40. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +3 -0
  41. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +11 -2
  42. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +98 -1
  43. package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +138 -4
  44. package/dist/extensions/pi-subagents/src/runs/shared/background-process-options.ts +9 -0
  45. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  46. package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +52 -3
  47. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +47 -1
  48. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +45 -18
  49. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +21 -2
  50. package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +15 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +34 -1
  52. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +11 -3
  53. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +31 -0
  54. package/dist/extensions/pi-subagents/src/tui/render.ts +597 -112
  55. package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +40 -1
  56. package/dist/extensions/pi-subagents/src/workflows/host-command.ts +6 -1
  57. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +53 -2
  58. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +55 -6
  59. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +111 -1
  60. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +206 -38
  61. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +522 -31
  62. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +123 -0
  63. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +48 -0
  64. package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +57 -1
  65. package/dist/extensions/pi-subagents/test/unit/background-process-options.test.ts +17 -0
  66. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +44 -4
  68. package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +91 -0
  69. package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +1 -0
  70. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +0 -1
  71. package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +20 -3
  72. package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +27 -0
  73. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +93 -17
  74. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +1 -0
  75. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +103 -26
  76. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +58 -0
  77. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +22 -2
  78. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +21 -0
  79. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +13 -0
  80. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +54 -0
  81. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +2 -0
  82. package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +32 -0
  83. package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +48 -1
  84. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +21 -6
  85. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +11 -0
  86. package/dist/extensions/pi-web-agent/CHANGELOG.md +410 -0
  87. package/dist/extensions/pi-web-agent/README.md +131 -0
  88. package/dist/extensions/pi-web-agent/package.json +4 -2
  89. package/dist/extensions/pi-web-agent/src/backends/config.ts +62 -5
  90. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  91. package/dist/extensions/pi-web-agent/src/backends/factory.ts +96 -6
  92. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +183 -47
  93. package/dist/extensions/pi-web-agent/src/extension.ts +62 -25
  94. package/dist/extensions/pi-web-agent/src/extract/readability.ts +19 -11
  95. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +5 -4
  96. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +2 -25
  97. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +5 -2
  98. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +2 -0
  99. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +72 -7
  100. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +8 -1
  101. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +28 -3
  102. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +4 -0
  103. package/dist/extensions/pi-web-agent/src/orchestration/url.ts +35 -0
  104. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +18 -6
  105. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +14 -2
  106. package/dist/extensions/pi-web-agent/src/readers/github-reader.ts +150 -0
  107. package/dist/extensions/pi-web-agent/src/readers/limits.ts +3 -0
  108. package/dist/extensions/pi-web-agent/src/readers/pdf-reader.ts +87 -0
  109. package/dist/extensions/pi-web-agent/src/readers/resolver.ts +25 -0
  110. package/dist/extensions/pi-web-agent/src/readers/types.ts +11 -0
  111. package/dist/extensions/pi-web-agent/src/readers/youtube-reader.ts +79 -0
  112. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +32 -5
  113. package/dist/extensions/pi-web-agent/src/search/exa.ts +109 -0
  114. package/dist/extensions/pi-web-agent/src/search/fanout.ts +154 -0
  115. package/dist/extensions/pi-web-agent/src/search/tavily.ts +113 -0
  116. package/dist/extensions/pi-web-agent/src/search/youcom.ts +109 -0
  117. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +22 -9
  118. package/dist/extensions/pi-web-agent/src/types.ts +19 -4
  119. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ import { getSubtitles, getVideoDetails } from 'youtube-caption-extractor';
2
+ import type { WebFetchResponse } from '../types.js';
3
+ import type { SpecialContentReader } from './types.js';
4
+ import { READER_TEXT_CAP } from './limits.js';
5
+
6
+ type Subtitle = { start: string; dur: string; text: string };
7
+ type YoutubeReaderDeps = {
8
+ fetchSubtitles?: (input: { videoID: string; lang: string }) => Promise<Subtitle[]>;
9
+ fetchDetails?: (input: { videoID: string; lang: string }) => Promise<{ title?: string; description?: string }>;
10
+ };
11
+
12
+ function extractVideoId(url: string): string | undefined {
13
+ let parsed: URL;
14
+ try {
15
+ parsed = new URL(url);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
20
+ if (host === 'youtu.be') {
21
+ return parsed.pathname.split('/').filter(Boolean)[0];
22
+ }
23
+ if (host === 'youtube.com') {
24
+ if (parsed.pathname === '/watch') return parsed.searchParams.get('v') ?? undefined;
25
+ const [prefix, id] = parsed.pathname.split('/').filter(Boolean);
26
+ if ((prefix === 'shorts' || prefix === 'live' || prefix === 'embed') && id) return id;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetails = getVideoDetails }: YoutubeReaderDeps = {}): SpecialContentReader {
32
+ return {
33
+ name: 'youtube',
34
+ canHandle(url: string): boolean {
35
+ return extractVideoId(url) !== undefined;
36
+ },
37
+ async read(url: string): Promise<WebFetchResponse> {
38
+ const videoID = extractVideoId(url);
39
+ if (!videoID) {
40
+ return {
41
+ status: 'error',
42
+ url,
43
+ metadata: { method: 'youtube', cacheHit: false },
44
+ error: { code: 'YOUTUBE_READ_FAILED', message: 'Could not extract a video id.' }
45
+ };
46
+ }
47
+ try {
48
+ const [subtitles, details] = await Promise.all([
49
+ fetchSubtitles({ videoID, lang: 'en' }),
50
+ fetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
51
+ ]);
52
+
53
+ if (!subtitles || subtitles.length === 0) {
54
+ return {
55
+ status: 'unsupported',
56
+ url,
57
+ metadata: { method: 'youtube', cacheHit: false },
58
+ error: { code: 'YOUTUBE_NO_CAPTIONS', message: 'No captions available for this video.' }
59
+ };
60
+ }
61
+
62
+ const transcript = subtitles.map((line) => line.text).join(' ').replace(/\s+/g, ' ').trim();
63
+ return {
64
+ status: 'ok',
65
+ url,
66
+ content: { title: details.title ?? `YouTube ${videoID}`, text: transcript.slice(0, READER_TEXT_CAP) },
67
+ metadata: { method: 'youtube', cacheHit: false, truncated: transcript.length >= READER_TEXT_CAP }
68
+ };
69
+ } catch (err) {
70
+ return {
71
+ status: 'error',
72
+ url,
73
+ metadata: { method: 'youtube', cacheHit: false },
74
+ error: { code: 'YOUTUBE_READ_FAILED', message: err instanceof Error ? err.message : 'YouTube read failed.' }
75
+ };
76
+ }
77
+ }
78
+ };
79
+ }
@@ -33,12 +33,39 @@ export function buildSearchUrl(query: string): string {
33
33
  return `https://html.duckduckgo.com/html/?${params.toString()}`;
34
34
  }
35
35
 
36
- export async function fetchDuckDuckGoHtml(query: string): Promise<string> {
37
- const response = await fetch(buildSearchUrl(query));
38
- if (!response.ok) {
39
- throw new Error(`DuckDuckGo request failed with ${response.status}`);
36
+ export const DUCKDUCKGO_HEADERS = {
37
+ 'User-Agent':
38
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
39
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
40
+ 'Accept-Language': 'en-US,en;q=0.9'
41
+ } as const;
42
+
43
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
44
+
45
+ export async function fetchDuckDuckGoHtml(
46
+ query: string,
47
+ {
48
+ fetchImpl = fetch,
49
+ retries = 1,
50
+ sleep = defaultSleep
51
+ }: { fetchImpl?: typeof fetch; retries?: number; sleep?: (ms: number) => Promise<void> } = {}
52
+ ): Promise<string> {
53
+ let lastError: unknown;
54
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
55
+ try {
56
+ const response = await fetchImpl(buildSearchUrl(query), { headers: { ...DUCKDUCKGO_HEADERS } });
57
+ if (!response.ok) {
58
+ throw new Error(`DuckDuckGo request failed with ${response.status}`);
59
+ }
60
+ return response.text();
61
+ } catch (error) {
62
+ lastError = error;
63
+ if (attempt < retries) {
64
+ await sleep(500);
65
+ }
66
+ }
40
67
  }
41
- return response.text();
68
+ throw lastError instanceof Error ? lastError : new Error('DuckDuckGo request failed');
42
69
  }
43
70
 
44
71
  export function parseDuckDuckGoResults(html: string): ParsedDuckDuckGoResults {
@@ -0,0 +1,109 @@
1
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
+ import type { SearchResult, WebSearchResponse } from '../types.js';
3
+
4
+ type ExaSearchResult = {
5
+ title?: unknown;
6
+ url?: unknown;
7
+ text?: unknown;
8
+ };
9
+
10
+ type ExaSearchResponse = {
11
+ results?: ExaSearchResult[];
12
+ };
13
+
14
+ const EXA_SEARCH_URL = 'https://api.exa.ai/search';
15
+
16
+ function resultWithPresentation(result: WebSearchResponse): WebSearchResponse {
17
+ return { ...result, presentation: buildSearchPresentation(result) };
18
+ }
19
+
20
+ function normalizeResults(response: ExaSearchResponse): SearchResult[] {
21
+ return (response.results ?? []).flatMap((item) => {
22
+ if (typeof item.title !== 'string' || typeof item.url !== 'string') {
23
+ return [];
24
+ }
25
+
26
+ return [
27
+ {
28
+ title: item.title,
29
+ url: item.url,
30
+ snippet: typeof item.text === 'string' ? item.text : ''
31
+ }
32
+ ];
33
+ });
34
+ }
35
+
36
+ export function createExaSearchTool({
37
+ apiKey,
38
+ fetchImpl = fetch
39
+ }: {
40
+ apiKey?: string;
41
+ fetchImpl?: typeof fetch;
42
+ }) {
43
+ return async function exaSearch({ query }: { query: string }): Promise<WebSearchResponse> {
44
+ const normalizedQuery = query.trim();
45
+
46
+ if (!normalizedQuery) {
47
+ return resultWithPresentation({
48
+ status: 'error',
49
+ results: [],
50
+ metadata: { backend: 'exa', cacheHit: false },
51
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
52
+ });
53
+ }
54
+
55
+ if (!apiKey?.trim()) {
56
+ return resultWithPresentation({
57
+ status: 'error',
58
+ results: [],
59
+ metadata: { backend: 'exa', cacheHit: false },
60
+ error: {
61
+ code: 'BACKEND_CONFIG_INVALID',
62
+ message: 'Exa search requires EXA_API_KEY.'
63
+ }
64
+ });
65
+ }
66
+
67
+ try {
68
+ const response = await fetchImpl(EXA_SEARCH_URL, {
69
+ method: 'POST',
70
+ headers: {
71
+ Accept: 'application/json',
72
+ 'Content-Type': 'application/json',
73
+ 'x-api-key': apiKey
74
+ },
75
+ body: JSON.stringify({ query: normalizedQuery, numResults: 10 })
76
+ });
77
+
78
+ if (!response.ok) {
79
+ throw new Error(`HTTP ${response.status}`);
80
+ }
81
+
82
+ const parsed = (await response.json()) as ExaSearchResponse;
83
+ const results = normalizeResults(parsed);
84
+
85
+ if (results.length === 0) {
86
+ return resultWithPresentation({
87
+ status: 'error',
88
+ results: [],
89
+ metadata: { backend: 'exa', cacheHit: false },
90
+ error: { code: 'NO_RESULTS', message: 'Exa returned no usable results for this query.' }
91
+ });
92
+ }
93
+
94
+ return resultWithPresentation({
95
+ status: 'ok',
96
+ results,
97
+ metadata: { backend: 'exa', cacheHit: false }
98
+ });
99
+ } catch (error) {
100
+ const rawMessage = error instanceof Error ? error.message : String(error);
101
+ return resultWithPresentation({
102
+ status: 'error',
103
+ results: [],
104
+ metadata: { backend: 'exa', cacheHit: false },
105
+ error: { code: 'FETCH_FAILED', message: `Exa search request failed: ${rawMessage}` }
106
+ });
107
+ }
108
+ };
109
+ }
@@ -0,0 +1,154 @@
1
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
+ import { canonicalizeUrl } from '../orchestration/url.js';
3
+ import type { FanoutMetadata, FanoutMode, SearchProviderName, SearchResult, WebSearchResponse } from '../types.js';
4
+
5
+ export type FanoutProvider = {
6
+ name: SearchProviderName;
7
+ search: (input: { query: string }) => Promise<WebSearchResponse>;
8
+ };
9
+
10
+ const FANOUT_MIN_RESULTS = 3;
11
+
12
+ type RankedEntry = {
13
+ result: SearchResult;
14
+ providers: Set<SearchProviderName>;
15
+ bestRank: number;
16
+ };
17
+
18
+ function hostOf(url: string): string | undefined {
19
+ try {
20
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ function primaryLooksWeak(results: SearchResult[]): boolean {
27
+ if (results.length < FANOUT_MIN_RESULTS) return true;
28
+ const hosts = new Set(results.map((r) => hostOf(r.url)).filter(Boolean));
29
+ return hosts.size <= 1;
30
+ }
31
+
32
+ function merge(lists: Array<{ name: SearchProviderName; results: SearchResult[] }>): SearchResult[] {
33
+ const byKey = new Map<string, RankedEntry>();
34
+
35
+ for (const { name, results } of lists) {
36
+ const seenThisProvider = new Set<string>();
37
+ results.forEach((result, index) => {
38
+ const key = canonicalizeUrl(result.url) ?? result.url;
39
+ if (seenThisProvider.has(key)) return; // ignore duplicates within a single provider's own list
40
+ seenThisProvider.add(key);
41
+ const existing = byKey.get(key);
42
+ if (!existing) {
43
+ byKey.set(key, { result: { ...result }, providers: new Set([name]), bestRank: index });
44
+ return;
45
+ }
46
+ existing.providers.add(name);
47
+ existing.bestRank = Math.min(existing.bestRank, index);
48
+ if ((result.title?.length ?? 0) > (existing.result.title?.length ?? 0)) existing.result.title = result.title;
49
+ if ((result.snippet?.length ?? 0) > (existing.result.snippet?.length ?? 0)) existing.result.snippet = result.snippet;
50
+ });
51
+ }
52
+
53
+ return [...byKey.values()]
54
+ .sort((a, b) => b.providers.size - a.providers.size || a.bestRank - b.bestRank)
55
+ .map((entry) => entry.result);
56
+ }
57
+
58
+ function withPresentation(result: WebSearchResponse): WebSearchResponse {
59
+ return { ...result, presentation: buildSearchPresentation(result) };
60
+ }
61
+
62
+ const FANOUT_PROVIDER_TIMEOUT_MS = 8000;
63
+
64
+ /** Resolve to undefined if the provider doesn't answer in time, so one slow/unreachable
65
+ * provider (e.g. a down self-hosted SearXNG) can't stall the whole fanout across passes. */
66
+ function withTimeout(
67
+ promise: Promise<WebSearchResponse>,
68
+ ms: number
69
+ ): Promise<WebSearchResponse | undefined> {
70
+ return new Promise((resolve) => {
71
+ const timer = setTimeout(() => resolve(undefined), ms);
72
+ promise.then(
73
+ (value) => {
74
+ clearTimeout(timer);
75
+ resolve(value);
76
+ },
77
+ () => {
78
+ clearTimeout(timer);
79
+ resolve(undefined);
80
+ }
81
+ );
82
+ });
83
+ }
84
+
85
+ export function createFanoutSearch({
86
+ providers,
87
+ mode,
88
+ timeoutMs = FANOUT_PROVIDER_TIMEOUT_MS
89
+ }: {
90
+ providers: FanoutProvider[];
91
+ mode: Exclude<FanoutMode, 'off'>;
92
+ timeoutMs?: number;
93
+ }) {
94
+ return async function fanoutSearch({ query }: { query: string }): Promise<WebSearchResponse> {
95
+ const [primary, ...rest] = providers;
96
+
97
+ async function runSet(set: FanoutProvider[]) {
98
+ const settled = await Promise.all(set.map((p) => withTimeout(p.search({ query }), timeoutMs)));
99
+ const contributing: Array<{ name: SearchProviderName; results: SearchResult[] }> = [];
100
+ const skipped: SearchProviderName[] = [];
101
+ set.forEach((provider, i) => {
102
+ const value = settled[i];
103
+ if (value && value.status === 'ok' && value.results.length > 0) {
104
+ contributing.push({ name: provider.name, results: value.results });
105
+ } else {
106
+ skipped.push(provider.name);
107
+ }
108
+ });
109
+ return { contributing, skipped };
110
+ }
111
+
112
+ function finalize(
113
+ lists: Array<{ name: SearchProviderName; results: SearchResult[] }>,
114
+ skipped: SearchProviderName[],
115
+ resolvedMode: Exclude<FanoutMode, 'off'>
116
+ ): WebSearchResponse {
117
+ if (lists.length === 0) {
118
+ const fanout: FanoutMetadata = { mode: resolvedMode, providers: [] };
119
+ if (skipped.length > 0) fanout.skipped = skipped;
120
+ return withPresentation({
121
+ status: 'error',
122
+ results: [],
123
+ metadata: { backend: primary.name, cacheHit: false, fanout },
124
+ error: { code: 'FANOUT_NO_RESULTS', message: 'No fanout provider returned usable results.' }
125
+ });
126
+ }
127
+ const fanout: FanoutMetadata = { mode: resolvedMode, providers: lists.map((l) => l.name) };
128
+ if (skipped.length > 0) fanout.skipped = skipped;
129
+ return withPresentation({
130
+ status: 'ok',
131
+ results: merge(lists),
132
+ metadata: { backend: primary.name, cacheHit: false, fanout }
133
+ });
134
+ }
135
+
136
+ if (mode === 'auto') {
137
+ const primaryOutcome = await withTimeout(primary.search({ query }), timeoutMs);
138
+ const primaryResults = primaryOutcome?.status === 'ok' ? primaryOutcome.results : [];
139
+ if (primaryResults.length > 0 && !primaryLooksWeak(primaryResults)) {
140
+ return withPresentation(primaryOutcome as WebSearchResponse); // strong primary: no fanout
141
+ }
142
+ const { contributing, skipped } = await runSet(rest);
143
+ const lists = [
144
+ ...(primaryResults.length ? [{ name: primary.name, results: primaryResults }] : []),
145
+ ...contributing
146
+ ];
147
+ const allSkipped = [...(primaryResults.length ? [] : [primary.name]), ...skipped];
148
+ return finalize(lists, allSkipped, 'auto');
149
+ }
150
+
151
+ const { contributing, skipped } = await runSet(providers);
152
+ return finalize(contributing, skipped, 'on');
153
+ };
154
+ }
@@ -0,0 +1,113 @@
1
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
+ import type { SearchResult, WebSearchResponse } from '../types.js';
3
+
4
+ type TavilySearchResult = {
5
+ title?: unknown;
6
+ url?: unknown;
7
+ content?: unknown;
8
+ };
9
+
10
+ type TavilySearchResponse = {
11
+ results?: TavilySearchResult[];
12
+ };
13
+
14
+ const TAVILY_SEARCH_URL = 'https://api.tavily.com/search';
15
+
16
+ function resultWithPresentation(result: WebSearchResponse): WebSearchResponse {
17
+ return { ...result, presentation: buildSearchPresentation(result) };
18
+ }
19
+
20
+ function normalizeResults(response: TavilySearchResponse): SearchResult[] {
21
+ return (response.results ?? []).flatMap((item) => {
22
+ if (typeof item.title !== 'string' || typeof item.url !== 'string') {
23
+ return [];
24
+ }
25
+
26
+ return [
27
+ {
28
+ title: item.title,
29
+ url: item.url,
30
+ snippet: typeof item.content === 'string' ? item.content : ''
31
+ }
32
+ ];
33
+ });
34
+ }
35
+
36
+ export function createTavilySearchTool({
37
+ apiKey,
38
+ keyless = false,
39
+ fetchImpl = fetch
40
+ }: {
41
+ apiKey?: string;
42
+ keyless?: boolean;
43
+ fetchImpl?: typeof fetch;
44
+ }) {
45
+ return async function tavilySearch({ query }: { query: string }): Promise<WebSearchResponse> {
46
+ const normalizedQuery = query.trim();
47
+
48
+ if (!normalizedQuery) {
49
+ return resultWithPresentation({
50
+ status: 'error',
51
+ results: [],
52
+ metadata: { backend: 'tavily', cacheHit: false },
53
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
54
+ });
55
+ }
56
+
57
+ if (!apiKey?.trim() && !keyless) {
58
+ return resultWithPresentation({
59
+ status: 'error',
60
+ results: [],
61
+ metadata: { backend: 'tavily', cacheHit: false },
62
+ error: {
63
+ code: 'BACKEND_CONFIG_INVALID',
64
+ message: 'Tavily search requires TAVILY_API_KEY.'
65
+ }
66
+ });
67
+ }
68
+
69
+ try {
70
+ const response = await fetchImpl(TAVILY_SEARCH_URL, {
71
+ method: 'POST',
72
+ headers: {
73
+ Accept: 'application/json',
74
+ 'Content-Type': 'application/json',
75
+ ...(apiKey?.trim()
76
+ ? { Authorization: `Bearer ${apiKey}` }
77
+ : { 'X-Tavily-Access-Mode': 'keyless' })
78
+ },
79
+ body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
80
+ });
81
+
82
+ if (!response.ok) {
83
+ throw new Error(`HTTP ${response.status}`);
84
+ }
85
+
86
+ const parsed = (await response.json()) as TavilySearchResponse;
87
+ const results = normalizeResults(parsed);
88
+
89
+ if (results.length === 0) {
90
+ return resultWithPresentation({
91
+ status: 'error',
92
+ results: [],
93
+ metadata: { backend: 'tavily', cacheHit: false },
94
+ error: { code: 'NO_RESULTS', message: 'Tavily returned no usable results for this query.' }
95
+ });
96
+ }
97
+
98
+ return resultWithPresentation({
99
+ status: 'ok',
100
+ results,
101
+ metadata: { backend: 'tavily', cacheHit: false }
102
+ });
103
+ } catch (error) {
104
+ const rawMessage = error instanceof Error ? error.message : String(error);
105
+ return resultWithPresentation({
106
+ status: 'error',
107
+ results: [],
108
+ metadata: { backend: 'tavily', cacheHit: false },
109
+ error: { code: 'FETCH_FAILED', message: `Tavily search request failed: ${rawMessage}` }
110
+ });
111
+ }
112
+ };
113
+ }
@@ -0,0 +1,109 @@
1
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
+ import type { SearchResult, WebSearchResponse } from '../types.js';
3
+
4
+ type YouComSearchResult = {
5
+ title?: unknown;
6
+ url?: unknown;
7
+ snippet?: unknown;
8
+ };
9
+
10
+ type YouComSearchResponse = {
11
+ results?: YouComSearchResult[];
12
+ };
13
+
14
+ const YOUCOM_SEARCH_URL = 'https://api.you.com/v1/agents/search';
15
+
16
+ function resultWithPresentation(result: WebSearchResponse): WebSearchResponse {
17
+ return { ...result, presentation: buildSearchPresentation(result) };
18
+ }
19
+
20
+ function normalizeResults(response: YouComSearchResponse): SearchResult[] {
21
+ return (response.results ?? []).flatMap((item) => {
22
+ if (typeof item.title !== 'string' || typeof item.url !== 'string') {
23
+ return [];
24
+ }
25
+
26
+ return [
27
+ {
28
+ title: item.title,
29
+ url: item.url,
30
+ snippet: typeof item.snippet === 'string' ? item.snippet : ''
31
+ }
32
+ ];
33
+ });
34
+ }
35
+
36
+ export function createYouComSearchTool({
37
+ apiKey,
38
+ fetchImpl = fetch
39
+ }: {
40
+ apiKey?: string;
41
+ fetchImpl?: typeof fetch;
42
+ }) {
43
+ return async function youComSearch({ query }: { query: string }): Promise<WebSearchResponse> {
44
+ const normalizedQuery = query.trim();
45
+
46
+ if (!normalizedQuery) {
47
+ return resultWithPresentation({
48
+ status: 'error',
49
+ results: [],
50
+ metadata: { backend: 'youcom', cacheHit: false },
51
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
52
+ });
53
+ }
54
+
55
+ if (!apiKey?.trim()) {
56
+ return resultWithPresentation({
57
+ status: 'error',
58
+ results: [],
59
+ metadata: { backend: 'youcom', cacheHit: false },
60
+ error: {
61
+ code: 'BACKEND_CONFIG_INVALID',
62
+ message: 'You.com search requires YDC_API_KEY.'
63
+ }
64
+ });
65
+ }
66
+
67
+ try {
68
+ const response = await fetchImpl(YOUCOM_SEARCH_URL, {
69
+ method: 'POST',
70
+ headers: {
71
+ Accept: 'application/json',
72
+ 'Content-Type': 'application/json',
73
+ 'X-API-Key': apiKey
74
+ },
75
+ body: JSON.stringify({ query: normalizedQuery, max_results: 10 })
76
+ });
77
+
78
+ if (!response.ok) {
79
+ throw new Error(`HTTP ${response.status}`);
80
+ }
81
+
82
+ const parsed = (await response.json()) as YouComSearchResponse;
83
+ const results = normalizeResults(parsed);
84
+
85
+ if (results.length === 0) {
86
+ return resultWithPresentation({
87
+ status: 'error',
88
+ results: [],
89
+ metadata: { backend: 'youcom', cacheHit: false },
90
+ error: { code: 'NO_RESULTS', message: 'You.com returned no usable results for this query.' }
91
+ });
92
+ }
93
+
94
+ return resultWithPresentation({
95
+ status: 'ok',
96
+ results,
97
+ metadata: { backend: 'youcom', cacheHit: false }
98
+ });
99
+ } catch (error) {
100
+ const rawMessage = error instanceof Error ? error.message : String(error);
101
+ return resultWithPresentation({
102
+ status: 'error',
103
+ results: [],
104
+ metadata: { backend: 'youcom', cacheHit: false },
105
+ error: { code: 'FETCH_FAILED', message: `You.com search request failed: ${rawMessage}` }
106
+ });
107
+ }
108
+ };
109
+ }
@@ -36,7 +36,11 @@ function htmlLooksBlocked(html: string) {
36
36
  normalized.includes('challenge') ||
37
37
  normalized.includes('verify you are human') ||
38
38
  normalized.includes('are you a robot') ||
39
- normalized.includes('unusual traffic')
39
+ normalized.includes('unusual traffic') ||
40
+ normalized.includes('automated requests') ||
41
+ normalized.includes('automated queries') ||
42
+ normalized.includes('detected unusual') ||
43
+ normalized.includes('too many requests')
40
44
  );
41
45
  }
42
46
 
@@ -80,8 +84,15 @@ export function createWebSearchTool({
80
84
  }
81
85
 
82
86
  try {
83
- const html = await searchHtml(normalizedQuery);
84
- const parsed = parseDuckDuckGoResults(html);
87
+ let html = await searchHtml(normalizedQuery);
88
+ let parsed = parseDuckDuckGoResults(html);
89
+
90
+ // A 200-OK bot-wall reads as a successful fetch, so the fetch-layer retry never sees it.
91
+ // Give a page that looks blocked one more shot here before we classify it.
92
+ if (parsed.results.length === 0 && htmlLooksBlocked(html)) {
93
+ html = await searchHtml(normalizedQuery);
94
+ parsed = parseDuckDuckGoResults(html);
95
+ }
85
96
 
86
97
  if (parsed.results.length > 0) {
87
98
  const result: WebSearchResponse = {
@@ -96,14 +107,16 @@ export function createWebSearchTool({
96
107
  };
97
108
  }
98
109
 
99
- if (parsed.noResults) {
110
+ // Check for a bot-wall before "no results": a page can carry both markers, and BLOCKED is
111
+ // the honest call since it routes to the fallback instead of a dead end.
112
+ if (htmlLooksBlocked(html)) {
100
113
  const result: WebSearchResponse = {
101
114
  status: 'error',
102
115
  results: [],
103
116
  metadata: { backend: 'duckduckgo', cacheHit: false },
104
117
  error: {
105
- code: 'NO_RESULTS',
106
- message: 'DuckDuckGo returned no usable results for this query.'
118
+ code: 'BLOCKED',
119
+ message: 'DuckDuckGo search appears to be blocked or rate limited.'
107
120
  }
108
121
  };
109
122
  return {
@@ -112,14 +125,14 @@ export function createWebSearchTool({
112
125
  };
113
126
  }
114
127
 
115
- if (htmlLooksBlocked(html)) {
128
+ if (parsed.noResults) {
116
129
  const result: WebSearchResponse = {
117
130
  status: 'error',
118
131
  results: [],
119
132
  metadata: { backend: 'duckduckgo', cacheHit: false },
120
133
  error: {
121
- code: 'BLOCKED',
122
- message: 'DuckDuckGo search appears to be blocked or rate limited.'
134
+ code: 'NO_RESULTS',
135
+ message: 'DuckDuckGo returned no usable results for this query.'
123
136
  }
124
137
  };
125
138
  return {