@xl0/pi-lovely-web 0.1.4 → 0.2.1

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.
@@ -1,14 +1,107 @@
1
- import { StringEnum } from "@earendil-works/pi-ai"
2
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
3
2
  import { Text } from "@earendil-works/pi-tui"
4
- import { Type } from "typebox"
5
- import { getImageMaxSize, getProvider, isImageEnabled, isImageResizeEnabled, loadConfig } from "./config.js"
6
- import { asErrorMessage } from "./format.js"
3
+ import { type TSchema, Type } from "typebox"
4
+ import {
5
+ DEFAULT_PROVIDER_ID,
6
+ getImageMaxSize,
7
+ getProvider,
8
+ isFetchEnabled,
9
+ isImageEnabled,
10
+ isImageResizeEnabled,
11
+ loadConfig,
12
+ lovelyWebConfigSpec,
13
+ providers,
14
+ resolveApiKey,
15
+ type WebToolsConfig
16
+ } from "./config.js"
17
+ import { DEFAULT_TIMEOUT_MS } from "./constants.js"
18
+ import { asErrorMessage, formatSearchOutput, stringify } from "./format.js"
7
19
  import { DEFAULT_MAX_IMAGE_BYTES, imageImpl, MAX_IMAGE_BYTES } from "./image.js"
20
+ import type { FetchOptions, SearchOptions } from "./providers/types.js"
8
21
  import { renderTextResult } from "./render.js"
9
- import { fetchImpl, searchImpl } from "./tool-impl.js"
22
+ import type { ToolResult } from "./types.js"
10
23
 
