@xl0/pi-lovely-web 0.1.4 → 0.2.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.
@@ -1,14 +1,172 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai"
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
3
- 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"
2
+ import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent"
3
+ import { type Component, Text, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"
4
+ import { type TSchema, Type } from "typebox"
5
+ import {
6
+ DEFAULT_PROVIDER_ID,
7
+ getImageMaxSize,
8
+ getProvider,
9
+ isFetchEnabled,
10
+ isImageEnabled,
11
+ isImageResizeEnabled,
12
+ loadConfig,
13
+ lovelyWebConfigSpec,
14
+ providers,
15
+ resolveApiKey,
16
+ type WebToolsConfig
17
+ } from "./config.js"
18
+ import { DEFAULT_TIMEOUT_MS } from "./constants.js"
19
+ import { type FindMode, formatFindTextMatches } from "./find.js"
20
+ import { asErrorMessage, formatSearchOutput, stringify } from "./format.js"
7
21
  import { DEFAULT_MAX_IMAGE_BYTES, imageImpl, MAX_IMAGE_BYTES } from "./image.js"
22
+ import type { FetchOptions, SearchOptions } from "./providers/types.js"
8
23
  import { renderTextResult } from "./render.js"
9
- import { fetchImpl, searchImpl } from "./tool-impl.js"
24
+ import { smartProcess } from "./smart.js"
10
25
 
11
- export function registerLovelyWebTools(pi: ExtensionAPI) {
26
+ interface SearchToolArgs {
27
+ query: string
28
+ limit?: number
29
+ source?: string
30
+ fetchResult?: boolean
31
+ category?: string
32
+ location?: string
33
+ country?: string
34
+ tbs?: string
35
+ timeRange?: string
36
+ topic?: string
37
+ includeImages?: boolean
38
+ searchLang?: string
39
+ freshness?: string
40
+ }
41
+
42
+ interface FetchToolArgs extends FetchOptions {
43
+ url: string
44
+ includeMetadata?: boolean
45
+ smartQuery?: string
46
+ findText?: string[]
47
+ findMode?: FindMode
48
+ }
49
+
50
+ interface WebFetchRenderState {
51
+ smartCost?: string
52
+ smartCostInvalidated?: boolean
53
+ }
54
+
55
+ class WebFetchCallComponent implements Component {
56
+ constructor(
57
+ private readonly oneLine: string,
58
+ private readonly splitLines: string[]
59
+ ) {}
60
+
61
+ invalidate(): void {}
62
+
63
+ render(width: number): string[] {
64
+ return visibleWidth(this.oneLine) <= width ? [this.oneLine] : this.splitLines.flatMap(line => wrapTextWithAnsi(line, width))
65
+ }
66
+ }
67
+
68
+ async function fetchSearchResultImage(config: WebToolsConfig, url: string, signal?: AbortSignal): Promise<AgentToolResult<unknown>> {
69
+ return imageImpl(
70
+ {
71
+ url,
72
+ timeout: DEFAULT_TIMEOUT_MS,
73
+ resize: isImageResizeEnabled(config),
74
+ maxSize: getImageMaxSize(config)
75
+ },
76
+ signal
77
+ )
78
+ }
79
+
80
+ async function fetchSearchResultMarkdown(config: WebToolsConfig, url: string, signal?: AbortSignal): Promise<string> {
81
+ const fetchProvider = getProvider("fetch", config)
82
+ const fetchApiKey = resolveApiKey(fetchProvider, config)
83
+ const fetched = await fetchProvider.fetch(fetchApiKey, url, { timeout: DEFAULT_TIMEOUT_MS }, signal)
84
+ return fetched.markdown
85
+ }
86
+
87
+ function getSearchParameters(config: WebToolsConfig) {
88
+ const configured = config.webSearchProvider
89
+ const providerId = configured && providers[configured] ? configured : DEFAULT_PROVIDER_ID
90
+ const params: Record<string, TSchema> = {
91
+ query: Type.String({ description: "The search query." }),
92
+ limit: Type.Optional(
93
+ Type.Integer({
94
+ description: "Maximum number of results to return. Defaults to 5.",
95
+ minimum: 1,
96
+ maximum: 20
97
+ })
98
+ ),
99
+ fetchResult: Type.Optional(
100
+ Type.Boolean({
101
+ description: "Whether to fetch the first result. Defaults to false; image searches fetch image content when enabled."
102
+ })
103
+ )
104
+ }
105
+
106
+ Object.assign(params, providers[providerId]?.searchParameters)
107
+ return Type.Object(params)
108
+ }
109
+
110
+ function getFetchParameters(config: WebToolsConfig) {
111
+ const configured = config.webFetchProvider
112
+ const providerId = configured && providers[configured]?.fetch ? configured : "firecrawl"
113
+ const params: Record<string, TSchema> & { smartQuery?: TSchema } = {
114
+ url: Type.String({ description: "The URL to fetch.", format: "uri" }),
115
+ timeout: Type.Optional(Type.Integer({ description: "Request timeout in milliseconds. Defaults to 30000.", minimum: 1 })),
116
+ includeMetadata: Type.Optional(
117
+ Type.Boolean({
118
+ description: "Append verbose page metadata to the markdown output. Defaults to false. Full metadata is always available in details."
119
+ })
120
+ ),
121
+ findText: Type.Optional(
122
+ Type.Array(Type.String(), {
123
+ description: "Text strings to find in fetched markdown. Returns merged verification snippets."
124
+ })
125
+ ),
126
+ findMode: Type.Optional(
127
+ StringEnum(["exact", "lower", "fuzzy"], {
128
+ description:
129
+ "Find mode. fuzzy matches normalized paragraph chunks (default); exact is case-sensitive literal; lower is case-insensitive literal."
130
+ })
131
+ )
132
+ }
133
+
134
+ if (config.smartSearchEnabled) {
135
+ params.smartQuery = Type.Optional(
136
+ Type.String({
137
+ description:
138
+ "Fast and intelligent model processes fetched markdown, produces grounded result. Questions, extraction, summarization, troubleshooting, or any other task you can do on a page. Give commanders intent (do what and why). Can be combined with findText; both run independently over the fetched text."
139
+ })
140
+ )
141
+ }
142
+
143
+ Object.assign(params, providers[providerId]?.fetchParameters)
144
+ return Type.Object(params)
145
+ }
146
+
147
+ function formatUsd(amount: number): string {
148
+ if (amount === 0) return "$0"
149
+ if (amount < 0.0001) return "<$0.0001"
150
+ if (amount < 0.01) return `$${amount.toFixed(4)}`
151
+ return `$${amount.toFixed(3)}`
152
+ }
153
+
154
+ function smartCostLabel(details: unknown): string | undefined {
155
+ if (!details || typeof details !== "object") return undefined
156
+ const smart = (details as { smart?: unknown }).smart
157
+ if (!smart || typeof smart !== "object") return undefined
158
+ const usage = (smart as { usage?: unknown }).usage
159
+ if (!usage || typeof usage !== "object") return undefined
160
+ const cost = (usage as { cost?: unknown }).cost
161
+ if (cost && typeof cost === "object") {
162
+ const total = (cost as { total?: unknown }).total
163
+ if (typeof total === "number") return `cost:${formatUsd(total)}`
164
+ }
165
+ const totalTokens = (usage as { totalTokens?: unknown }).totalTokens
166
+ return typeof totalTokens === "number" ? `smart:${totalTokens} tok` : undefined
167
+ }
168
+
169
+ export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
12
170
  pi.registerTool({
13
171
  name: "web_search",
14
172
  label: "Web Search",
@@ -18,28 +176,15 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
18
176
  "Use web_search when the user asks for current web information, discovery, or sources beyond the local workspace.",
19
177
  "Use web_fetch after web_search when you need the full content of a specific page."
20
178
  ],
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
- }),
179
+ parameters: getSearchParameters(config),
37
180
  renderCall(args, theme, context) {
38
181
  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")
182
+ const input = args as unknown as SearchToolArgs
183
+ const mode = input.includeImages ? "images" : (input.source ?? input.topic ?? input.category ?? "web")
184
+ const bits = [mode, `limit ${input.limit ?? 5}`]
185
+ if (input.fetchResult === true) bits.push("fetch first")
41
186
  text.setText(
42
- `${theme.fg("toolTitle", theme.bold("web_search "))}${theme.fg("muted", `"${args.query}"`)} ${theme.fg("dim", `(${bits.join(", ")})`)}`
187
+ `${theme.fg("toolTitle", theme.bold("web_search "))}${theme.fg("muted", `"${input.query}"`)} ${theme.fg("dim", `(${bits.join(", ")})`)}`
43
188
  )
44
189
  return text
45
190
  },
@@ -48,77 +193,181 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
48
193
  },
