@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.
- package/README.md +71 -33
- package/extensions/lovely-web/command.ts +32 -163
- package/extensions/lovely-web/config.ts +178 -56
- package/extensions/lovely-web/constants.ts +1 -0
- package/extensions/lovely-web/find.ts +356 -0
- package/extensions/lovely-web/format.ts +15 -4
- package/extensions/lovely-web/image.ts +51 -62
- package/extensions/lovely-web/index.ts +16 -4
- package/extensions/lovely-web/providers/brave.ts +29 -14
- package/extensions/lovely-web/providers/exa.ts +20 -10
- package/extensions/lovely-web/providers/firecrawl.ts +37 -6
- package/extensions/lovely-web/providers/tavily.ts +44 -10
- package/extensions/lovely-web/providers/types.ts +28 -14
- package/extensions/lovely-web/render.ts +29 -3
- package/extensions/lovely-web/smart.ts +197 -0
- package/extensions/lovely-web/tools.ts +325 -75
- package/package.json +13 -4
- package/extensions/lovely-web/tool-impl.ts +0 -95
|
@@ -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 {
|
|
6
|
-
|
|
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 {
|
|
24
|
+
import { smartProcess } from "./smart.js"
|
|
10
25
|
|
|
11
|
-
|
|
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:
|
|
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
|
|
40
|
-
|
|
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", `"${
|
|
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: ${
|
|
55
|
-
details: undefined
|
|
200
|
+
content: [{ type: "text", text: `Searching web with ${searchProvider.label} for: ${input.query}` }],
|
|
201
|
+
details: undefined
|
|
56
202
|
})
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
"
|
|
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:
|
|
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
|
|
262
|
+
const input = args as unknown as FetchToolArgs
|
|
263
|
+
const state = context.state as WebFetchRenderState
|
|
95
264
|
const bits: string[] = []
|
|
96
|
-
if (
|
|
97
|
-
if (
|
|
98
|
-
if (
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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}: ${
|
|
112
|
-
details: undefined
|
|
305
|
+
content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${input.url}` }],
|
|
306
|
+
details: undefined
|
|
113
307
|
})
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
45
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
46
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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
|
-
}
|