11
- export function registerLovelyWebTools(pi: ExtensionAPI) {
24
+ interface SearchToolArgs {
25
+ query: string
26
+ limit?: number
27
+ source?: string
28
+ fetchResult?: boolean
29
+ category?: string
30
+ location?: string
31
+ country?: string
32
+ tbs?: string
33
+ timeRange?: string
34
+ topic?: string
35
+ includeImages?: boolean
36
+ searchLang?: string
37
+ freshness?: string
38
+ }
39
+
40
+ interface FetchToolArgs extends FetchOptions {
41
+ url: string
42
+ includeMetadata?: boolean
43
+ }
44
+
45
+ async function fetchSearchResultImage(config: WebToolsConfig, url: string, signal?: AbortSignal): Promise<ToolResult> {
46
+ return imageImpl(
47
+ {
48
+ url,
49
+ timeout: DEFAULT_TIMEOUT_MS,
50
+ resize: isImageResizeEnabled(config),
51
+ maxSize: getImageMaxSize(config)
52
+ },
53
+ signal
54
+ )
55
+ }
56
+
57
+ async function fetchSearchResultMarkdown(config: WebToolsConfig, url: string, signal?: AbortSignal): Promise<string> {
58
+ const fetchProvider = getProvider("fetch", config)
59
+ const fetchApiKey = resolveApiKey(fetchProvider, config)
60
+ const fetched = await fetchProvider.fetch(fetchApiKey, url, { timeout: DEFAULT_TIMEOUT_MS }, signal)
61
+ return fetched.markdown
62
+ }
63
+
64
+ function getSearchParameters(config: WebToolsConfig) {
65
+ const configured = config.webSearchProvider
66
+ const providerId = configured && providers[configured] ? configured : DEFAULT_PROVIDER_ID
67
+ const params: Record<string, TSchema> = {
68
+ query: Type.String({ description: "The search query." }),
69
+ limit: Type.Optional(
70
+ Type.Integer({
71
+ description: "Maximum number of results to return. Defaults to 5.",
72
+ minimum: 1,
73
+ maximum: 20
74
+ })
75
+ ),
76
+ fetchResult: Type.Optional(
77
+ Type.Boolean({
78
+ description: "Whether to fetch the first result. Defaults to false; image searches fetch image content when enabled."
79
+ })
80
+ )
81
+ }
82
+
83
+ Object.assign(params, providers[providerId]?.searchParameters)
84
+ return Type.Object(params)
85
+ }
86
+
87
+ function getFetchParameters(config: WebToolsConfig) {
88
+ const configured = config.webFetchProvider
89
+ const providerId = configured && providers[configured]?.fetch ? configured : "firecrawl"
90
+ const params: Record<string, TSchema> = {
91
+ url: Type.String({ description: "The URL to fetch.", format: "uri" }),
92
+ timeout: Type.Optional(Type.Integer({ description: "Request timeout in milliseconds. Defaults to 30000.", minimum: 1 })),
93
+ includeMetadata: Type.Optional(
94
+ Type.Boolean({
95
+ description: "Append verbose page metadata to the markdown output. Defaults to false. Full metadata is always available in details."
96
+ })
97
+ )
98
+ }
99
+
100
+ Object.assign(params, providers[providerId]?.fetchParameters)
101
+ return Type.Object(params)
102
+ }
103
+
104
+ export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
12
105
  pi.registerTool({
13
106
  name: "web_search",
14
107
  label: "Web Search",
@@ -18,28 +111,15 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
18
111
  "Use web_search when the user asks for current web information, discovery, or sources beyond the local workspace.",
19
112
  "Use web_fetch after web_search when you need the full content of a specific page."
20
113
  ],
21
- parameters: Type.Object({
22
- query: Type.String({ description: "The web search query." }),
23
- limit: Type.Optional(
24
- Type.Integer({
25
- description: "Maximum number of results to return. Defaults to 5.",
26
- minimum: 1,
27
- maximum: 20
28
- })
29
- ),
30
- source: Type.Optional(StringEnum(["web", "news", "images"] as const)),
31
- fetchResult: Type.Optional(
32
- Type.Boolean({
33
- description: "Whether to fetch the first result and include markdown. Defaults to true."
34
- })
35
- )
36
- }),
114
+ parameters: getSearchParameters(config),
37
115
  renderCall(args, theme, context) {
38
116
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0)
39
- const bits = [args.source ?? "web", `limit ${args.limit ?? 5}`]
40
- if (args.fetchResult ?? true) bits.push("fetch first")
117
+ const input = args as unknown as SearchToolArgs
118
+ const mode = input.includeImages ? "images" : (input.source ?? input.topic ?? input.category ?? "web")
119
+ const bits = [mode, `limit ${input.limit ?? 5}`]
120
+ if (input.fetchResult === true) bits.push("fetch first")
41
121
  text.setText(
42
- `${theme.fg("toolTitle", theme.bold("web_search "))}${theme.fg("muted", `"${args.query}"`)} ${theme.fg("dim", `(${bits.join(", ")})`)}`
122
+ `${theme.fg("toolTitle", theme.bold("web_search "))}${theme.fg("muted", `"${input.query}"`)} ${theme.fg("dim", `(${bits.join(", ")})`)}`
43
123
  )
44
124
  return text
45
125
  },
@@ -50,11 +130,45 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
50
130
  try {
51
131
  const config = loadConfig(ctx.cwd)
52
132
  const searchProvider = getProvider("search", config)
133
+ const input = params as unknown as SearchToolArgs
53
134
  onUpdate?.({
54
- content: [{ type: "text", text: `Searching web with ${searchProvider.label} for: ${params.query}` }],
55
- details: undefined as unknown
135
+ content: [{ type: "text", text: `Searching web with ${searchProvider.label} for: ${input.query}` }],
136
+ details: undefined
56
137
  })
57
- return await searchImpl(config, params, signal, onUpdate)
138
+
139
+ const { query, fetchResult, limit, ...providerOptions } = input
140
+ const searchOptions: SearchOptions = { ...providerOptions, limit: limit ?? 5, timeout: DEFAULT_TIMEOUT_MS }
141
+ const searchResult = await searchProvider.search(resolveApiKey(searchProvider, config), query, searchOptions, signal)
142
+ if (signal?.aborted) throw new Error("Search cancelled")
143
+
144
+ const first = searchResult.results[0]
145
+ let fetchedImage: ToolResult | undefined
146
+ if (fetchResult === true && first?.url) {
147
+ onUpdate?.({ content: [{ type: "text", text: `Fetching first result: ${first.url}` }], details: undefined })
148
+ try {
149
+ const isImageSearch = input.source === "images" || input.includeImages === true
150
+ if (isImageSearch) {
151
+ try {
152
+ fetchedImage = await fetchSearchResultImage(config, first.url, signal)
153
+ } catch {
154
+ if (signal?.aborted) throw new Error("Search cancelled")
155
+ // Some image-search providers return source pages instead of direct image URLs.
156
+ }
157
+ }
158
+ if (!fetchedImage && isFetchEnabled(config)) first.markdown = await fetchSearchResultMarkdown(config, first.url, signal)
159
+ if (signal?.aborted) throw new Error("Search cancelled")
160
+ } catch (err) {
161
+ if (signal?.aborted) throw err
162
+ first.description = first.description || `[Fetch failed: ${asErrorMessage(err)}]`
163
+ }
164
+ }
165
+
166
+ const result: ToolResult = {
167
+ content: [{ type: "text", text: formatSearchOutput(searchResult.results) }, ...(fetchedImage?.content || [])],
168
+ details: fetchedImage ? { search: searchResult.raw, image: fetchedImage.details } : searchResult.raw
169
+ }
170
+ onUpdate?.(result)
171
+ return result
58
172
  } catch (error) {
59
173
  return {
60
174
  content: [{ type: "text", text: `Web search failed: ${asErrorMessage(error)}` }],
@@ -64,7 +178,9 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
64
178
  }
65
179
  }
66
180
  })