49
194
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
50
195
  try {
51
- const config = loadConfig(ctx.cwd)
196
+ const config = loadConfig(ctx.cwd, ctx)
52
197
  const searchProvider = getProvider("search", config)
198
+ const input = params as unknown as SearchToolArgs
53
199
  onUpdate?.({
54
- content: [{ type: "text", text: `Searching web with ${searchProvider.label} for: ${params.query}` }],
55
- details: undefined as unknown
200
+ content: [{ type: "text", text: `Searching web with ${searchProvider.label} for: ${input.query}` }],
201
+ details: undefined
56
202
  })
57
- return await searchImpl(config, params, signal, onUpdate)
58
- } catch (error) {
59
- return {
60
- content: [{ type: "text", text: `Web search failed: ${asErrorMessage(error)}` }],
61
- details: { error: asErrorMessage(error) },
62
- isError: true
203
+
204
+ const { query, fetchResult, limit, ...providerOptions } = input
205
+ const searchOptions: SearchOptions = { ...providerOptions, limit: limit ?? 5, timeout: DEFAULT_TIMEOUT_MS }
206
+ const searchResult = await searchProvider.search(resolveApiKey(searchProvider, config), query, searchOptions, signal)
207
+ if (signal?.aborted) throw new Error("Search cancelled")
208
+
209
+ const first = searchResult.results[0]
210
+ let fetchedImage: AgentToolResult<unknown> | undefined
211
+ if (fetchResult === true && first?.url) {
212
+ onUpdate?.({ content: [{ type: "text", text: `Fetching first result: ${first.url}` }], details: undefined })
213
+ try {
214
+ const isImageSearch = input.source === "images" || input.includeImages === true
215
+ if (isImageSearch) {
216
+ try {
217
+ fetchedImage = await fetchSearchResultImage(config, first.url, signal)
218
+ } catch {
219
+ if (signal?.aborted) throw new Error("Search cancelled")
220
+ // Some image-search providers return source pages instead of direct image URLs.
221
+ }
222
+ }
223
+ if (!fetchedImage && isFetchEnabled(config)) first.markdown = await fetchSearchResultMarkdown(config, first.url, signal)
224
+ if (signal?.aborted) throw new Error("Search cancelled")
225
+ } catch (err) {
226
+ if (signal?.aborted) throw err
227
+ first.description = first.description || `[Fetch failed: ${asErrorMessage(err)}]`
228
+ }
229
+ }
230
+
231
+ const details = fetchedImage ? { search: searchResult.raw, image: fetchedImage.details } : searchResult.raw
232
+ const result: AgentToolResult<unknown> = {
233
+ content: [{ type: "text", text: formatSearchOutput(searchResult.results) }, ...(fetchedImage?.content || [])],
234
+ details
63
235
  }
236
+ onUpdate?.(result)
237
+ return result
238
+ } catch (error) {
239
+ throw new Error(`Web search failed: ${asErrorMessage(error)}`)
64
240
  }
65
241
  }
