@xl0/pi-lovely-web 0.1.2

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.
@@ -0,0 +1,103 @@
1
+ import { requestJson } from "./http.js"
2
+ import type { Provider, SearchResult } from "./types.js"
3
+
4
+ const BASE_URL = "https://api.search.brave.com/res/v1"
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+
7
+ interface BraveWebResult {
8
+ title: string
9
+ url: string
10
+ description: string
11
+ }
12
+
13
+ interface BraveWebResponse {
14
+ web?: { results?: BraveWebResult[] }
15
+ }
16
+
17
+ interface BraveNewsResult {
18
+ title: string
19
+ url: string
20
+ description: string
21
+ }
22
+
23
+ interface BraveNewsResponse {
24
+ results?: BraveNewsResult[]
25
+ }
26
+
27
+ interface BraveImageResult {
28
+ url: string
29
+ title?: string
30
+ description?: string
31
+ }
32
+
33
+ interface BraveImageResponse {
34
+ results?: BraveImageResult[]
35
+ }
36
+
37
+ function stripHtmlTags(text: string): string {
38
+ return text.replace(/<[^>]+>/g, "")
39
+ }
40
+
41
+ function fetchJson(url: string, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
42
+ return requestJson(
43
+ "Brave",
44
+ url,
45
+ {
46
+ headers: {
47
+ "X-Subscription-Token": apiKey,
48
+ Accept: "application/json"
49
+ }
50
+ },
51
+ timeout,
52
+ signal
53
+ )
54
+ }
55
+
56
+ function buildSearchUrl(source: string | undefined, query: string, count: number): string {
57
+ const encodedQuery = encodeURIComponent(query)
58
+ if (source === "news") {
59
+ return `${BASE_URL}/news/search?q=${encodedQuery}&count=${count}`
60
+ }
61
+ if (source === "images") {
62
+ return `${BASE_URL}/images/search?q=${encodedQuery}&count=${count}`
63
+ }
64
+ return `${BASE_URL}/web/search?q=${encodedQuery}&count=${count}`
65
+ }
66
+
67
+ export const braveProvider: Provider = {
68
+ id: "brave",
69
+ label: "Brave Search",
70
+ envApiKey: "BRAVE_API_KEY",
71
+
72
+ async search(apiKey, query, opts, signal) {
73
+ const url = buildSearchUrl(opts.source, query, opts.limit)
74
+ const raw = await fetchJson(url, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
75
+
76
+ let items: SearchResult[] = []
77
+
78
+ if (opts.source === "news") {
79
+ const data = raw as BraveNewsResponse
80
+ items = (data.results ?? []).map(item => ({
81
+ title: item.title || "Untitled",
82
+ url: item.url,
83
+ description: stripHtmlTags(item.description || "")
84
+ }))
85
+ } else if (opts.source === "images") {
86
+ const data = raw as BraveImageResponse
87
+ items = (data.results ?? []).map(item => ({
88
+ title: item.title || item.url,
89
+ url: item.url,
90
+ description: item.description || ""
91
+ }))
92
+ } else {
93
+ const data = raw as BraveWebResponse
94
+ items = (data.web?.results ?? []).map(item => ({
95
+ title: item.title || "Untitled",
96
+ url: item.url,
97
+ description: stripHtmlTags(item.description || "")
98
+ }))
99
+ }
100
+
101
+ return { results: items, raw }
102
+ }
103
+ }
@@ -0,0 +1,112 @@
1
+ import { requestJson } from "./http.js"
2
+ import type { Provider, SearchResult } from "./types.js"
3
+
4
+ const BASE_URL = "https://api.exa.ai"
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+
7
+ interface SearchBody {
8
+ query: string
9
+ numResults: number
10
+ type: string
11
+ contents: { summary: boolean }
12
+ category?: string
13
+ }
14
+
15
+ interface ContentsBody {
16
+ ids: string[]
17
+ text: boolean
18
+ }
19
+
20
+ interface ExaSearchResult {
21
+ title: string
22
+ url: string
23
+ summary?: string
24
+ }
25
+
26
+ interface ExaContentsResult {
27
+ id: string
28
+ url: string
29
+ title?: string
30
+ text?: string
31
+ }
32
+
33
+ function sourceToCategory(source?: string): string | undefined {
34
+ if (source === "news") return "news"
35
+ return undefined
36
+ }
37
+
38
+ function stripSummaryLabel(text: string): string {
39
+ return text.replace(/^Summary:\s*/i, "")
40
+ }
41
+
42
+ function fetchJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
43
+ return requestJson(
44
+ "Exa",
45
+ url,
46
+ {
47
+ method: "POST",
48
+ headers: {
49
+ "x-api-key": apiKey,
50
+ "Content-Type": "application/json"
51
+ },
52
+ body: JSON.stringify(body)
53
+ },
54
+ timeout,
55
+ signal
56
+ )
57
+ }
58
+
59
+ export const exaProvider: Provider = {
60
+ id: "exa",
61
+ label: "Exa",
62
+ envApiKey: "EXA_API_KEY",
63
+
64
+ async search(apiKey, query, opts, signal) {
65
+ const body: SearchBody = {
66
+ query,
67
+ numResults: opts.limit,
68
+ type: "auto",
69
+ contents: { summary: true }
70
+ }
71
+ const category = sourceToCategory(opts.source)
72
+ if (category) body.category = category
73
+
74
+ const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
75
+ const data = (raw as { results?: ExaSearchResult[] }).results ?? []
76
+
77
+ const results: SearchResult[] = data.map(item => {
78
+ const r: SearchResult = {
79
+ title: item.title || "Untitled",
80
+ url: item.url
81
+ }
82
+ if (item.summary) r.description = stripSummaryLabel(item.summary)
83
+ return r
84
+ })
85
+
86
+ return { results, raw }
87
+ },
88
+
89
+ async fetch(apiKey, url, opts, signal) {
90
+ const body: ContentsBody = {
91
+ ids: [url],
92
+ text: true
93
+ }
94
+
95
+ const raw = await fetchJson(`${BASE_URL}/contents`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
96
+ const results = (raw as { results?: ExaContentsResult[] }).results
97
+ const first = results?.[0]
98
+
99
+ if (!first?.text) {
100
+ const statuses = (raw as { statuses?: Array<{ id: string; status: string; error?: { tag: string } }> }).statuses
101
+ const status = statuses?.find(s => s.id === url)
102
+ const detail = status?.error?.tag ?? "no content"
103
+ throw new Error(`Exa could not fetch content for ${url}: ${detail}`)
104
+ }
105
+
106
+ return {
107
+ markdown: first.text.trim(),
108
+ metadata: first.title ? { title: first.title } : undefined,
109
+ raw
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,91 @@
1
+ import { requestJson } from "./http.js"
2
+ import type { Provider, SearchResult } from "./types.js"
3
+
4
+ const BASE_URL = "https://api.firecrawl.dev/v1"
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+
7
+ interface SearchBody {
8
+ query: string
9
+ limit: number
10
+ sources?: string[]
11
+ }
12
+
13
+ interface ScrapeBody {
14
+ url: string
15
+ formats: string[]
16
+ onlyMainContent: boolean
17
+ waitFor?: number
18
+ }
19
+
20
+ interface FirecrawlResponse {
21
+ success: boolean
22
+ data: unknown
23
+ error?: string
24
+ }
25
+
26
+ function throwIfError(response: unknown): asserts response is FirecrawlResponse {
27
+ if (!response || typeof response !== "object" || !("success" in response)) {
28
+ throw new Error("Unexpected Firecrawl response")
29
+ }
30
+ const r = response as FirecrawlResponse
31
+ if (r.success !== true) throw new Error(r.error || "Firecrawl request failed")
32
+ }
33
+
34
+ function fetchJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
35
+ return requestJson(
36
+ "Firecrawl",
37
+ url,
38
+ {
39
+ method: "POST",
40
+ headers: {
41
+ Authorization: `Bearer ${apiKey}`,
42
+ "Content-Type": "application/json"
43
+ },
44
+ body: JSON.stringify(body)
45
+ },
46
+ timeout,
47
+ signal
48
+ )
49
+ }
50
+
51
+ export const firecrawlProvider: Provider = {
52
+ id: "firecrawl",
53
+ label: "Firecrawl",
54
+ envApiKey: "FIRECRAWL_API_KEY",
55
+
56
+ async search(apiKey, query, opts, signal) {
57
+ const body: SearchBody = { query, limit: opts.limit }
58
+ if (opts.source) body.sources = [opts.source]
59
+
60
+ const raw = await fetchJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
61
+ throwIfError(raw)
62
+ const data = raw.data as Array<{ title: string; url: string; description: string }>
63
+
64
+ const results: SearchResult[] = data.map(item => {
65
+ const r: SearchResult = { title: item.title, url: item.url }
66
+ if (item.description) r.description = item.description
67
+ return r
68
+ })
69
+
70
+ return { results, raw }
71
+ },
72
+
73
+ async fetch(apiKey, url, opts, signal) {
74
+ const body: ScrapeBody = {
75
+ url,
76
+ formats: ["markdown"],
77
+ onlyMainContent: true
78
+ }
79
+ if (opts.waitFor !== undefined) body.waitFor = opts.waitFor
80
+
81
+ const raw = await fetchJson(`${BASE_URL}/scrape`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
82
+ throwIfError(raw)
83
+ const data = raw.data as { markdown: string; metadata?: unknown }
84
+
85
+ return {
86
+ markdown: data.markdown?.trim() || "No markdown content returned.",
87
+ metadata: data.metadata,
88
+ raw
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,21 @@
1
+ export async function requestJson(label: string, url: string, init: RequestInit, timeout: number, signal?: AbortSignal): Promise<unknown> {
2
+ const controller = new AbortController()
3
+ const timer = setTimeout(() => controller.abort(), timeout)
4
+ const abort = () => controller.abort()
5
+ if (signal?.aborted) controller.abort()
6
+ else signal?.addEventListener("abort", abort, { once: true })
7
+
8
+ try {
9
+ const res = await fetch(url, { ...init, signal: controller.signal })
10
+
11
+ if (!res.ok) {
12
+ const text = await res.text()
13
+ throw new Error(`${label} request failed (${res.status}): ${text}`)
14
+ }
15
+
16
+ return res.json()
17
+ } finally {
18
+ clearTimeout(timer)
19
+ signal?.removeEventListener("abort", abort)
20
+ }
21
+ }
@@ -0,0 +1,131 @@
1
+ import { requestJson } from "./http.js"
2
+ import type { Provider, SearchResult } from "./types.js"
3
+
4
+ const BASE_URL = "https://api.tavily.com"
5
+ const DEFAULT_TIMEOUT_MS = 30_000
6
+
7
+ interface SearchBody {
8
+ query: string
9
+ max_results: number
10
+ search_depth: string
11
+ topic?: string
12
+ }
13
+
14
+ interface ExtractBody {
15
+ urls: string[]
16
+ extract_depth: string
17
+ format: string
18
+ }
19
+
20
+ interface TavilySearchResult {
21
+ title: string
22
+ url: string
23
+ content: string
24
+ raw_content?: string
25
+ score: number
26
+ }
27
+
28
+ interface TavilySearchResponse {
29
+ query: string
30
+ answer?: string
31
+ results?: TavilySearchResult[]
32
+ images?: unknown[]
33
+ response_time: number
34
+ }
35
+
36
+ interface TavilyExtractResult {
37
+ url: string
38
+ raw_content: string
39
+ images?: unknown[]
40
+ favicon?: string
41
+ }
42
+
43
+ interface TavilyExtractResponse {
44
+ results?: TavilyExtractResult[]
45
+ failed_results?: Array<{ url: string; error: string }>
46
+ response_time: number
47
+ }
48
+
49
+ function sourceToTopic(source?: string): string | undefined {
50
+ if (source === "news") return "news"
51
+ return undefined
52
+ }
53
+
54
+ function postJson(url: string, body: unknown, apiKey: string, timeout: number, signal?: AbortSignal): Promise<unknown> {
55
+ return requestJson(
56
+ "Tavily",
57
+ url,
58
+ {
59
+ method: "POST",
60
+ headers: {
61
+ Authorization: `Bearer ${apiKey}`,
62
+ "Content-Type": "application/json"
63
+ },
64
+ body: JSON.stringify(body)
65
+ },
66
+ timeout,
67
+ signal
68
+ )
69
+ }
70
+
71
+ export const tavilyProvider: Provider = {
72
+ id: "tavily",
73
+ label: "Tavily",
74
+ envApiKey: "TAVILY_API_KEY",
75
+
76
+ async search(apiKey, query, opts, signal) {
77
+ const body: SearchBody = {
78
+ query,
79
+ max_results: opts.limit,
80
+ search_depth: "basic"
81
+ }
82
+ const topic = sourceToTopic(opts.source)
83
+ if (topic) body.topic = topic
84
+
85
+ const raw = await postJson(`${BASE_URL}/search`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
86
+ const data = raw as TavilySearchResponse
87
+ const items = data.results ?? []
88
+
89
+ const results: SearchResult[] = items.map(item => {
90
+ const r: SearchResult = {
91
+ title: item.title || "Untitled",
92
+ url: item.url
93
+ }
94
+ if (item.content) r.description = item.content
95
+ return r
96
+ })
97
+
98
+ return { results, raw }
99
+ },
100
+
101
+ async fetch(apiKey, url, opts, signal) {
102
+ const body: ExtractBody = {
103
+ urls: [url],
104
+ extract_depth: "basic",
105
+ format: "markdown"
106
+ }
107
+
108
+ const raw = await postJson(`${BASE_URL}/extract`, body, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
109
+ const data = raw as TavilyExtractResponse
110
+
111
+ const failed = data.failed_results?.find(f => f.url === url)
112
+ if (failed) {
113
+ throw new Error(`Tavily could not extract ${url}: ${failed.error}`)
114
+ }
115
+
116
+ const result = data.results?.find(r => r.url === url)
117
+ if (!result?.raw_content) {
118
+ throw new Error(`Tavily returned no content for ${url}`)
119
+ }
120
+
121
+ const metadata: { images?: unknown[]; favicon?: string } = {}
122
+ if (result.images?.length) metadata.images = result.images
123
+ if (result.favicon) metadata.favicon = result.favicon
124
+
125
+ return {
126
+ markdown: result.raw_content.trim(),
127
+ metadata: Object.keys(metadata).length ? metadata : undefined,
128
+ raw
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,31 @@
1
+ export interface SearchResult {
2
+ title: string
3
+ url: string
4
+ description?: string
5
+ markdown?: string // populated for first result when fetchResult=true
6
+ }
7
+
8
+ export interface Provider {
9
+ readonly id: string
10
+ readonly label: string
11
+ readonly envApiKey: string
12
+ search(
13
+ apiKey: string,
14
+ query: string,
15
+ opts: { limit: number; source?: string; timeout?: number },
16
+ signal?: AbortSignal
17
+ ): Promise<{ results: SearchResult[]; raw: unknown }>
18
+ fetch?(
19
+ apiKey: string,
20
+ url: string,
21
+ opts: { waitFor?: number; timeout?: number },
22
+ signal?: AbortSignal
23
+ ): Promise<{ markdown: string; metadata?: unknown; raw: unknown }>
24
+ }
25
+
26
+ export interface WebToolsConfig {
27
+ webSearch?: { provider?: string; enabled?: boolean }
28
+ webFetch?: { provider?: string; enabled?: boolean }
29
+ webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
30
+ webApiKeys?: Record<string, string>
31
+ }
@@ -0,0 +1,23 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent"
2
+ import { Text } from "@earendil-works/pi-tui"
3
+
4
+ const COLLAPSED_RESULT_LINES = 6
5
+
6
+ export function renderTextResult(
7
+ result: { content: Array<{ type: string; text?: string }> },
8
+ expanded: boolean,
9
+ theme: Theme,
10
+ partialLabel: string
11
+ ) {
12
+ const content = result.content[0]
13
+ if (content?.type !== "text" || content.text === undefined) return new Text(theme.fg("error", "No text output"), 0, 0)
14
+ if (!content.text.trim()) return new Text(theme.fg("dim", partialLabel), 0, 0)
15
+
16
+ const lines = content.text.split("\n")
17
+ const shown = expanded ? lines : lines.slice(0, COLLAPSED_RESULT_LINES)
18
+ let text = shown.map(line => theme.fg("toolOutput", line)).join("\n")
19
+ if (!expanded && lines.length > COLLAPSED_RESULT_LINES) {
20
+ text += `\n${theme.fg("muted", `... ${lines.length - COLLAPSED_RESULT_LINES} more lines (ctrl-o to expand)`)}`
21
+ }
22
+ return new Text(text, 0, 0)
23
+ }
@@ -0,0 +1,95 @@
1
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"
2
+ import { DEFAULT_TIMEOUT_MS, getProvider, resolveApiKey } from "./config.js"
3
+ import { asErrorMessage, formatSearchOutput, stringify } from "./format.js"
4
+ import type { WebToolsConfig } from "./providers/types.js"
5
+
6
+ export interface ToolResult {
7
+ content: Array<TextContent | ImageContent>
8
+ details: unknown
9
+ isError?: boolean
10
+ }
11
+
12
+ export async function searchImpl(
13
+ config: WebToolsConfig,
14
+ params: { query: string; limit?: number; source?: string; fetchResult?: boolean },
15
+ signal?: AbortSignal,
16
+ onUpdate?: (result: ToolResult) => void
17
+ ): Promise<ToolResult> {
18
+ const searchProvider = getProvider("search", config)
19
+ const apiKey = resolveApiKey(searchProvider, config)
20
+
21
+ const searchResult = await searchProvider.search(
22
+ apiKey,
23
+ params.query,
24
+ {
25
+ limit: params.limit ?? 5,
26
+ timeout: DEFAULT_TIMEOUT_MS,
27
+ ...(params.source !== undefined ? { source: params.source } : {})
28
+ },
29
+ signal
30
+ )
31
+
32
+ if (signal?.aborted) throw new Error("Search cancelled")
33
+
34
+ const shouldFetch = params.fetchResult ?? true
35
+ const first = searchResult.results[0]
36
+ if (shouldFetch && first?.url) {
37
+ onUpdate?.({
38
+ content: [{ type: "text" as const, text: `Fetching first result: ${first.url}` }],
39
+ details: undefined as unknown
40
+ })
41
+ try {
42
+ const fetchProvider = getProvider("fetch", config)
43
+ const fetchApiKey = resolveApiKey(fetchProvider, config)
44
+ const fetched = await fetchProvider.fetch(fetchApiKey, first.url, { timeout: DEFAULT_TIMEOUT_MS }, signal)
45
+
46
+ if (signal?.aborted) throw new Error("Search cancelled")
47
+ first.markdown = fetched.markdown
48
+ if (fetched.metadata) (first as { metadata?: unknown }).metadata = fetched.metadata
49
+ } catch (err) {
50
+ first.description = first.description || `[Fetch failed: ${asErrorMessage(err)}]`
51
+ }
52
+ }
53
+
54
+ const result: ToolResult = {
55
+ content: [{ type: "text" as const, text: formatSearchOutput(searchResult.results) }],
56
+ details: searchResult.raw
57
+ }
58
+ onUpdate?.(result)
59
+ return result
60
+ }
61
+
62
+ export async function fetchImpl(
63
+ config: WebToolsConfig,
64
+ params: { url: string; waitFor?: number; timeout?: number; includeMetadata?: boolean },
65
+ signal?: AbortSignal,
66
+ onUpdate?: (result: ToolResult) => void
67
+ ): Promise<ToolResult> {
68
+ const fetchProvider = getProvider("fetch", config)
69
+ const apiKey = resolveApiKey(fetchProvider, config)
70
+
71
+ const result = await fetchProvider.fetch(
72
+ apiKey,
73
+ params.url,
74
+ {
75
+ timeout: params.timeout ?? DEFAULT_TIMEOUT_MS,
76
+ ...(params.waitFor !== undefined ? { waitFor: params.waitFor } : {})
77
+ },
78
+ signal
79
+ )
80
+
81
+ if (signal?.aborted) throw new Error("Fetch cancelled")
82
+
83
+ const warning =
84
+ ["exa", "tavily"].includes(fetchProvider.id) && params.waitFor !== undefined
85
+ ? `Warning: ${fetchProvider.label} ignores waitFor; request sent without any extra page-load delay.\n\n`
86
+ : ""
87
+ const metadata = params.includeMetadata && result.metadata ? `\n\nMetadata:\n${stringify(result.metadata)}` : ""
88
+
89
+ const toolResult: ToolResult = {
90
+ content: [{ type: "text" as const, text: `${warning}${result.markdown}${metadata}` }],
91
+ details: result.raw
92
+ }
93
+ onUpdate?.(toolResult)
94
+ return toolResult
95
+ }