181
+ }
67
182
 
183
+ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
68
184
  pi.registerTool({
69
185
  name: "web_fetch",
70
186
  label: "Web Fetch",
@@ -74,30 +190,18 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
74
190
  "Use web_fetch when you need the full readable markdown content of a known URL.",
75
191
  "Prefer web_fetch over bash/curl for web pages because web_fetch returns cleaned markdown suitable for agent context."
76
192
  ],
77
- parameters: Type.Object({
78
- url: Type.String({ description: "The URL to fetch.", format: "uri" }),
79
- waitFor: Type.Optional(
80
- Type.Integer({
81
- description: "Milliseconds to wait before capturing content, useful for JS-heavy pages.",
82
- minimum: 0
83
- })
84
- ),
85
- timeout: Type.Optional(Type.Integer({ description: "Request timeout in milliseconds. Defaults to 30000.", minimum: 1 })),
86
- includeMetadata: Type.Optional(
87
- Type.Boolean({
88
- description:
89
- "Append verbose page metadata to the markdown output. Defaults to false. Full metadata is always available in details."
90
- })
91
- )
92
- }),
193
+ parameters: getFetchParameters(config),
93
194
  renderCall(args, theme, context) {
94
195
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0)
196
+ const input = args as unknown as FetchToolArgs
95
197
  const bits: string[] = []
96
- if (args.waitFor !== undefined) bits.push(`wait ${args.waitFor}ms`)
97
- if (args.timeout !== undefined) bits.push(`timeout ${args.timeout}ms`)
98
- if (args.includeMetadata) bits.push("metadata")
198
+ if (input.waitFor !== undefined) bits.push(`wait ${input.waitFor}ms`)
199
+ if (input.maxAgeHours !== undefined) bits.push(`max age ${input.maxAgeHours}h`)
200
+ if (input.extractDepth !== undefined) bits.push(input.extractDepth)
201
+ if (input.timeout !== undefined) bits.push(`timeout ${input.timeout}ms`)
202
+ if (input.includeMetadata) bits.push("metadata")
99
203
  const suffix = bits.length ? ` ${theme.fg("dim", `(${bits.join(", ")})`)}` : ""
100
- text.setText(`${theme.fg("toolTitle", theme.bold("web_fetch "))}${theme.fg("muted", args.url)}${suffix}`)
204
+ text.setText(`${theme.fg("toolTitle", theme.bold("web_fetch "))}${theme.fg("muted", input.url)}${suffix}`)
101
205
  return text
102
206
  },
103
207
  renderResult(result, { expanded, isPartial }, theme) {
@@ -105,13 +209,34 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
105
209
  },