66
242
  })
243
+ }
67
244
 
245
+ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
68
246
  pi.registerTool({
69
247
  name: "web_fetch",
70
248
  label: "Web Fetch",
71
249
  description: "Fetch a page as markdown. Metadata is verbose and opt-in.",
72
250
  promptSnippet: "Use web_fetch to fetch a URL as markdown.",
73
251
  promptGuidelines: [
74
- "Use web_fetch when you need the full readable markdown content of a known URL.",
75
- "Prefer web_fetch over bash/curl for web pages because web_fetch returns cleaned markdown suitable for agent context."
252
+ "Use web_fetch when you need the full readable markdown content of a known URL; prefer web_fetch over bash/curl for web pages because web_fetch returns cleaned markdown suitable for agent context.",
253
+ "Use web_fetch.findText for search inside the page. Snippets are returned.",
254
+ ...(config.smartSearchEnabled
255
+ ? [
256
+ "Use web_fetch.smartQuery for grounded page processing: summarization, extraction, comparison, troubleshooting, config/API details, or verbatim code/command examples. Any task that can be done on a page by a fast and intelligent LLM."
257
+ ]
258
+ : [])
76
259
  ],
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
- }),
260
+ parameters: getFetchParameters(config),
93
261
  renderCall(args, theme, context) {
94
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0)
262
+ const input = args as unknown as FetchToolArgs
263
+ const state = context.state as WebFetchRenderState
95
264
  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")
