@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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
export type FindMode = "exact" | "lower" | "fuzzy"
|
|
2
|
+
|
|
3
|
+
export const FIND_HIGHLIGHT_START = "\uE000"
|
|
4
|
+
export const FIND_HIGHLIGHT_END = "\uE001"
|
|
5
|
+
|
|
6
|
+
const FIND_TEXT_CONTEXT_CHARS = 500
|
|
7
|
+
const FIND_TEXT_SNIPPET_CHARS = FIND_TEXT_CONTEXT_CHARS * 2
|
|
8
|
+
const FIND_TEXT_MAX_CHARS = FIND_TEXT_SNIPPET_CHARS * 20
|
|
9
|
+
|
|
10
|
+
interface SnippetRange {
|
|
11
|
+
start: number
|
|
12
|
+
end: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TextMatch {
|
|
16
|
+
query: string
|
|
17
|
+
index: number
|
|
18
|
+
length: number
|
|
19
|
+
highlights?: SnippetRange[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FindSnippet extends SnippetRange {
|
|
23
|
+
matches: TextMatch[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TextToken extends SnippetRange {
|
|
27
|
+
normalized: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OffsetMappedText {
|
|
31
|
+
text: string
|
|
32
|
+
offsets: SnippetRange[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function compactSnippetPart(text: string): string {
|
|
36
|
+
return text.replace(/\s+/g, " ").trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function snippetRange(text: string, index: number, length: number): SnippetRange {
|
|
40
|
+
return {
|
|
41
|
+
start: Math.max(0, index - FIND_TEXT_CONTEXT_CHARS),
|
|
42
|
+
end: Math.min(text.length, index + length + FIND_TEXT_CONTEXT_CHARS)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mergeRanges(ranges: SnippetRange[]): SnippetRange[] {
|
|
47
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end)
|
|
48
|
+
const merged: SnippetRange[] = []
|
|
49
|
+
for (const range of sorted) {
|
|
50
|
+
const last = merged.at(-1)
|
|
51
|
+
if (!last || range.start > last.end) merged.push({ ...range })
|
|
52
|
+
else last.end = Math.max(last.end, range.end)
|
|
53
|
+
}
|
|
54
|
+
return merged
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mergeMatchesIntoSnippets(text: string, matches: TextMatch[]): FindSnippet[] {
|
|
58
|
+
const sorted = [...matches].sort((a, b) => a.index - b.index || a.query.localeCompare(b.query))
|
|
59
|
+
const merged: FindSnippet[] = []
|
|
60
|
+
for (const match of sorted) {
|
|
61
|
+
const range = snippetRange(text, match.index, match.length)
|
|
62
|
+
const last = merged.at(-1)
|
|
63
|
+
if (!last || range.start > last.end) merged.push({ ...range, matches: [match] })
|
|
64
|
+
else {
|
|
65
|
+
last.end = Math.max(last.end, range.end)
|
|
66
|
+
last.matches.push(match)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return merged
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matchHighlights(match: TextMatch): SnippetRange[] {
|
|
73
|
+
return match.highlights ?? [{ start: match.index, end: match.index + match.length }]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatPlainSnippet(text: string, snippet: FindSnippet): string {
|
|
77
|
+
return `${snippet.start > 0 ? "…" : ""}${compactSnippetPart(text.slice(snippet.start, snippet.end))}${snippet.end < text.length ? "…" : ""}`.trim()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatMarkedSnippet(text: string, snippet: FindSnippet): string {
|
|
81
|
+
const highlights = mergeRanges(
|
|
82
|
+
snippet.matches
|
|
83
|
+
.flatMap(match => matchHighlights(match))
|
|
84
|
+
.map(range => ({ start: Math.max(snippet.start, range.start), end: Math.min(snippet.end, range.end) }))
|
|
85
|
+
.filter(range => range.end > range.start)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
let cursor = snippet.start
|
|
89
|
+
let marked = ""
|
|
90
|
+
for (const range of highlights) {
|
|
91
|
+
marked += text.slice(cursor, range.start)
|
|
92
|
+
marked += `${FIND_HIGHLIGHT_START}${text.slice(range.start, range.end)}${FIND_HIGHLIGHT_END}`
|
|
93
|
+
cursor = range.end
|
|
94
|
+
}
|
|
95
|
+
marked += text.slice(cursor, snippet.end)
|
|
96
|
+
return `${snippet.start > 0 ? "…" : ""}${compactSnippetPart(marked)}${snippet.end < text.length ? "…" : ""}`.trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeFindText(text: string): string {
|
|
100
|
+
return text
|
|
101
|
+
.normalize("NFD")
|
|
102
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
103
|
+
.toLocaleLowerCase()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lowerWithOffsetMap(text: string): OffsetMappedText {
|
|
107
|
+
let loweredText = ""
|
|
108
|
+
const offsets: SnippetRange[] = []
|
|
109
|
+
let index = 0
|
|
110
|
+
for (const char of text) {
|
|
111
|
+
const lowered = char.toLocaleLowerCase()
|
|
112
|
+
const end = index + char.length
|
|
113
|
+
loweredText += lowered
|
|
114
|
+
for (let i = 0; i < lowered.length; i++) offsets.push({ start: index, end })
|
|
115
|
+
index = end
|
|
116
|
+
}
|
|
117
|
+
return { text: loweredText, offsets }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeQuery(text: string): string {
|
|
121
|
+
return normalizeFindText(text)
|
|
122
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
123
|
+
.replace(/\s+/g, " ")
|
|
124
|
+
.trim()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tokenize(text: string, baseStart = 0): TextToken[] {
|
|
128
|
+
const tokens: TextToken[] = []
|
|
129
|
+
const re = /[\p{L}\p{N}]+/gu
|
|
130
|
+
let match = re.exec(text)
|
|
131
|
+
while (match) {
|
|
132
|
+
const raw = match[0]
|
|
133
|
+
const normalized = normalizeFindText(raw)
|
|
134
|
+
if (normalized) tokens.push({ normalized, start: baseStart + match.index, end: baseStart + match.index + raw.length })
|
|
135
|
+
match = re.exec(text)
|
|
136
|
+
}
|
|
137
|
+
return tokens
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cappedEditDistance(a: string, b: string, max: number): number {
|
|
141
|
+
if (Math.abs(a.length - b.length) > max) return max + 1
|
|
142
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
143
|
+
for (let i = 1; i <= a.length; i++) {
|
|
144
|
+
const curr = [i]
|
|
145
|
+
let rowMin = curr[0] ?? 0
|
|
146
|
+
for (let j = 1; j <= b.length; j++) {
|
|
147
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
148
|
+
const value = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost)
|
|
149
|
+
curr[j] = value
|
|
150
|
+
rowMin = Math.min(rowMin, value)
|
|
151
|
+
}
|
|
152
|
+
if (rowMin > max) return max + 1
|
|
153
|
+
prev = curr
|
|
154
|
+
}
|
|
155
|
+
return prev[b.length] ?? max + 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function fuzzyTokenMatches(token: string, chunkToken: string): boolean {
|
|
159
|
+
if (chunkToken === token) return true
|
|
160
|
+
if (/\d/.test(token)) return false
|
|
161
|
+
if (token.length < 6) return false
|
|
162
|
+
const maxDistance = token.length <= 8 ? 1 : 2
|
|
163
|
+
return Math.abs(token.length - chunkToken.length) <= maxDistance && cappedEditDistance(token, chunkToken, maxDistance) <= maxDistance
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function matchingQueryIndexes(token: string, queryTokens: string[]): number[] {
|
|
167
|
+
return queryTokens.flatMap((queryToken, index) => (fuzzyTokenMatches(queryToken, token) ? [index] : []))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function selectFuzzyWindow(tokens: TextToken[], queryTokens: string[], maxSpan: number): { tokens: TextToken[]; coverage: number } {
|
|
171
|
+
const candidates = tokens
|
|
172
|
+
.map(token => ({ token, queryIndexes: matchingQueryIndexes(token.normalized, queryTokens) }))
|
|
173
|
+
.filter(candidate => candidate.queryIndexes.length > 0)
|
|
174
|
+
const minCoverage = queryTokens.length === 1 ? 1 : Math.ceil(queryTokens.length * 0.6)
|
|
175
|
+
for (let target = queryTokens.length; target >= minCoverage; target--) {
|
|
176
|
+
const counts = new Map<number, number>()
|
|
177
|
+
let unique = 0
|
|
178
|
+
let left = 0
|
|
179
|
+
let best: { left: number; right: number; span: number } | undefined
|
|
180
|
+
for (let right = 0; right < candidates.length; right++) {
|
|
181
|
+
for (const queryIndex of candidates[right]?.queryIndexes ?? []) {
|
|
182
|
+
const count = counts.get(queryIndex) ?? 0
|
|
183
|
+
if (count === 0) unique++
|
|
184
|
+
counts.set(queryIndex, count + 1)
|
|
185
|
+
}
|
|
186
|
+
while (unique >= target) {
|
|
187
|
+
const leftToken = candidates[left]?.token
|
|
188
|
+
const rightToken = candidates[right]?.token
|
|
189
|
+
if (leftToken && rightToken) {
|
|
190
|
+
const span = rightToken.end - leftToken.start
|
|
191
|
+
if (!best || span < best.span) best = { left, right, span }
|
|
192
|
+
}
|
|
193
|
+
for (const queryIndex of candidates[left]?.queryIndexes ?? []) {
|
|
194
|
+
const count = (counts.get(queryIndex) ?? 0) - 1
|
|
195
|
+
if (count === 0) {
|
|
196
|
+
counts.delete(queryIndex)
|
|
197
|
+
unique--
|
|
198
|
+
} else counts.set(queryIndex, count)
|
|
199
|
+
}
|
|
200
|
+
left++
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (best && best.span <= maxSpan) {
|
|
204
|
+
return {
|
|
205
|
+
tokens: candidates.slice(best.left, best.right + 1).map(candidate => candidate.token),
|
|
206
|
+
coverage: target / queryTokens.length
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { tokens: [], coverage: 0 }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function splitChunks(text: string): Array<{ text: string; start: number }> {
|
|
214
|
+
const chunks: Array<{ text: string; start: number }> = []
|
|
215
|
+
const re = /\n\s*\n+/g
|
|
216
|
+
let start = 0
|
|
217
|
+
let match = re.exec(text)
|
|
218
|
+
while (match) {
|
|
219
|
+
const chunkText = text.slice(start, match.index)
|
|
220
|
+
const trimmed = chunkText.trim()
|
|
221
|
+
const firstNonSpace = chunkText.search(/\S/)
|
|
222
|
+
if (trimmed.length > 20 && firstNonSpace >= 0) chunks.push({ text: trimmed, start: start + firstNonSpace })
|
|
223
|
+
start = re.lastIndex
|
|
224
|
+
match = re.exec(text)
|
|
225
|
+
}
|
|
226
|
+
const chunkText = text.slice(start)
|
|
227
|
+
const trimmed = chunkText.trim()
|
|
228
|
+
const firstNonSpace = chunkText.search(/\S/)
|
|
229
|
+
if (trimmed.length > 20 && firstNonSpace >= 0) chunks.push({ text: trimmed, start: start + firstNonSpace })
|
|
230
|
+
return chunks
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function snippetChars(range: SnippetRange): number {
|
|
234
|
+
return range.end - range.start
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clipSnippet(snippet: FindSnippet, maxChars: number): FindSnippet | undefined {
|
|
238
|
+
const end = Math.min(snippet.end, snippet.start + maxChars)
|
|
239
|
+
const matches = snippet.matches.filter(match => match.index < end && match.index + match.length > snippet.start)
|
|
240
|
+
return matches.length > 0 ? { start: snippet.start, end, matches } : undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function countQueries(matches: TextMatch[]): Array<{ query: string; count: number }> {
|
|
244
|
+
const counts = new Map<string, number>()
|
|
245
|
+
for (const match of matches) counts.set(match.query, (counts.get(match.query) ?? 0) + 1)
|
|
246
|
+
return [...counts].map(([query, count]) => ({ query, count }))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatQueryCounts(matches: TextMatch[]): string {
|
|
250
|
+
return countQueries(matches)
|
|
251
|
+
.map(({ query, count }) => `"${query}" ×${count}`)
|
|
252
|
+
.join(", ")
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function collectLiteralMatches(text: string, query: string, mode: "exact" | "lower"): TextMatch[] {
|
|
256
|
+
const needle = query.trim()
|
|
257
|
+
const lowered = mode === "lower" ? lowerWithOffsetMap(text) : undefined
|
|
258
|
+
const haystack = lowered?.text ?? text
|
|
259
|
+
const searchNeedle = mode === "exact" ? needle : needle.toLocaleLowerCase()
|
|
260
|
+
const matches: TextMatch[] = []
|
|
261
|
+
let index = searchNeedle ? haystack.indexOf(searchNeedle) : -1
|
|
262
|
+
while (index !== -1) {
|
|
263
|
+
const start = lowered?.offsets[index]?.start ?? index
|
|
264
|
+
const end = lowered?.offsets[index + searchNeedle.length - 1]?.end ?? index + needle.length
|
|
265
|
+
matches.push({ query: needle, index: start, length: end - start })
|
|
266
|
+
index = haystack.indexOf(searchNeedle, index + searchNeedle.length)
|
|
267
|
+
}
|
|
268
|
+
return matches
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function collectFuzzyMatches(text: string, query: string): TextMatch[] {
|
|
272
|
+
const normalizedQuery = normalizeQuery(query)
|
|
273
|
+
const queryTokens = [...new Set(normalizedQuery.split(" ").filter(token => token.length >= 2))].slice(0, 20)
|
|
274
|
+
if (queryTokens.length === 0) return []
|
|
275
|
+
const maxSpan = queryTokens.length <= 2 ? Math.max(60, normalizedQuery.length * 3) : Math.max(30, queryTokens.join(" ").length * 2)
|
|
276
|
+
|
|
277
|
+
return splitChunks(text)
|
|
278
|
+
.map(chunk => {
|
|
279
|
+
const tokens = tokenize(chunk.text, chunk.start)
|
|
280
|
+
const normalizedChunk = tokens.map(token => token.normalized).join(" ")
|
|
281
|
+
const phraseHit = normalizedChunk.includes(queryTokens.join(" "))
|
|
282
|
+
const window = selectFuzzyWindow(tokens, queryTokens, maxSpan)
|
|
283
|
+
const matchedTokens = window.tokens
|
|
284
|
+
const coverage = window.coverage
|
|
285
|
+
return { chunk, matchedTokens, phraseHit, coverage, score: coverage + (phraseHit ? 1 : 0) }
|
|
286
|
+
})
|
|
287
|
+
.filter(match => match.phraseHit || (queryTokens.length === 1 ? match.coverage > 0 : match.coverage >= 0.6))
|
|
288
|
+
.sort((a, b) => b.score - a.score || a.chunk.start - b.chunk.start)
|
|
289
|
+
.map(match => {
|
|
290
|
+
const highlights = mergeRanges(match.matchedTokens.map(token => ({ start: token.start, end: token.end })))
|
|
291
|
+
const start = highlights[0]?.start ?? match.chunk.start
|
|
292
|
+
const end = highlights.at(-1)?.end ?? Math.min(text.length, match.chunk.start + 80)
|
|
293
|
+
return { query, index: start, length: end - start, highlights }
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function formatFindTextMatches(
|
|
298
|
+
text: string,
|
|
299
|
+
queries: string[],
|
|
300
|
+
mode: FindMode
|
|
301
|
+
): { text: string; renderText: string; details: unknown } {
|
|
302
|
+
const trimmedQueries = queries.map(query => query.trim()).filter(Boolean)
|
|
303
|
+
const matches = trimmedQueries.flatMap(query =>
|
|
304
|
+
mode === "fuzzy" ? collectFuzzyMatches(text, query) : collectLiteralMatches(text, query, mode)
|
|
305
|
+
)
|
|
306
|
+
const queryResults = trimmedQueries.map(query => ({ query, matchCount: matches.filter(match => match.query === query).length }))
|
|
307
|
+
const snippets = mergeMatchesIntoSnippets(text, matches)
|
|
308
|
+
const selected: FindSnippet[] = []
|
|
309
|
+
let charsUsed = 0
|
|
310
|
+
for (const snippet of snippets) {
|
|
311
|
+
const remaining = FIND_TEXT_MAX_CHARS - charsUsed
|
|
312
|
+
if (remaining <= 0) break
|
|
313
|
+
const chars = snippetChars(snippet)
|
|
314
|
+
if (chars <= remaining) {
|
|
315
|
+
selected.push(snippet)
|
|
316
|
+
charsUsed += chars
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
const clipped = clipSnippet(snippet, remaining)
|
|
320
|
+
if (clipped) {
|
|
321
|
+
selected.push(clipped)
|
|
322
|
+
charsUsed += snippetChars(clipped)
|
|
323
|
+
}
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
const shownMatches = selected.reduce((sum, snippet) => sum + snippet.matches.length, 0)
|
|
327
|
+
const noMatchQueries = queryResults.filter(result => result.matchCount === 0).map(result => `"${result.query}"`)
|
|
328
|
+
const title = matches.length ? `Text matches (${mode}):` : `Text matches (${mode}): no matches`
|
|
329
|
+
const body = selected.map((snippet, i) => `${i + 1}. ${formatQueryCounts(snippet.matches)}\n${formatPlainSnippet(text, snippet)}`)
|
|
330
|
+
const renderBody = selected.map((snippet, i) => `${i + 1}. ${formatQueryCounts(snippet.matches)}\n${formatMarkedSnippet(text, snippet)}`)
|
|
331
|
+
const noMatches = noMatchQueries.length ? `No matches: ${noMatchQueries.join(", ")}` : undefined
|
|
332
|
+
if (noMatches) {
|
|
333
|
+
body.push(noMatches)
|
|
334
|
+
renderBody.push(noMatches)
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
text: body.length ? `${title}\n\n${body.join("\n\n")}` : title,
|
|
338
|
+
renderText: renderBody.length ? `${title}\n\n${renderBody.join("\n\n")}` : title,
|
|
339
|
+
details: {
|
|
340
|
+
mode,
|
|
341
|
+
contextChars: FIND_TEXT_CONTEXT_CHARS,
|
|
342
|
+
snippetChars: FIND_TEXT_SNIPPET_CHARS,
|
|
343
|
+
maxChars: FIND_TEXT_MAX_CHARS,
|
|
344
|
+
charsUsed,
|
|
345
|
+
matchCount: matches.length,
|
|
346
|
+
returnedMatches: shownMatches,
|
|
347
|
+
queryResults,
|
|
348
|
+
snippets: selected.map(snippet => ({
|
|
349
|
+
start: snippet.start,
|
|
350
|
+
end: snippet.end,
|
|
351
|
+
chars: snippetChars(snippet),
|
|
352
|
+
matches: countQueries(snippet.matches)
|
|
353
|
+
}))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -15,6 +15,17 @@ function truncate(text: string, maxLen: number): string {
|
|
|
15
15
|
return `${text.slice(0, maxLen)}…`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function indent(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map(line => ` ${line}`)
|
|
22
|
+
.join("\n")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function field(label: string, value: string): string[] {
|
|
26
|
+
return value.includes("\n") ? [` ${label}:`, indent(value)] : [` ${label}: ${value}`]
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export function formatSearchOutput(results: SearchResult[]) {
|
|
19
30
|
if (results.length === 0) return "No results."
|
|
20
31
|
|
|
@@ -24,13 +35,13 @@ export function formatSearchOutput(results: SearchResult[]) {
|
|
|
24
35
|
const url = result.url
|
|
25
36
|
const description = result.description
|
|
26
37
|
const markdown = result.markdown?.trim()
|
|
27
|
-
const lines = [`${index + 1}
|
|
38
|
+
const lines = [`${index + 1}.`, ...field("title", title)]
|
|
28
39
|
|
|
29
|
-
if (url) lines.push(
|
|
40
|
+
if (url) lines.push(...field("url", url))
|
|
30
41
|
if (description && !(index === 0 && markdown)) {
|
|
31
|
-
lines.push(
|
|
42
|
+
lines.push(...field("desc", truncate(description, MAX_DESCRIPTION_LENGTH)))
|
|
32
43
|
}
|
|
33
|
-
if (index === 0 && markdown) lines.push("
|
|
44
|
+
if (index === 0 && markdown) lines.push(" markdown:", markdown)
|
|
34
45
|
|
|
35
46
|
return lines.join("\n")
|
|
36
47
|
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { formatDimensionNote, type ResizedImage, resizeImage } from "@earendil-works/pi-coding-agent"
|
|
1
|
+
import { type AgentToolResult, formatDimensionNote, type ResizedImage, resizeImage } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { getImageDimensions } from "@earendil-works/pi-tui"
|
|
3
|
-
import { DEFAULT_TIMEOUT_MS } from "./
|
|
4
|
-
import type { ToolResult } from "./tool-impl.js"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "./constants.js"
|
|
5
4
|
|
|
6
5
|
export const DEFAULT_MAX_IMAGE_BYTES = 5_000_000
|
|
7
6
|
export const MAX_IMAGE_BYTES = 20_000_000
|
|
@@ -63,6 +62,25 @@ async function fetchImageContent(
|
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
function textOnlyResult(text: string, details: Record<string, unknown>): AgentToolResult<unknown> {
|
|
66
|
+
return { content: [{ type: "text" as const, text }], details }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function imageResult(text: string, image: { data: string; mimeType: string }, details: Record<string, unknown>): AgentToolResult<unknown> {
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text" as const, text },
|
|
73
|
+
{ type: "image" as const, data: image.data, mimeType: image.mimeType }
|
|
74
|
+
],
|
|
75
|
+
details
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emitResult(result: AgentToolResult<unknown>, onUpdate?: (result: AgentToolResult<unknown>) => void): AgentToolResult<unknown> {
|
|
80
|
+
onUpdate?.(result)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
export async function imageImpl(
|
|
67
85
|
params: {
|
|
68
86
|
url: string
|
|
@@ -72,8 +90,8 @@ export async function imageImpl(
|
|
|
72
90
|
maxSize?: number | undefined
|
|
73
91
|
},
|
|
74
92
|
signal?: AbortSignal,
|
|
75
|
-
onUpdate?: (result:
|
|
76
|
-
): Promise<
|
|
93
|
+
onUpdate?: (result: AgentToolResult<unknown>) => void
|
|
94
|
+
): Promise<AgentToolResult<unknown>> {
|
|
77
95
|
const maxBytes = params.maxBytes ?? DEFAULT_MAX_IMAGE_BYTES
|
|
78
96
|
if (maxBytes > MAX_IMAGE_BYTES) throw new Error(`maxBytes cannot exceed ${MAX_IMAGE_BYTES}`)
|
|
79
97
|
|
|
@@ -81,87 +99,58 @@ export async function imageImpl(
|
|
|
81
99
|
if (signal?.aborted) throw new Error("Image fetch cancelled")
|
|
82
100
|
|
|
83
101
|
const originalDimensions = getImageDimensions(image.data, image.mimeType) ?? undefined
|
|
102
|
+
const downloadDetails = {
|
|
103
|
+
url: params.url,
|
|
104
|
+
mimeType: image.mimeType,
|
|
105
|
+
bytes: image.bytes,
|
|
106
|
+
contentLength: image.contentLength
|
|
107
|
+
}
|
|
84
108
|
|
|
85
109
|
if (params.resize === false) {
|
|
86
110
|
// Re-encode to sanitize, but don't downscale.
|
|
87
|
-
const validated = (await resizeImage(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)) as ResizedImage | null
|
|
111
|
+
const validated = (await resizeImage(Buffer.from(image.data, "base64"), image.mimeType, {
|
|
112
|
+
maxWidth: Infinity,
|
|
113
|
+
maxHeight: Infinity
|
|
114
|
+
})) as ResizedImage | null
|
|
91
115
|
if (!validated) {
|
|
92
116
|
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded]`
|
|
93
|
-
|
|
94
|
-
content: [{ type: "text" as const, text: note }],
|
|
95
|
-
details: {
|
|
96
|
-
url: params.url,
|
|
97
|
-
mimeType: image.mimeType,
|
|
98
|
-
bytes: image.bytes,
|
|
99
|
-
contentLength: image.contentLength
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
onUpdate?.(result)
|
|
103
|
-
return result
|
|
117
|
+
return emitResult(textOnlyResult(note, downloadDetails), onUpdate)
|
|
104
118
|
}
|
|
105
119
|
const dimensions = { widthPx: validated.width, heightPx: validated.height }
|
|
106
120
|
const note = `Fetched image [${validated.mimeType}]`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{ type: "image" as const, data: validated.data, mimeType: validated.mimeType }
|
|
111
|
-
],
|
|
112
|
-
details: {
|
|
113
|
-
url: params.url,
|
|
121
|
+
return emitResult(
|
|
122
|
+
imageResult(note, validated, {
|
|
123
|
+
...downloadDetails,
|
|
114
124
|
mimeType: validated.mimeType,
|
|
115
|
-
bytes: image.bytes,
|
|
116
|
-
contentLength: image.contentLength,
|
|
117
125
|
dimensions,
|
|
118
126
|
originalDimensions,
|
|
119
127
|
wasResized: validated.wasResized
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return result
|
|
128
|
+
}),
|
|
129
|
+
onUpdate
|
|
130
|
+
)
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
const resized = (await resizeImage(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)) as ResizedImage | null
|
|
133
|
+
const resized = (await resizeImage(Buffer.from(image.data, "base64"), image.mimeType, {
|
|
134
|
+
maxWidth: params.maxSize ?? 2000,
|
|
135
|
+
maxHeight: params.maxSize ?? 2000
|
|
136
|
+
})) as ResizedImage | null
|
|
130
137
|
|
|
131
138
|
if (!resized) {
|
|
132
139
|
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded or resized below the inline image size limit.]`
|
|
133
|
-
|
|
134
|
-
content: [{ type: "text" as const, text: note }],
|
|
135
|
-
details: {
|
|
136
|
-
url: params.url,
|
|
137
|
-
mimeType: image.mimeType,
|
|
138
|
-
bytes: image.bytes,
|
|
139
|
-
contentLength: image.contentLength,
|
|
140
|
-
dimensions: originalDimensions
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
onUpdate?.(result)
|
|
144
|
-
return result
|
|
140
|
+
return emitResult(textOnlyResult(note, { ...downloadDetails, dimensions: originalDimensions }), onUpdate)
|
|
145
141
|
}
|
|
146
142
|
|
|
147
143
|
const dimensionNote = formatDimensionNote(resized)
|
|
148
144
|
const note = `Fetched image [${resized.mimeType}]${dimensionNote ? `\n${dimensionNote}` : ""}`
|
|
149
145
|
const dimensions = { widthPx: resized.width, heightPx: resized.height }
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{ type: "image" as const, data: resized.data, mimeType: resized.mimeType }
|
|
154
|
-
],
|
|
155
|
-
details: {
|
|
156
|
-
url: params.url,
|
|
146
|
+
return emitResult(
|
|
147
|
+
imageResult(note, resized, {
|
|
148
|
+
...downloadDetails,
|
|
157
149
|
mimeType: resized.mimeType,
|
|
158
|
-
bytes: image.bytes,
|
|
159
|
-
contentLength: image.contentLength,
|
|
160
150
|
dimensions,
|
|
161
151
|
originalDimensions,
|
|
162
152
|
wasResized: resized.wasResized
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return result
|
|
153
|
+
}),
|
|
154
|
+
onUpdate
|
|
155
|
+
)
|
|
167
156
|
}
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { registerLovelyWebCommand } from "./command.js"
|
|
3
|
-
import { applyToolConfig,
|
|
4
|
-
import {
|
|
3
|
+
import { applyToolConfig, loadScopedConfig } from "./config.js"
|
|
4
|
+
import { asErrorMessage } from "./format.js"
|
|
5
|
+
import { resetSmartRuntimeState, validateSmartConfig } from "./smart.js"
|
|
6
|
+
import { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
|
|
5
7
|
|
|
6
8
|
export default function (pi: ExtensionAPI) {
|
|
9
|
+
registerLovelyWebStaticTools(pi)
|
|
7
10
|
pi.on("session_start", async (_event, ctx) => {
|
|
8
|
-
|
|
11
|
+
try {
|
|
12
|
+
resetSmartRuntimeState()
|
|
13
|
+
const loaded = loadScopedConfig(ctx.cwd, ctx)
|
|
14
|
+
if (loaded.warnings.length > 0) ctx.ui.notify(loaded.warnings.map(w => `${w.path}: ${w.message}`).join("\n"), "warning")
|
|
15
|
+
const config = validateSmartConfig(loaded.value, ctx) ? loaded.value : { ...loaded.value, smartSearchEnabled: false }
|
|
16
|
+
registerLovelyWebSearchTool(pi, config)
|
|
17
|
+
registerLovelyWebStaticTools(pi, config)
|
|
18
|
+
applyToolConfig(pi, config)
|
|
19
|
+
} catch (error) {
|
|
20
|
+
ctx.ui.notify(`Lovely Web config error: ${asErrorMessage(error)}`, "error")
|
|
21
|
+
}
|
|
9
22
|
})
|
|
10
|
-
registerLovelyWebTools(pi)
|
|
11
23
|
registerLovelyWebCommand(pi)
|
|
12
24
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai"
|
|
2
|
+
import { Type } from "typebox"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "../constants.js"
|
|
1
4
|
import { requestJson } from "./http.js"
|
|
2
5
|
import type { Provider, SearchResult } from "./types.js"
|
|
3
6
|
|
|
4
7
|
const BASE_URL = "https://api.search.brave.com/res/v1"
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 30_000
|
|
6
8
|
|
|
7
9
|
interface BraveWebResult {
|
|
8
10
|
title: string
|
|
@@ -28,6 +30,8 @@ interface BraveImageResult {
|
|
|
28
30
|
url: string
|
|
29
31
|
title?: string
|
|
30
32
|
description?: string
|
|
33
|
+
properties?: { url?: string }
|
|
34
|
+
thumbnail?: { src?: string }
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
interface BraveImageResponse {
|
|
@@ -53,24 +57,35 @@ function fetchJson(url: string, apiKey: string, timeout: number, signal?: AbortS
|
|
|
53
57
|
)
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
function buildSearchUrl(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
60
|
+
function buildSearchUrl(
|
|
61
|
+
source: string | undefined,
|
|
62
|
+
query: string,
|
|
63
|
+
count: number,
|
|
64
|
+
opts: { country?: string; searchLang?: string; freshness?: string }
|
|
65
|
+
): string {
|
|
66
|
+
const endpoint = source === "news" ? "news" : source === "images" ? "images" : "web"
|
|
67
|
+
const url = new URL(`${BASE_URL}/${endpoint}/search`)
|
|
68
|
+
url.searchParams.set("q", query)
|
|
69
|
+
url.searchParams.set("count", String(count))
|
|
70
|
+
if (opts.country) url.searchParams.set("country", opts.country.toUpperCase())
|
|
71
|
+
if (opts.searchLang) url.searchParams.set("search_lang", opts.searchLang)
|
|
72
|
+
if (opts.freshness && source !== "images") url.searchParams.set("freshness", opts.freshness)
|
|
73
|
+
return url.toString()
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
export const braveProvider: Provider = {
|
|
68
77
|
id: "brave",
|
|
69
78
|
label: "Brave Search",
|
|
70
79
|
envApiKey: "BRAVE_API_KEY",
|
|
80
|
+
searchParameters: {
|
|
81
|
+
source: Type.Optional(StringEnum(["web", "news", "images"])),
|
|
82
|
+
country: Type.Optional(Type.String({ description: "Two-letter country code, e.g. US, DE, CO, or ALL." })),
|
|
83
|
+
searchLang: Type.Optional(Type.String({ description: "Search language code, e.g. en, es, de." })),
|
|
84
|
+
freshness: Type.Optional(Type.String({ description: "Freshness filter for web/news: pd, pw, pm, py, or YYYY-MM-DDtoYYYY-MM-DD." }))
|
|
85
|
+
},
|
|
71
86
|
|
|
72
87
|
async search(apiKey, query, opts, signal) {
|
|
73
|
-
const url = buildSearchUrl(opts.source, query, opts.limit)
|
|
88
|
+
const url = buildSearchUrl(opts.source, query, opts.limit, opts)
|
|
74
89
|
const raw = await fetchJson(url, apiKey, opts.timeout ?? DEFAULT_TIMEOUT_MS, signal)
|
|
75
90
|
|
|
76
91
|
let items: SearchResult[] = []
|
|
@@ -85,9 +100,9 @@ export const braveProvider: Provider = {
|
|
|
85
100
|
} else if (opts.source === "images") {
|
|
86
101
|
const data = raw as BraveImageResponse
|
|
87
102
|
items = (data.results ?? []).map(item => ({
|
|
88
|
-
title: item.title || item.url,
|
|
89
|
-
url: item.url,
|
|
90
|
-
description: item.description || ""
|
|
103
|
+
title: item.title || item.properties?.url || item.url,
|
|
104
|
+
url: item.properties?.url || item.thumbnail?.src || item.url,
|
|
105
|
+
description: item.description || item.url || ""
|
|
91
106
|
}))
|
|
92
107
|
} else {
|
|
93
108
|
const data = raw as BraveWebResponse
|