106
210
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
107
211
  try {
212
+ const input = params as unknown as FetchToolArgs
108
213
  const config = loadConfig(ctx.cwd)
109
214
  const fetchProvider = getProvider("fetch", config)
110
215
  onUpdate?.({
111
- content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${params.url}` }],
112
- details: undefined as unknown
216
+ content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${input.url}` }],
217
+ details: undefined
113
218
  })
114
- return await fetchImpl(config, params, signal, onUpdate)
219
+
220
+ const result = await fetchProvider.fetch(
221
+ resolveApiKey(fetchProvider, config),
222
+ input.url,
223
+ {
224
+ timeout: input.timeout ?? DEFAULT_TIMEOUT_MS,
225
+ ...(input.waitFor !== undefined ? { waitFor: input.waitFor } : {}),
226
+ ...(input.maxAgeHours !== undefined ? { maxAgeHours: input.maxAgeHours } : {}),
227
+ ...(input.extractDepth !== undefined ? { extractDepth: input.extractDepth } : {})
228
+ },
229
+ signal
230
+ )
231
+ if (signal?.aborted) throw new Error("Fetch cancelled")
232
+
233
+ const metadata = input.includeMetadata && result.metadata ? `\n\nMetadata:\n${stringify(result.metadata)}` : ""
234
+ const toolResult: ToolResult = {
235
+ content: [{ type: "text", text: `${result.markdown}${metadata}` }],
236
+ details: result.raw
237
+ }
238
+ onUpdate?.(toolResult)
239
+ return toolResult
115
240
  } catch (error) {
116
241
  return {
117
242
  content: [{ type: "text", text: `Web fetch failed: ${asErrorMessage(error)}` }],
@@ -157,7 +282,7 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
157
282
  if (!isImageEnabled(config)) throw new Error("web_image is disabled. Enable it via /lovely-web.")
158
283
  onUpdate?.({
159
284
  content: [{ type: "text", text: `Fetching image: ${params.url}` }],
160
- details: undefined as unknown
285
+ details: undefined
161
286
  })
162
287
  return await imageImpl(
163
288
  {
@@ -180,3 +305,8 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
180
305
  }
181
306
  })
182
307
  }
308
+
309
+ export function registerLovelyWebTools(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
310
+ registerLovelyWebSearchTool(pi, config)
311
+ registerLovelyWebStaticTools(pi, config)
312
+ }
@@ -0,0 +1,7 @@
1
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"
2
+
3
+ export interface ToolResult {
4
+ content: Array<TextContent | ImageContent>
5
+ details: unknown
6
+ isError?: boolean
7
+ }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@xl0/pi-lovely-web",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
+ "description": "Pi extension package for web_search, web_fetch, and web_image via Firecrawl, Exa, Tavily, and Brave.",
4
5
  "keywords": [
5
6
  "pi-package",
6
7
  "firecrawl",
@@ -36,14 +37,20 @@
36
37
  },
37
38
  "devDependencies": {
38
39
  "@biomejs/biome": "^2.4.14",
40
+ "@earendil-works/pi-ai": "^0.79.10",
41
+ "@earendil-works/pi-coding-agent": "^0.79.10",
42
+ "@earendil-works/pi-tui": "^0.79.10",
39
43
  "@typescript/native-preview": "^7.0.0-dev.20260502.1",
40
44
  "bun-types": "^1.3.13",
41
45
  "typescript": "^6.0.3"
42
46
  },
47
+ "dependencies": {
48
+ "@xl0/pi-lovely-config": "^0.1.0"
49
+ },
43
50
  "peerDependencies": {
44
- "@earendil-works/pi-ai": ">=0.75.4",
45
- "@earendil-works/pi-coding-agent": ">=0.75.4",
46
- "@earendil-works/pi-tui": ">=0.75.4",
51
+ "@earendil-works/pi-ai": ">=0.79.10",
52
+ "@earendil-works/pi-coding-agent": ">=0.79.10",
53
+ "@earendil-works/pi-tui": ">=0.79.10",
47
54
  "typebox": "*"
48
55
  },
49
56
  "pi": {
@@ -1,95 +0,0 @@
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
- }