265
+ if (input.waitFor !== undefined) bits.push(`wait ${input.waitFor}ms`)
266
+ if (input.maxAgeHours !== undefined) bits.push(`max age ${input.maxAgeHours}h`)
267
+ if (input.extractDepth !== undefined) bits.push(input.extractDepth)
268
+ if (input.timeout !== undefined) bits.push(`timeout ${input.timeout}ms`)
269
+ if (input.includeMetadata) bits.push("metadata")
99
270
  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}`)
101
- return text
271
+ const smart = input.smartQuery ? ` ${theme.fg("dim", `smart:"${input.smartQuery}"`)}` : ""
272
+ const find = input.findText?.length
273
+ ? ` ${theme.fg("dim", `find:${input.findMode ?? "fuzzy"}:[${input.findText.map(text => `"${text}"`).join(", ")}]`)}`
274
+ : ""
275
+ const cost = state.smartCost ? ` ${theme.fg("dim", state.smartCost)}` : ""
276
+ const title = `${theme.fg("toolTitle", theme.bold("web_fetch "))}${theme.fg("muted", input.url)}`
277
+ const oneLine = `${title}${smart}${find}${suffix}${cost}`
278
+ const splitLines = [`${title}${suffix}${cost}`]
279
+ if (input.smartQuery) splitLines.push(theme.fg("dim", `smart:"${input.smartQuery}"`))
280
+ if (input.findText?.length) {
281
+ splitLines.push(theme.fg("dim", `find:${input.findMode ?? "fuzzy"}:[${input.findText.map(text => `"${text}"`).join(", ")}]`))
282
+ }
283
+ return new WebFetchCallComponent(oneLine, splitLines)
102
284
  },
103
- renderResult(result, { expanded, isPartial }, theme) {
285
+ renderResult(result, { expanded, isPartial }, theme, context) {
286
+ const state = context.state as WebFetchRenderState
287
+ const cost = !isPartial ? smartCostLabel(result.details) : undefined
288
+ if (cost && state.smartCost !== cost) {
289
+ state.smartCost = cost
290
+ if (!state.smartCostInvalidated) {
291
+ state.smartCostInvalidated = true
292
+ queueMicrotask(() => context.invalidate())
293
+ }
294
+ }
104
295
  return renderTextResult(result, expanded, theme, isPartial ? "Fetching..." : "No content")
105
296
  },
106
297
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
298
+ const input = params as unknown as FetchToolArgs
299
+ let config: WebToolsConfig
300
+ let result: { markdown: string; metadata?: unknown; raw: unknown }
107
301
  try {
108
- const config = loadConfig(ctx.cwd)
302
+ config = loadConfig(ctx.cwd, ctx)
109
303
  const fetchProvider = getProvider("fetch", config)
110
304
  onUpdate?.({
111
- content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${params.url}` }],
112
- details: undefined as unknown
305
+ content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${input.url}` }],
306
+ details: undefined
113
307
  })
114
- return await fetchImpl(config, params, signal, onUpdate)
308
+
309
+ result = await fetchProvider.fetch(
310
+ resolveApiKey(fetchProvider, config),
311
+ input.url,
312
+ {
313
+ timeout: input.timeout ?? DEFAULT_TIMEOUT_MS,
314
+ ...(input.waitFor !== undefined ? { waitFor: input.waitFor } : {}),
315
+ ...(input.maxAgeHours !== undefined ? { maxAgeHours: input.maxAgeHours } : {}),
316
+ ...(input.extractDepth !== undefined ? { extractDepth: input.extractDepth } : {})
317
+ },
318
+ signal
319
+ )
320
+ if (signal?.aborted) throw new Error("Fetch cancelled")
115
321
  } catch (error) {
116
- return {
117
- content: [{ type: "text", text: `Web fetch failed: ${asErrorMessage(error)}` }],
118
- details: { error: asErrorMessage(error) },
119
- isError: true
322
+ throw new Error(`Web fetch failed: ${asErrorMessage(error)}`)
323
+ }
324
+
325
+ const metadata = input.includeMetadata && result.metadata ? `\n\nMetadata:\n${stringify(result.metadata)}` : ""
326
+ const fetchedText = `${result.markdown}${metadata}`
327
+ const outputParts: string[] = []
328
+ const renderParts: string[] = []
329
+ const extraDetails: { smart?: unknown; findText?: unknown; renderText?: string } = {}
330
+ const findText = input.findText?.map(text => text.trim()).filter(Boolean) ?? []
331
+ if (input.smartQuery?.trim()) {
332
+ onUpdate?.({ content: [{ type: "text", text: "Processing fetched page with smart search" }], details: undefined })
333
+ let smartText: string
334
+ try {
335
+ const smart = await smartProcess(
336
+ config,
337
+ ctx,
338
+ { query: input.smartQuery.trim(), resultText: fetchedText, sourceUrl: input.url },
339
+ signal
340
+ )
341
+ if (!smart) throw new Error("unavailable. Check /lovely-web smart search model and auth config.")
342
+ smartText = smart.text
343
+ extraDetails.smart = smart.details
344
+ } catch (error) {
345
+ if (signal?.aborted) throw error
346
+ smartText = `Smart search failed: ${asErrorMessage(error)}`
347
+ extraDetails.smart = { error: smartText }
348
+ if (findText.length === 0) throw new Error(smartText)
349
+ }
350
+ outputParts.push(smartText)
351
+ renderParts.push(smartText)
352
+ }
353
+ if (findText.length > 0) {
354
+ const found = formatFindTextMatches(fetchedText, findText, input.findMode ?? "fuzzy")
355
+ if (found.text) {
356
+ outputParts.push(found.text)
357
+ renderParts.push(found.renderText)
120
358
  }
359
+ extraDetails.findText = found.details
121
360
  }
361
+ const hasExtraOutput = outputParts.length > 0
362
+ const outputText = hasExtraOutput ? outputParts.join("\n\n---\n\n") : fetchedText
363
+ const renderText = renderParts.join("\n\n---\n\n")
364
+ if (hasExtraOutput && renderText !== outputText) extraDetails.renderText = renderText
365
+ const toolResult: AgentToolResult<unknown> = {
366
+ content: [{ type: "text", text: outputText }],
367
+ details: hasExtraOutput ? { fetch: result.raw, ...extraDetails } : result.raw
368
+ }
369
+ onUpdate?.(toolResult)
370
+ return toolResult
122
371
  }
123
372
  })
124
373
 
@@ -153,11 +402,11 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
153
402
  },
154
403
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
155
404
  try {
156
- const config = loadConfig(ctx.cwd)
405
+ const config = loadConfig(ctx.cwd, ctx)
157
406
  if (!isImageEnabled(config)) throw new Error("web_image is disabled. Enable it via /lovely-web.")
158
407
  onUpdate?.({
159
408
  content: [{ type: "text", text: `Fetching image: ${params.url}` }],
160
- details: undefined as unknown
409
+ details: undefined
161
410
  })
162
411
  return await imageImpl(
163
412
  {
@@ -171,12 +420,13 @@ export function registerLovelyWebTools(pi: ExtensionAPI) {
171
420
  onUpdate
172
421
  )
173
422
  } catch (error) {
174
- return {
175
- content: [{ type: "text", text: `Web image failed: ${asErrorMessage(error)}` }],
176
- details: { error: asErrorMessage(error) },
177
- isError: true
178
- }
423
+ throw new Error(`Web image failed: ${asErrorMessage(error)}`)
179
424
  }
180
425
  }
181
426
  })
182
427
  }
428
+
429
+ export function registerLovelyWebTools(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
430
+ registerLovelyWebSearchTool(pi, config)
431
+ registerLovelyWebStaticTools(pi, config)
432
+ }
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.2",
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",
@@ -27,6 +28,7 @@
27
28
  "check": "bun run typecheck && bun run biome:check",
28
29
  "test": "bun run test/run.ts",
29
30
  "test:image": "bun run test/image.ts",
31
+ "test:smart": "bun run test/smart-prompt.ts",
30
32
  "test:update": "bun run test/update-references.ts",
31
33
  "release": "bun publish --access public",
32
34
  "lint": "biome lint .",
@@ -36,14 +38,21 @@
36
38
  },
37
39
  "devDependencies": {
38
40
  "@biomejs/biome": "^2.4.14",
41
+ "@earendil-works/pi-ai": "^0.79.10",
42
+ "@earendil-works/pi-coding-agent": "^0.79.10",
43
+ "@earendil-works/pi-tui": "^0.79.10",
39
44
  "@typescript/native-preview": "^7.0.0-dev.20260502.1",
40
45
  "bun-types": "^1.3.13",
41
46
  "typescript": "^6.0.3"
42
47
  },
48
+ "dependencies": {
49
+ "@xl0/pi-lovely-config": "^0.1.1"
50
+ },
51
+
43
52
  "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",
53
+ "@earendil-works/pi-ai": ">=0.79.10",
54
+ "@earendil-works/pi-coding-agent": ">=0.79.10",
55
+ "@earendil-works/pi-tui": ">=0.79.10",
47
56
  "typebox": "*"
48
57
  },
49
58
  "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
- }