@pal-code/web-search 0.0.6
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/package.json +23 -0
- package/src/fetch.ts +152 -0
- package/src/index.ts +59 -0
- package/src/providers/duckduckgo.ts +80 -0
- package/src/search.ts +201 -0
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pal-code/web-search",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Web search extension for pal — SearXNG, DuckDuckGo, and more",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/**/*.ts"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@pal/core": "0.0.6",
|
|
18
|
+
"cheerio": "^1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.1.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch_url — Read a webpage's content.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies: uses native fetch + regex-based HTML stripping.
|
|
5
|
+
* Respects robots.txt conventions (User-Agent, timeout).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {ToolHandler} from '@pal/core';
|
|
9
|
+
|
|
10
|
+
const MAX_CHARS = 50_000;
|
|
11
|
+
const TIMEOUT_MS = 15_000;
|
|
12
|
+
|
|
13
|
+
const USER_AGENT = 'Mozilla/5.0 (compatible; pal/0.1; +https://github.com/pal)';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strip HTML tags and collapse whitespace, producing rough plain text.
|
|
17
|
+
* Good enough for LLM consumption — not a full parser.
|
|
18
|
+
*/
|
|
19
|
+
function htmlToText(html: string): string {
|
|
20
|
+
// Remove <script>, <style>, and their contents
|
|
21
|
+
let text = html.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
22
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
23
|
+
|
|
24
|
+
// Convert block-level elements to newlines
|
|
25
|
+
text = text.replace(/<(br|hr)\s*\/?>/gi, '\n');
|
|
26
|
+
text = text.replace(/<\/(p|div|h[1-6]|li|tr|blockquote)>/gi, '\n');
|
|
27
|
+
text = text.replace(/<br\s*\/?>/gi, '\n');
|
|
28
|
+
|
|
29
|
+
// Remove all remaining tags
|
|
30
|
+
text = text.replace(/<[^>]+>/g, '');
|
|
31
|
+
|
|
32
|
+
// Decode common HTML entities
|
|
33
|
+
text = text
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'|'/g, "'")
|
|
39
|
+
.replace(/ /g, ' ')
|
|
40
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
|
|
41
|
+
|
|
42
|
+
// Collapse whitespace runs (but preserve single newlines)
|
|
43
|
+
text = text.replace(/[^\S\n]+/g, ' ');
|
|
44
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
45
|
+
|
|
46
|
+
return text.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const fetchUrl: ToolHandler = async (args) => {
|
|
50
|
+
const url = String(args.url);
|
|
51
|
+
const format = (args.format as string) ?? 'markdown';
|
|
52
|
+
|
|
53
|
+
if (!url) return 'Error: url is required';
|
|
54
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
55
|
+
return 'Error: url must start with http:// or https://';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
headers: {'User-Agent': USER_AGENT, Accept: 'text/html,application/xhtml+xml,text/plain'},
|
|
60
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
61
|
+
redirect: 'follow',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
return `Error: HTTP ${res.status} ${res.statusText}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
69
|
+
const body = await res.text();
|
|
70
|
+
|
|
71
|
+
// Plain text response — return as-is
|
|
72
|
+
if (contentType.includes('text/plain') || !contentType.includes('text/html')) {
|
|
73
|
+
return body.length > MAX_CHARS ? body.slice(0, MAX_CHARS) + '\n\n... (truncated)' : body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// HTML — extract text content
|
|
77
|
+
const text = format === 'text' ? htmlToText(body) : htmlToMarkdown(body);
|
|
78
|
+
|
|
79
|
+
if (text.length > MAX_CHARS) {
|
|
80
|
+
return text.slice(0, MAX_CHARS) + '\n\n... (truncated)';
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rough HTML → Markdown conversion for LLM consumption.
|
|
87
|
+
* Preserves headings, lists, links, code blocks, and paragraphs.
|
|
88
|
+
*/
|
|
89
|
+
function htmlToMarkdown(html: string): string {
|
|
90
|
+
let md = html;
|
|
91
|
+
|
|
92
|
+
// Remove script/style
|
|
93
|
+
md = md.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
94
|
+
md = md.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
95
|
+
|
|
96
|
+
// Code blocks
|
|
97
|
+
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
|
|
98
|
+
return '\n```\n' + decodeEntities(code) + '\n```\n';
|
|
99
|
+
});
|
|
100
|
+
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, code) => {
|
|
101
|
+
return '\n```\n' + decodeEntities(code) + '\n```\n';
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Inline code
|
|
105
|
+
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, code) => '`' + decodeEntities(code) + '`');
|
|
106
|
+
|
|
107
|
+
// Headings
|
|
108
|
+
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n');
|
|
109
|
+
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n');
|
|
110
|
+
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n');
|
|
111
|
+
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n');
|
|
112
|
+
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n');
|
|
113
|
+
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n');
|
|
114
|
+
|
|
115
|
+
// Links
|
|
116
|
+
md = md.replace(/<a[^>]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
|
|
117
|
+
|
|
118
|
+
// Bold / italic
|
|
119
|
+
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
|
|
120
|
+
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
|
|
121
|
+
|
|
122
|
+
// List items
|
|
123
|
+
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
|
|
124
|
+
|
|
125
|
+
// Block elements → newlines
|
|
126
|
+
md = md.replace(/<\/?(p|div|blockquote|section|article|main|header|footer|nav)[^>]*>/gi, '\n');
|
|
127
|
+
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
128
|
+
md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
|
|
129
|
+
|
|
130
|
+
// Remove remaining tags
|
|
131
|
+
md = md.replace(/<[^>]+>/g, '');
|
|
132
|
+
|
|
133
|
+
// Decode entities
|
|
134
|
+
md = decodeEntities(md);
|
|
135
|
+
|
|
136
|
+
// Collapse whitespace
|
|
137
|
+
md = md.replace(/[^\S\n]+/g, ' ');
|
|
138
|
+
md = md.replace(/\n{3,}/g, '\n\n');
|
|
139
|
+
|
|
140
|
+
return md.trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function decodeEntities(s: string): string {
|
|
144
|
+
return s
|
|
145
|
+
.replace(/&/g, '&')
|
|
146
|
+
.replace(/</g, '<')
|
|
147
|
+
.replace(/>/g, '>')
|
|
148
|
+
.replace(/"/g, '"')
|
|
149
|
+
.replace(/'|'/g, "'")
|
|
150
|
+
.replace(/ /g, ' ')
|
|
151
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pal-code/web-search — Internet browsing extension for pal.
|
|
3
|
+
*
|
|
4
|
+
* Provides two tools:
|
|
5
|
+
* fetch_url — read a webpage (free, no API key)
|
|
6
|
+
* web_search — search the web (provider: searxng / tavily / brave)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {ToolDef, ToolExtension} from '@pal/core';
|
|
10
|
+
import {fetchUrl} from './fetch.js';
|
|
11
|
+
import {webSearch} from './search.js';
|
|
12
|
+
|
|
13
|
+
const fetchUrlDef: ToolDef = {
|
|
14
|
+
type: 'function',
|
|
15
|
+
function: {
|
|
16
|
+
name: 'fetch_url',
|
|
17
|
+
description: 'Fetch and read the content of a URL. Returns the text content of the page.',
|
|
18
|
+
parameters: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
url: {type: 'string', description: 'The URL to fetch'},
|
|
22
|
+
format: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['text', 'markdown'],
|
|
25
|
+
description: 'Output format (default: markdown)',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ['url'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const webSearchDef: ToolDef = {
|
|
34
|
+
type: 'function',
|
|
35
|
+
function: {
|
|
36
|
+
name: 'web_search',
|
|
37
|
+
description: 'Search the web and return relevant results.',
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
query: {type: 'string', description: 'Search query'},
|
|
42
|
+
max_results: {
|
|
43
|
+
type: 'number',
|
|
44
|
+
description: 'Maximum number of results (default: 5)',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
required: ['query'],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
name: 'web-search',
|
|
54
|
+
tools: [fetchUrlDef, webSearchDef],
|
|
55
|
+
handlers: {
|
|
56
|
+
fetch_url: fetchUrl,
|
|
57
|
+
web_search: webSearch,
|
|
58
|
+
},
|
|
59
|
+
} satisfies ToolExtension;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web search using DuckDuckGo (free, no API key required).
|
|
3
|
+
*
|
|
4
|
+
* Scrapes html.duckduckgo.com/html/ — a static, server-rendered page that
|
|
5
|
+
* requires no JavaScript. Works reliably for agent use cases.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as cheerio from 'cheerio';
|
|
9
|
+
|
|
10
|
+
const UA =
|
|
11
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
12
|
+
|
|
13
|
+
export interface SearchResult {
|
|
14
|
+
title: string;
|
|
15
|
+
url: string;
|
|
16
|
+
snippet: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Search DuckDuckGo and return structured results.
|
|
21
|
+
*/
|
|
22
|
+
export async function searchDuckDuckGo(
|
|
23
|
+
query: string,
|
|
24
|
+
maxResults: number = 5,
|
|
25
|
+
): Promise<SearchResult[]> {
|
|
26
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
27
|
+
|
|
28
|
+
const res = await fetch(url, {
|
|
29
|
+
headers: {
|
|
30
|
+
'User-Agent': UA,
|
|
31
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
32
|
+
},
|
|
33
|
+
signal: AbortSignal.timeout(15_000),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
37
|
+
|
|
38
|
+
const html = await res.text();
|
|
39
|
+
const $ = cheerio.load(html);
|
|
40
|
+
|
|
41
|
+
const results: SearchResult[] = [];
|
|
42
|
+
|
|
43
|
+
$('.result').each((_, el) => {
|
|
44
|
+
if (results.length >= maxResults) return false;
|
|
45
|
+
|
|
46
|
+
const titleEl = $(el).find('.result__a');
|
|
47
|
+
const title = titleEl.text().trim();
|
|
48
|
+
let searchUrl = titleEl.attr('href') || '';
|
|
49
|
+
const snippet = $(el).find('.result__snippet').text().trim();
|
|
50
|
+
|
|
51
|
+
// Unwrap DDG redirect URLs
|
|
52
|
+
if (searchUrl.startsWith('//')) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL('https:' + searchUrl);
|
|
55
|
+
searchUrl = decodeURIComponent(parsed.searchParams.get('uddg') || searchUrl);
|
|
56
|
+
} catch {
|
|
57
|
+
// Keep original URL if parsing fails
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (title && searchUrl) {
|
|
62
|
+
results.push({title, url: searchUrl, snippet});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Search and format results as markdown.
|
|
71
|
+
*/
|
|
72
|
+
export async function searchDdgMarkdown(query: string, maxResults: number = 5): Promise<string> {
|
|
73
|
+
const results = await searchDuckDuckGo(query, maxResults);
|
|
74
|
+
|
|
75
|
+
if (results.length === 0) {
|
|
76
|
+
return 'No results found.';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return results.map((r) => `### ${r.title}\n${r.url}\n${r.snippet}\n`).join('\n');
|
|
80
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web_search — Search the web via pluggable providers.
|
|
3
|
+
*
|
|
4
|
+
* Reads config from ~/.pal/config.json → webSearch section.
|
|
5
|
+
* Falls back to SearXNG on localhost if nothing is configured.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {readFileSync} from 'fs';
|
|
9
|
+
import {join} from 'path';
|
|
10
|
+
import {homedir} from 'os';
|
|
11
|
+
import type {ToolHandler} from '@pal/core';
|
|
12
|
+
import {searchDdgMarkdown} from './providers/duckduckgo.js';
|
|
13
|
+
|
|
14
|
+
export interface WebSearchConfig {
|
|
15
|
+
/** Search provider: 'duckduckgo' | 'searxng' | 'tavily' | 'brave'. Default: 'duckduckgo'. */
|
|
16
|
+
provider?: string;
|
|
17
|
+
/** SearXNG instance URL. Default: 'http://localhost:8080'. */
|
|
18
|
+
searxngUrl?: string;
|
|
19
|
+
/** API key for paid providers (tavily, brave). */
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadWebSearchConfig(): WebSearchConfig {
|
|
24
|
+
try {
|
|
25
|
+
const configPath = join(homedir(), '.pal', 'config.json');
|
|
26
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
return parsed.webSearch ?? {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Provider: SearXNG (free, self-hosted) ──────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface SearxngResult {
|
|
37
|
+
title: string;
|
|
38
|
+
url: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function searchSearxng(
|
|
43
|
+
query: string,
|
|
44
|
+
maxResults: number,
|
|
45
|
+
config: WebSearchConfig,
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const base = config.searxngUrl ?? 'http://localhost:8080';
|
|
48
|
+
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json&categories=general`;
|
|
49
|
+
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
signal: AbortSignal.timeout(10_000),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new Error(`SearXNG returned HTTP ${res.status} — is it running at ${base}?`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const data = (await res.json()) as {results: SearxngResult[]};
|
|
59
|
+
const results = (data.results ?? []).slice(0, maxResults);
|
|
60
|
+
|
|
61
|
+
if (results.length === 0) {
|
|
62
|
+
return `No results found for "${query}".`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return results
|
|
66
|
+
.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.content ?? '(no snippet)'}`)
|
|
67
|
+
.join('\n\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Provider: Tavily (API key required) ────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface TavilyResult {
|
|
73
|
+
title: string;
|
|
74
|
+
url: string;
|
|
75
|
+
content: string;
|
|
76
|
+
score: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface TavilyResponse {
|
|
80
|
+
results: TavilyResult[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function searchTavily(
|
|
84
|
+
query: string,
|
|
85
|
+
maxResults: number,
|
|
86
|
+
config: WebSearchConfig,
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
const apiKey = config.apiKey;
|
|
89
|
+
if (!apiKey) {
|
|
90
|
+
return 'Error: Tavily requires an API key. Set webSearch.apiKey in ~/.pal/config.json';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetch('https://api.tavily.com/search', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {'Content-Type': 'application/json'},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
api_key: apiKey,
|
|
98
|
+
query,
|
|
99
|
+
max_results: maxResults,
|
|
100
|
+
include_answer: true,
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(10_000),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const body = await res.text();
|
|
107
|
+
throw new Error(`Tavily API error ${res.status}: ${body}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = (await res.json()) as TavilyResponse;
|
|
111
|
+
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
if (data.results.length === 0) {
|
|
114
|
+
return `No results found for "${query}".`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
data.results.forEach((r, i) => {
|
|
118
|
+
lines.push(`${i + 1}. **${r.title}**\n ${r.url}\n ${r.content}`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return lines.join('\n\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Provider: Brave Search (API key required) ──────────────────────────────
|
|
125
|
+
|
|
126
|
+
interface BraveResult {
|
|
127
|
+
title: string;
|
|
128
|
+
url: string;
|
|
129
|
+
description: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface BraveResponse {
|
|
133
|
+
web: {results: BraveResult[]};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function searchBrave(
|
|
137
|
+
query: string,
|
|
138
|
+
maxResults: number,
|
|
139
|
+
config: WebSearchConfig,
|
|
140
|
+
): Promise<string> {
|
|
141
|
+
const apiKey = config.apiKey;
|
|
142
|
+
if (!apiKey) {
|
|
143
|
+
return 'Error: Brave Search requires an API key. Set webSearch.apiKey in ~/.pal/config.json';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${maxResults}`;
|
|
147
|
+
|
|
148
|
+
const res = await fetch(url, {
|
|
149
|
+
headers: {
|
|
150
|
+
Accept: 'application/json',
|
|
151
|
+
'X-Subscription-Token': apiKey,
|
|
152
|
+
},
|
|
153
|
+
signal: AbortSignal.timeout(10_000),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const body = await res.text();
|
|
158
|
+
throw new Error(`Brave Search API error ${res.status}: ${body}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = (await res.json()) as BraveResponse;
|
|
162
|
+
const results = data.web?.results ?? [];
|
|
163
|
+
|
|
164
|
+
if (results.length === 0) {
|
|
165
|
+
return `No results found for "${query}".`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return results
|
|
169
|
+
.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.description}`)
|
|
170
|
+
.join('\n\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Main handler ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
export const webSearch: ToolHandler = async (args) => {
|
|
176
|
+
const query = String(args.query ?? '');
|
|
177
|
+
const maxResults = Math.min(Number(args.max_results ?? 5), 20);
|
|
178
|
+
|
|
179
|
+
if (!query) return 'Error: query is required';
|
|
180
|
+
|
|
181
|
+
const config = loadWebSearchConfig();
|
|
182
|
+
const provider = config.provider ?? 'duckduckgo';
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (provider === 'duckduckgo') {
|
|
186
|
+
return await searchDdgMarkdown(query, maxResults);
|
|
187
|
+
}
|
|
188
|
+
if (provider === 'searxng') {
|
|
189
|
+
return await searchSearxng(query, maxResults, config);
|
|
190
|
+
}
|
|
191
|
+
if (provider === 'tavily') {
|
|
192
|
+
return await searchTavily(query, maxResults, config);
|
|
193
|
+
}
|
|
194
|
+
if (provider === 'brave') {
|
|
195
|
+
return await searchBrave(query, maxResults, config);
|
|
196
|
+
}
|
|
197
|
+
return `Error: unknown search provider "${provider}". Supported: duckduckgo, searxng, tavily, brave`;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return `Search error: ${err instanceof Error ? err.message : String(err)}`;
|
|
200
|
+
}
|
|
201
|
+
};
|