@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.
@@ -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}. ${title}`]
38
+ const lines = [`${index + 1}.`, ...field("title", title)]
28
39
 
29
- if (url) lines.push(` ${url}`)
40
+ if (url) lines.push(...field("url", url))
30
41
  if (description && !(index === 0 && markdown)) {
31
- lines.push(` ${truncate(description, MAX_DESCRIPTION_LENGTH)}`)
42
+ lines.push(...field("desc", truncate(description, MAX_DESCRIPTION_LENGTH)))
32
43
  }
33
- if (index === 0 && markdown) lines.push("", " Markdown:", markdown)
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 "./config.js"
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: ToolResult) => void
76
- ): Promise<ToolResult> {
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
- { type: "image", data: image.data, mimeType: image.mimeType },
89
- { maxWidth: Infinity, maxHeight: Infinity }
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
- const result: ToolResult = {
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
- const result: ToolResult = {
108
- content: [
109
- { type: "text" as const, text: note },
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
- onUpdate?.(result)
123
- return result
128
+ }),
129
+ onUpdate
130
+ )
124
131
  }
125
132
 
126
- const resized = (await resizeImage(
127
- { type: "image", data: image.data, mimeType: image.mimeType },
128
- { maxWidth: params.maxSize ?? 2000, maxHeight: params.maxSize ?? 2000 }
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
- const result: ToolResult = {
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
- const result: ToolResult = {
151
- content: [
152
- { type: "text" as const, text: note },
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
- onUpdate?.(result)
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, loadConfig } from "./config.js"
4
- import { registerLovelyWebTools } from "./tools.js"
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
- applyToolConfig(pi, loadConfig(ctx.cwd))
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(source: string | undefined, query: string, count: number): string {
57
- const encodedQuery = encodeURIComponent(query)
58
- if (source === "news") {
59
- return `${BASE_URL}/news/search?q=${encodedQuery}&count=${count}`
60
- }
61
- if (source === "images") {
62
- return `${BASE_URL}/images/search?q=${encodedQuery}&count=${count}`
63
- }
64
- return `${BASE_URL}/web/search?q=${encodedQuery}&count=${count}`
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