@selesai/code 0.13.2 → 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.
- package/CHANGELOG.md +20 -0
- package/dist/defaults/models.json +85 -13
- package/dist/extensions/cost-reconcile.test.ts +200 -4
- package/dist/extensions/cost-reconcile.ts +88 -102
- package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
- package/dist/extensions/pi-intercom/README.md +4 -5
- package/dist/extensions/pi-intercom/config.test.ts +3 -31
- package/dist/extensions/pi-intercom/config.ts +0 -15
- package/dist/extensions/pi-intercom/index.ts +9 -46
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +49 -57
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/reply-tracker.test.ts +20 -0
- package/dist/extensions/pi-intercom/reply-tracker.ts +8 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +27 -0
- package/dist/extensions/pi-subagents/docs/tool-reference.md +4 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +2 -2
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +48 -243
- package/dist/extensions/pi-subagents/skills/council-mode/references/pass-contracts.md +150 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +87 -37
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +49 -8
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +34 -27
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/review-and-validation.md +73 -0
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +157 -28
- package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -0
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +1 -0
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -0
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +4 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +45 -2
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +3 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +13 -2
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +10 -2
- package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +11 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +98 -1
- package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +138 -4
- package/dist/extensions/pi-subagents/src/runs/shared/background-process-options.ts +9 -0
- package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
- package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +52 -3
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +47 -1
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +45 -18
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +21 -2
- package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +15 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +34 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +11 -3
- package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +31 -0
- package/dist/extensions/pi-subagents/src/tui/render.ts +597 -112
- package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +40 -1
- package/dist/extensions/pi-subagents/src/workflows/host-command.ts +6 -1
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +53 -2
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +55 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +111 -1
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +206 -38
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +522 -31
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +123 -0
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +48 -0
- package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +57 -1
- package/dist/extensions/pi-subagents/test/unit/background-process-options.test.ts +17 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +44 -4
- package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +91 -0
- package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +0 -1
- package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +20 -3
- package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +27 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +93 -17
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +1 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +103 -26
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +58 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +22 -2
- package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +13 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +54 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +2 -0
- package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +32 -0
- package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +48 -1
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +21 -6
- package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +11 -0
- package/dist/extensions/pi-web-agent/CHANGELOG.md +410 -0
- package/dist/extensions/pi-web-agent/README.md +131 -0
- package/dist/extensions/pi-web-agent/package.json +4 -2
- package/dist/extensions/pi-web-agent/src/backends/config.ts +62 -5
- package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
- package/dist/extensions/pi-web-agent/src/backends/factory.ts +96 -6
- package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +183 -47
- package/dist/extensions/pi-web-agent/src/extension.ts +62 -25
- package/dist/extensions/pi-web-agent/src/extract/readability.ts +19 -11
- package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +5 -4
- package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +2 -25
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +5 -2
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +2 -0
- package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +72 -7
- package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +8 -1
- package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +28 -3
- package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +4 -0
- package/dist/extensions/pi-web-agent/src/orchestration/url.ts +35 -0
- package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +18 -6
- package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +14 -2
- package/dist/extensions/pi-web-agent/src/readers/github-reader.ts +150 -0
- package/dist/extensions/pi-web-agent/src/readers/limits.ts +3 -0
- package/dist/extensions/pi-web-agent/src/readers/pdf-reader.ts +87 -0
- package/dist/extensions/pi-web-agent/src/readers/resolver.ts +25 -0
- package/dist/extensions/pi-web-agent/src/readers/types.ts +11 -0
- package/dist/extensions/pi-web-agent/src/readers/youtube-reader.ts +79 -0
- package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +32 -5
- package/dist/extensions/pi-web-agent/src/search/exa.ts +109 -0
- package/dist/extensions/pi-web-agent/src/search/fanout.ts +154 -0
- package/dist/extensions/pi-web-agent/src/search/tavily.ts +113 -0
- package/dist/extensions/pi-web-agent/src/search/youcom.ts +109 -0
- package/dist/extensions/pi-web-agent/src/tools/web-search.ts +22 -9
- package/dist/extensions/pi-web-agent/src/types.ts +19 -4
- package/dist/extensions/tokenin-onboarding.ts +302 -0
- 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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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: '
|
|
106
|
-
message: 'DuckDuckGo
|
|
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 (
|
|
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: '
|
|
122
|
-
message: 'DuckDuckGo
|
|
134
|
+
code: 'NO_RESULTS',
|
|
135
|
+
message: 'DuckDuckGo returned no usable results for this query.'
|
|
123
136
|
}
|
|
124
137
|
};
|
|
125
138
|
return {
|