@xl0/pi-lovely-web 0.2.1 → 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 CHANGED
@@ -38,7 +38,7 @@ The plain-text tool output looks like this:
38
38
  desc: Pi Coding Agent is a minimal, highly customizable terminal coding harness.
39
39
  ```
40
40
 
41
- - `web_fetch` - The single web page in markdown format
41
+ - `web_fetch` - The single web page in markdown format. Set `findText` to an array of strings to return snippets from the fetched markdown. Optional `findMode` is `fuzzy` (default, normalized chunk match), `exact` (case-sensitive literal), or `lower` (case-insensitive literal). When smart search is enabled, set `smartQuery` to have a Pi text model answer/extract from the fetched markdown; `findText` and `smartQuery` can be combined.
42
42
  - `web_image` - The single image, returned as media content. Respects the Pi image resizing settings:
43
43
 
44
44
  ![web_image](https://raw.githubusercontent.com/xl0/pi-lovely-web/master/assets/web_image.png)
@@ -57,6 +57,10 @@ The settings are stored in `~/.pi/agent/xl0-pi-lovely-web.json` (global) or `.pi
57
57
  "webSearchProvider": "firecrawl",
58
58
  "webFetchProvider": "firecrawl",
59
59
  "webImageEnabled": true,
60
+ "smartSearchEnabled": false,
61
+ "smartSearchModel": "anthropic/claude-sonnet-4-5",
62
+ "smartSearchMaxTokens": 2000,
63
+ "smartSearchSystemPrompt": "Process one web_fetch result for a coding agent.\nUse only facts explicitly stated in the provided page text.\n...",
60
64
  "firecrawlApiKey": "fc-...",
61
65
  "exaApiKey": "...",
62
66
  "tavilyApiKey": "...",
@@ -68,6 +72,12 @@ API keys can also be set via environment variables: `FIRECRAWL_API_KEY`, `EXA_AP
68
72
 
69
73
  Search defaults to Firecrawl. Fetch defaults to `disabled`; configure `webFetchProvider` to enable `web_fetch` and `fetchResult:true` first-result fetches from `web_search`. Set `webSearchProvider` or `webFetchProvider` to `disabled` to remove that tool from Pi's active tool list. Set `webImageEnabled:false` to disable `web_image`.
70
74
 
75
+ `web_fetch` also supports `findText`, an array of strings to search over fetched markdown. It returns deduped plain-text snippets with 500 characters of context; overlapping contexts are merged, each snippet lists matching queries and counts, UI rendering highlights hits, and returned snippets are capped to about 20k raw characters total. `findMode` defaults to `fuzzy`; `exact` preserves case, `lower` is case-insensitive literal, and `fuzzy` splits text on blank lines, normalizes accents/case/punctuation, scores chunks by query-token coverage plus typo-tolerant token matches, and highlights matched source tokens in the UI.
76
+
77
+ When `smartSearchEnabled` is true, `smartQuery` post-processes `web_fetch` output with a Pi text model. It supports grounded summaries, extraction, comparisons, troubleshooting, limits/config/API details, security/migration notes, and verbatim code/command/schema examples. The prompt adapts the output format to the query, uses only explicitly stated page facts, preserves exact concrete fields, quotes/source-contexts important claims when useful without repeating the single fetched URL in every evidence bullet, and says `Not found on page.` for absent requested info. `/lovely-web` populates `smartSearchModel` from authenticated Pi text models and defaults to the current model when unset; invalid stored values warn and fall back to the default. `smartSearchSystemPrompt` defaults to the built-in prompt and can be edited. `findText` and `smartQuery` are independent: both read the raw fetched markdown and their outputs are concatenated.
78
+
79
+ Smart input is limited to half the selected model context estimate. If model context is unknown, the fallback limit is about 60k estimated tokens. If input is trimmed, the tool result includes a visible note with kept/original character counts.
80
+
71
81
  Old `xl0-web-tools.json` configs are migrated to `xl0-pi-lovely-web.json` on load, then deleted.
72
82
 
73
83
  `web_search` and `web_fetch` parameters are provider-specific and update dynamically when you change providers. Changing providers changes the tool schema and potentially may confuse the model if you change the schema mid-session, but unlikely with modern LLMs.
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
2
2
  import { ScopedConfigEditor } from "@xl0/pi-lovely-config"
3
3
  import { applyToolConfig, loadScopedConfig } from "./config.js"
4
4
  import { asErrorMessage } from "./format.js"
5
+ import { resetSmartConfigState, validateSmartConfig } from "./smart.js"
5
6
  import { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
6
7
 
7
8
  export function registerLovelyWebCommand(pi: ExtensionAPI) {
@@ -14,7 +15,7 @@ export function registerLovelyWebCommand(pi: ExtensionAPI) {
14
15
  }
15
16
 
16
17
  try {
17
- const config = loadScopedConfig(ctx.cwd)
18
+ const config = loadScopedConfig(ctx.cwd, ctx)
18
19
  notifyConfigWarnings(ctx, config.warnings)
19
20
 
20
21
  await ctx.ui.custom<void>(
@@ -24,9 +25,11 @@ export function registerLovelyWebCommand(pi: ExtensionAPI) {
24
25
  theme,
25
26
  config,
26
27
  onChange(config) {
27
- registerLovelyWebSearchTool(pi, config.value)
28
- registerLovelyWebStaticTools(pi, config.value)
29
- applyToolConfig(pi, config.value)
28
+ resetSmartConfigState()
29
+ const value = validateSmartConfig(config.value, ctx) ? config.value : { ...config.value, smartSearchEnabled: false }
30
+ registerLovelyWebSearchTool(pi, value)
31
+ registerLovelyWebStaticTools(pi, value)
32
+ applyToolConfig(pi, value)
30
33
  },
31
34
  done
32
35
  })
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readFileSync, rmSync } from "node:fs"
2
2
  import { dirname, join } from "node:path"
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
- import { type ConfigFromSchema, defineScopedConfig, field } from "@xl0/pi-lovely-config"
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
4
+ import { type ConfigFromSchema, defineScopedConfig, field, type ScopedConfig } from "@xl0/pi-lovely-config"
5
5
  import { braveProvider } from "./providers/brave.js"
6
6
  import { exaProvider } from "./providers/exa.js"
7
7
  import { firecrawlProvider } from "./providers/firecrawl.js"
8
8
  import { tavilyProvider } from "./providers/tavily.js"
9
9
  import type { Provider } from "./providers/types.js"
10
+ import { SMART_SYSTEM_PROMPT } from "./smart.js"
10
11
 
11
12
  export const DEFAULT_PROVIDER_ID = "firecrawl"
12
13
  export const CONFIG_FILE_NAME = "xl0-pi-lovely-web.json"
@@ -17,6 +18,7 @@ const searchProviderValues = ["firecrawl", "exa", "tavily", "brave"] as const
17
18
  const fetchProviderValues = ["firecrawl", "exa", "tavily"] as const
18
19
  const searchProviderSettingValues = [DISABLED_VALUE, ...searchProviderValues] as const
19
20
  const fetchProviderSettingValues = [DISABLED_VALUE, ...fetchProviderValues] as const
21
+ type SmartModelConfigContext = Pick<ExtensionContext, "model" | "modelRegistry">
20
22
 
21
23
  export const providers: Record<string, Provider> = {
22
24
  firecrawl: firecrawlProvider,
@@ -32,45 +34,102 @@ const apiKeyFields = {
32
34
  brave: "braveApiKey"
33
35
  } as const
34
36
 
35
- const lovelyWebConfigSchema = {
36
- webSearchProvider: field.enum(searchProviderSettingValues, DEFAULT_PROVIDER_ID, {
37
- label: "web_search",
38
- description: "Search provider, or disabled to remove web_search from active tools."
39
- }),
40
- webFetchProvider: field.enum(fetchProviderSettingValues, DISABLED_VALUE, {
41
- label: "web_fetch",
42
- description: "Fetch provider, or disabled to remove web_fetch from active tools."
43
- }),
44
- webImageEnabled: field.boolean(true, {
45
- label: "web_image",
46
- description: "Enable or disable direct image URL fetching."
47
- }),
48
- webImageResize: field.boolean(true, {
49
- label: "Resize images",
50
- description: "Resize fetched images to fit within the max size limit.",
51
- depth: 1,
52
- visibleWhen: ({ get }) => get("webImageEnabled") === true
53
- }),
54
- webImageMaxSize: field.number(2000, {
55
- label: "Max image size",
56
- description: "Maximum longest side in pixels for resized images.",
57
- min: 1,
58
- step: 100,
37
+ function modelId(model: ExtensionContext["model"]): string | undefined {
38
+ return model ? `${model.provider}/${model.id}` : undefined
39
+ }
40
+
41
+ function smartSearchModelField(ctx?: SmartModelConfigContext) {
42
+ const availableModels = ctx?.modelRegistry.getAvailable().filter(model => model.input.includes("text")) ?? []
43
+ const [firstModelId, ...otherModelIds] = availableModels.map(model => `${model.provider}/${model.id}`)
44
+ if (firstModelId === undefined) {
45
+ return field.string("", {
46
+ label: "Smart search model",
47
+ description: "Text model as provider/model. No authenticated text models are currently available.",
48
+ depth: 1,
49
+ visibleWhen: ({ get }) => get("smartSearchEnabled") === true
50
+ })
51
+ }
52
+
53
+ const modelValues = [firstModelId, ...otherModelIds] as [string, ...string[]]
54
+ const currentModelId = modelId(ctx?.model)
55
+ const defaultModel = currentModelId && modelValues.includes(currentModelId) ? currentModelId : modelValues[0]
56
+ const valueDescriptions = Object.fromEntries(availableModels.map(model => [`${model.provider}/${model.id}`, model.name || model.id]))
57
+ return field.enum(modelValues, defaultModel, {
58
+ label: "Smart search model",
59
+ description: "Pi text model for smartQuery post-processing.",
60
+ search: true,
61
+ valueDescriptions,
59
62
  depth: 1,
60
- visibleWhen: ({ get }) => get("webImageEnabled") === true && get("webImageResize") === true
61
- }),
62
- firecrawlApiKey: field.string("", { label: "Firecrawl API key" }),
63
- exaApiKey: field.string("", { label: "Exa API key" }),
64
- tavilyApiKey: field.string("", { label: "Tavily API key" }),
65
- braveApiKey: field.string("", { label: "Brave API key" })
66
- } as const
63
+ visibleWhen: ({ get }) => get("smartSearchEnabled") === true
64
+ })
65
+ }
66
+
67
+ function createLovelyWebConfigSchema(ctx?: SmartModelConfigContext) {
68
+ return {
69
+ webSearchProvider: field.enum(searchProviderSettingValues, DEFAULT_PROVIDER_ID, {
70
+ label: "web_search",
71
+ description: "Search provider, or disabled to remove web_search from active tools."
72
+ }),
73
+ webFetchProvider: field.enum(fetchProviderSettingValues, DISABLED_VALUE, {
74
+ label: "web_fetch",
75
+ description: "Fetch provider, or disabled to remove web_fetch from active tools."
76
+ }),
77
+ webImageEnabled: field.boolean(true, {
78
+ label: "web_image",
79
+ description: "Enable or disable direct image URL fetching."
80
+ }),
81
+ webImageResize: field.boolean(true, {
82
+ label: "Resize images",
83
+ description: "Resize fetched images to fit within the max size limit.",
84
+ depth: 1,
85
+ visibleWhen: ({ get }) => get("webImageEnabled") === true
86
+ }),
87
+ webImageMaxSize: field.number(2000, {
88
+ label: "Max image size",
89
+ description: "Maximum longest side in pixels for resized images.",
90
+ min: 1,
91
+ step: 100,
92
+ depth: 1,
93
+ visibleWhen: ({ get }) => get("webImageEnabled") === true && get("webImageResize") === true
94
+ }),
95
+ smartSearchEnabled: field.boolean(false, {
96
+ label: "Smart search",
97
+ description: "Enable smartQuery post-processing for web_fetch."
98
+ }),
99
+ smartSearchModel: smartSearchModelField(ctx),
100
+ smartSearchMaxTokens: field.number(2000, {
101
+ label: "Smart search max tokens",
102
+ description: "Maximum output tokens for smartQuery post-processing.",
103
+ min: 1,
104
+ step: 100,
105
+ depth: 1,
106
+ visibleWhen: ({ get }) => get("smartSearchEnabled") === true
107
+ }),
108
+ smartSearchSystemPrompt: field.text(SMART_SYSTEM_PROMPT, {
109
+ label: "Smart search system prompt",
110
+ description: "System prompt for smartQuery post-processing. Unset to use the built-in default.",
111
+ depth: 1,
112
+ visibleWhen: ({ get }) => get("smartSearchEnabled") === true
113
+ }),
114
+ firecrawlApiKey: field.string("", { label: "Firecrawl API key" }),
115
+ exaApiKey: field.string("", { label: "Exa API key" }),
116
+ tavilyApiKey: field.string("", { label: "Tavily API key" }),
117
+ braveApiKey: field.string("", { label: "Brave API key" })
118
+ } as const
119
+ }
120
+
121
+ const lovelyWebConfigSchema = createLovelyWebConfigSchema()
67
122
 
68
123
  export type WebToolsConfig = ConfigFromSchema<typeof lovelyWebConfigSchema>
69
124
 
70
- export const lovelyWebConfigSpec = defineScopedConfig({
71
- fileName: CONFIG_FILE_NAME,
72
- schema: lovelyWebConfigSchema
73
- })
125
+ export const lovelyWebConfigSpec = createLovelyWebConfigSpec()
126
+
127
+ export function createLovelyWebConfigSpec(ctx?: SmartModelConfigContext): ScopedConfig<WebToolsConfig> {
128
+ return defineScopedConfig({
129
+ fileName: CONFIG_FILE_NAME,
130
+ schema: createLovelyWebConfigSchema(ctx)
131
+ }) as ScopedConfig<WebToolsConfig>
132
+ }
74
133
 
75
134
  export function isSearchEnabled(config: WebToolsConfig): boolean {
76
135
  return config.webSearchProvider !== DISABLED_VALUE
@@ -124,12 +183,12 @@ export function resolveApiKey(provider: Provider, config: WebToolsConfig): strin
124
183
  throw new Error(`No API key for ${provider.label}. Set it via /lovely-web or set the ${provider.envApiKey} environment variable.`)
125
184
  }
126
185
 
127
- export function loadConfig(cwd: string): WebToolsConfig {
128
- return loadScopedConfig(cwd).value
186
+ export function loadConfig(cwd: string, ctx?: SmartModelConfigContext): WebToolsConfig {
187
+ return loadScopedConfig(cwd, ctx).value
129
188
  }
130
189
 
131
- export function loadScopedConfig(cwd: string): typeof lovelyWebConfigSpec {
132
- const config = lovelyWebConfigSpec.load(cwd)
190
+ export function loadScopedConfig(cwd: string, ctx?: SmartModelConfigContext): ScopedConfig<WebToolsConfig> {
191
+ const config = createLovelyWebConfigSpec(ctx).load(cwd)
133
192
  migrateOldConfig(config)
134
193
  return config
135
194
  }
@@ -152,7 +211,7 @@ type OldConfig = {
152
211
  webApiKeys?: Record<string, string>
153
212
  }
154
213
 
155
- function migrateOldConfig(config: typeof lovelyWebConfigSpec): void {
214
+ function migrateOldConfig(config: ScopedConfig<WebToolsConfig>): void {
156
215
  for (const scope of config.scopes) {
157
216
  const newPath = config.path(scope)
158
217
  const oldPath = join(dirname(newPath), OLD_CONFIG_FILE_NAME)
@@ -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
+ }
@@ -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
3
  import { DEFAULT_TIMEOUT_MS } from "./constants.js"
4
- import type { ToolResult } from "./types.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,11 +62,11 @@ async function fetchImageContent(
63
62
  }
64
63
  }
65
64
 
66
- function textOnlyResult(text: string, details: Record<string, unknown>): ToolResult {
65
+ function textOnlyResult(text: string, details: Record<string, unknown>): AgentToolResult<unknown> {
67
66
  return { content: [{ type: "text" as const, text }], details }
68
67
  }
69
68
 
70
- function imageResult(text: string, image: { data: string; mimeType: string }, details: Record<string, unknown>): ToolResult {
69
+ function imageResult(text: string, image: { data: string; mimeType: string }, details: Record<string, unknown>): AgentToolResult<unknown> {
71
70
  return {
72
71
  content: [
73
72
  { type: "text" as const, text },
@@ -77,7 +76,7 @@ function imageResult(text: string, image: { data: string; mimeType: string }, de
77
76
  }
78
77
  }
79
78
 
80
- function emitResult(result: ToolResult, onUpdate?: (result: ToolResult) => void): ToolResult {
79
+ function emitResult(result: AgentToolResult<unknown>, onUpdate?: (result: AgentToolResult<unknown>) => void): AgentToolResult<unknown> {
81
80
  onUpdate?.(result)
82
81
  return result
83
82
  }
@@ -91,8 +90,8 @@ export async function imageImpl(
91
90
  maxSize?: number | undefined
92
91
  },
93
92
  signal?: AbortSignal,
94
- onUpdate?: (result: ToolResult) => void
95
- ): Promise<ToolResult> {
93
+ onUpdate?: (result: AgentToolResult<unknown>) => void
94
+ ): Promise<AgentToolResult<unknown>> {
96
95
  const maxBytes = params.maxBytes ?? DEFAULT_MAX_IMAGE_BYTES
97
96
  if (maxBytes > MAX_IMAGE_BYTES) throw new Error(`maxBytes cannot exceed ${MAX_IMAGE_BYTES}`)
98
97
 
@@ -2,17 +2,20 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
2
  import { registerLovelyWebCommand } from "./command.js"
3
3
  import { applyToolConfig, loadScopedConfig } from "./config.js"
4
4
  import { asErrorMessage } from "./format.js"
5
+ import { resetSmartRuntimeState, validateSmartConfig } from "./smart.js"
5
6
  import { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
6
7
 
7
8
  export default function (pi: ExtensionAPI) {
8
9
  registerLovelyWebStaticTools(pi)
9
10
  pi.on("session_start", async (_event, ctx) => {
10
11
  try {
11
- const loaded = loadScopedConfig(ctx.cwd)
12
+ resetSmartRuntimeState()
13
+ const loaded = loadScopedConfig(ctx.cwd, ctx)
12
14
  if (loaded.warnings.length > 0) ctx.ui.notify(loaded.warnings.map(w => `${w.path}: ${w.message}`).join("\n"), "warning")
13
- registerLovelyWebSearchTool(pi, loaded.value)
14
- registerLovelyWebStaticTools(pi, loaded.value)
15
- applyToolConfig(pi, loaded.value)
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)
16
19
  } catch (error) {
17
20
  ctx.ui.notify(`Lovely Web config error: ${asErrorMessage(error)}`, "error")
18
21
  }
@@ -1,10 +1,11 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent"
2
2
  import { Text } from "@earendil-works/pi-tui"
3
+ import { FIND_HIGHLIGHT_END, FIND_HIGHLIGHT_START } from "./find.js"
3
4
 
4
5
  const COLLAPSED_RESULT_LINES = 6
5
6
 
6
7
  export function renderTextResult(
7
- result: { content: Array<{ type: string; text?: string }> },
8
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
8
9
  expanded: boolean,
9
10
  theme: Theme,
10
11
  partialLabel: string
@@ -13,11 +14,36 @@ export function renderTextResult(
13
14
  if (content?.type !== "text" || content.text === undefined) return new Text(theme.fg("error", "No text output"), 0, 0)
14
15
  if (!content.text.trim()) return new Text(theme.fg("dim", partialLabel), 0, 0)
15
16
 
16
- const lines = content.text.split("\n")
17
+ const renderText = renderTextOverride(result.details) ?? content.text
18
+ const lines = renderText.split("\n")
17
19
  const shown = expanded ? lines : lines.slice(0, COLLAPSED_RESULT_LINES)
18
- let text = shown.map(line => theme.fg("toolOutput", line)).join("\n")
20
+ let text = shown.map(line => renderHighlightMarkers(line, theme)).join("\n")
19
21
  if (!expanded && lines.length > COLLAPSED_RESULT_LINES) {
20
22
  text += `\n${theme.fg("muted", `... ${lines.length - COLLAPSED_RESULT_LINES} more lines (ctrl-o to expand)`)}`
21
23
  }
22
24
  return new Text(text, 0, 0)
23
25
  }
26
+
27
+ function renderTextOverride(details: unknown): string | undefined {
28
+ if (!details || typeof details !== "object") return undefined
29
+ const renderText = (details as { renderText?: unknown }).renderText
30
+ return typeof renderText === "string" ? renderText : undefined
31
+ }
32
+
33
+ function renderHighlightMarkers(line: string, theme: Theme): string {
34
+ let rest = line
35
+ let output = ""
36
+ while (true) {
37
+ const start = rest.indexOf(FIND_HIGHLIGHT_START)
38
+ if (start === -1) return output + theme.fg("toolOutput", stripHighlightMarkers(rest))
39
+ const end = rest.indexOf(FIND_HIGHLIGHT_END, start + FIND_HIGHLIGHT_START.length)
40
+ if (end === -1) return output + theme.fg("toolOutput", stripHighlightMarkers(rest))
41
+ output += theme.fg("toolOutput", rest.slice(0, start))
42
+ output += theme.bold(theme.fg("accent", rest.slice(start + FIND_HIGHLIGHT_START.length, end)))
43
+ rest = rest.slice(end + FIND_HIGHLIGHT_END.length)
44
+ }
45
+ }
46
+
47
+ function stripHighlightMarkers(text: string): string {
48
+ return text.replaceAll(FIND_HIGHLIGHT_START, "").replaceAll(FIND_HIGHLIGHT_END, "")
49
+ }
@@ -0,0 +1,197 @@
1
+ import { type Api, type AssistantMessage, completeSimple, type Model, type SimpleStreamOptions } from "@earendil-works/pi-ai"
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
3
+ import type { WebToolsConfig } from "./config.js"
4
+
5
+ const SMART_INPUT_FALLBACK_CHARS = 60_000 * 3
6
+
7
+ export const SMART_SYSTEM_PROMPT = `Process one web_fetch result for a coding agent.
8
+ Use only facts explicitly stated in the provided page text.
9
+ Answer the smart query, not the whole page. Include neighboring/related sections only when needed for the requested task.
10
+
11
+ Return concise Markdown in the format that best fits the query:
12
+ - If the query asks for a specific format, use it.
13
+ - For verbatim code, commands, schemas, examples, config, or error text, return the requested excerpt first, exactly as written, preserving indentation and punctuation.
14
+ - For summaries, focus on what a coding agent needs: purpose, setup, APIs/options, steps, limits, caveats, security notes, migration/breaking changes, and gotchas that are directly stated.
15
+ - For extraction/comparison, use bullet points, preserve exact names, signatures, flags, codes, amounts, dates, deadlines, defaults, and version/platform caveats.
16
+ - For troubleshooting, include directly stated symptoms, causes, fixes/workarounds, and warnings.
17
+
18
+ Grounding rules:
19
+ - Include short exact quotes or source context for important claims unless the answer is only a verbatim excerpt.
20
+ - There is one fetched page; use a heading like "Evidence (on this page):" for an evidence section.
21
+ - If requested information is absent or partially absent, say "Not found on page." Mention missing fields under a short Missing section when useful.
22
+ - Use "None" only when the page explicitly supports it or the query only asks whether requested fields were found.
23
+ - For legal/admin pages, state only what the page says and flag when exact amounts/codes require a calculator, form, or agency confirmation.`
24
+
25
+ let warnedDefaultModel = false
26
+ let warnedUnavailableModel = false
27
+ let disabledForSession = false
28
+
29
+ export interface SmartProcessInput {
30
+ query: string
31
+ resultText: string
32
+ sourceUrl?: string
33
+ }
34
+
35
+ export interface SmartProcessResult {
36
+ text: string
37
+ details: {
38
+ query: string
39
+ model: string
40
+ inputChars: number
41
+ originalInputChars: number
42
+ maxInputChars: number
43
+ truncated: boolean
44
+ stopReason: AssistantMessage["stopReason"]
45
+ usage: AssistantMessage["usage"]
46
+ }
47
+ }
48
+
49
+ export function resetSmartRuntimeState(): void {
50
+ warnedDefaultModel = false
51
+ warnedUnavailableModel = false
52
+ disabledForSession = false
53
+ }
54
+
55
+ export function resetSmartConfigState(): void {
56
+ warnedUnavailableModel = false
57
+ disabledForSession = false
58
+ }
59
+
60
+ function modelName(model: Model<Api>): string {
61
+ return `${model.provider}/${model.id}`
62
+ }
63
+
64
+ function warn(ctx: ExtensionContext, message: string): void {
65
+ ctx.ui.notify(message, "warning")
66
+ }
67
+
68
+ function disableSmart(ctx: ExtensionContext, reason: string): void {
69
+ disabledForSession = true
70
+ if (warnedUnavailableModel) return
71
+ warnedUnavailableModel = true
72
+ warn(ctx, `Lovely Web smart search disabled: ${reason}`)
73
+ }
74
+
75
+ function resolveConfiguredModel(ctx: ExtensionContext, configured: string): Model<Api> | undefined {
76
+ const separator = configured.indexOf("/")
77
+ if (separator <= 0 || separator === configured.length - 1) {
78
+ disableSmart(ctx, `configured model must be "provider/model", got "${configured}".`)
79
+ return undefined
80
+ }
81
+
82
+ const provider = configured.slice(0, separator)
83
+ const modelId = configured.slice(separator + 1)
84
+ const model = ctx.modelRegistry.getAvailable().find(model => model.provider === provider && model.id === modelId)
85
+ if (!model) {
86
+ disableSmart(ctx, `configured model unavailable: ${configured}`)
87
+ return undefined
88
+ }
89
+ if (!model.input.includes("text")) {
90
+ disableSmart(ctx, `configured model does not support text input: ${configured}`)
91
+ return undefined
92
+ }
93
+ return model
94
+ }
95
+
96
+ function resolveCurrentModel(ctx: ExtensionContext): Model<Api> | undefined {
97
+ const model = ctx.model
98
+ if (!model) {
99
+ disableSmart(ctx, "no current model is selected.")
100
+ return undefined
101
+ }
102
+ if (!model.input.includes("text")) {
103
+ disableSmart(ctx, `current model does not support text input: ${modelName(model)}.`)
104
+ return undefined
105
+ }
106
+ if (!warnedDefaultModel) {
107
+ warnedDefaultModel = true
108
+ warn(ctx, `Lovely Web smart search model not selected; using current model ${modelName(model)}.`)
109
+ }
110
+ return model
111
+ }
112
+
113
+ function resolveSmartModel(config: WebToolsConfig, ctx: ExtensionContext): Model<Api> | undefined {
114
+ if (!config.smartSearchEnabled || disabledForSession) return undefined
115
+ const configured = config.smartSearchModel.trim()
116
+ return configured ? resolveConfiguredModel(ctx, configured) : resolveCurrentModel(ctx)
117
+ }
118
+
119
+ export function validateSmartConfig(config: WebToolsConfig, ctx: ExtensionContext): boolean {
120
+ if (!config.smartSearchEnabled) return true
121
+ return resolveSmartModel(config, ctx) !== undefined
122
+ }
123
+
124
+ function truncateForModel(text: string, model: Model<Api>): { text: string; originalChars: number; maxChars: number; truncated: boolean } {
125
+ const maxChars = model.contextWindow > 0 ? Math.max(4000, Math.floor((model.contextWindow / 2) * 3)) : SMART_INPUT_FALLBACK_CHARS
126
+ if (text.length <= maxChars) return { text, originalChars: text.length, maxChars, truncated: false }
127
+ return { text: text.slice(0, maxChars), originalChars: text.length, maxChars, truncated: true }
128
+ }
129
+
130
+ function extractText(message: AssistantMessage): string {
131
+ return message.content
132
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
133
+ .map(item => item.text)
134
+ .join("\n")
135
+ .trim()
136
+ }
137
+
138
+ function smartSystemPrompt(config: WebToolsConfig): string {
139
+ return config.smartSearchSystemPrompt.trim() ? config.smartSearchSystemPrompt : SMART_SYSTEM_PROMPT
140
+ }
141
+
142
+ export async function smartProcess(
143
+ config: WebToolsConfig,
144
+ ctx: ExtensionContext,
145
+ input: SmartProcessInput,
146
+ signal?: AbortSignal
147
+ ): Promise<SmartProcessResult | undefined> {
148
+ const model = resolveSmartModel(config, ctx)
149
+ if (!model) return undefined
150
+
151
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model)
152
+ if (!auth.ok) {
153
+ disableSmart(ctx, `model auth unavailable for ${modelName(model)}: ${auth.error}`)
154
+ return undefined
155
+ }
156
+
157
+ const maxTokens = Math.max(1, config.smartSearchMaxTokens)
158
+ const truncated = truncateForModel(input.resultText, model)
159
+ const sourceUrlText = input.sourceUrl ? `\n\nSource URL:\n${input.sourceUrl}` : ""
160
+ const prompt = `Smart query:\n${input.query}${sourceUrlText}\n\nResult text:\n${truncated.text}`
161
+ const options: SimpleStreamOptions = { maxTokens }
162
+ if (auth.apiKey !== undefined) options.apiKey = auth.apiKey
163
+ if (auth.headers !== undefined) options.headers = auth.headers
164
+ if (auth.env !== undefined) options.env = auth.env
165
+ if (signal !== undefined) options.signal = signal
166
+
167
+ const response = await completeSimple(
168
+ model,
169
+ {
170
+ systemPrompt: smartSystemPrompt(config),
171
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }]
172
+ },
173
+ options
174
+ )
175
+
176
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
177
+ throw new Error(response.errorMessage || response.stopReason)
178
+ }
179
+
180
+ const answer = extractText(response) || "Not found in provided results."
181
+ const truncationNotice = truncated.truncated
182
+ ? `\n\nNote: Smart input was truncated to ${truncated.text.length}/${truncated.originalChars} characters to stay within half of ${modelName(model)} context. Answer may omit trimmed content.`
183
+ : ""
184
+ return {
185
+ text: `${answer}${truncationNotice}`,
186
+ details: {
187
+ query: input.query,
188
+ model: modelName(model),
189
+ inputChars: truncated.text.length,
190
+ originalInputChars: truncated.originalChars,
191
+ maxInputChars: truncated.maxChars,
192
+ truncated: truncated.truncated,
193
+ stopReason: response.stopReason,
194
+ usage: response.usage
195
+ }
196
+ }
197
+ }
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
- import { Text } from "@earendil-works/pi-tui"
1
+ import { StringEnum } from "@earendil-works/pi-ai"
2
+ import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent"
3
+ import { type Component, Text, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"
3
4
  import { type TSchema, Type } from "typebox"
4
5
  import {
5
6
  DEFAULT_PROVIDER_ID,
@@ -15,11 +16,12 @@ import {
15
16
  type WebToolsConfig
16
17
  } from "./config.js"
17
18
  import { DEFAULT_TIMEOUT_MS } from "./constants.js"
19
+ import { type FindMode, formatFindTextMatches } from "./find.js"
18
20
  import { asErrorMessage, formatSearchOutput, stringify } from "./format.js"
19
21
  import { DEFAULT_MAX_IMAGE_BYTES, imageImpl, MAX_IMAGE_BYTES } from "./image.js"
20
22
  import type { FetchOptions, SearchOptions } from "./providers/types.js"
21
23
  import { renderTextResult } from "./render.js"
22
- import type { ToolResult } from "./types.js"
24
+ import { smartProcess } from "./smart.js"
23
25
 
24
26
  interface SearchToolArgs {
25
27
  query: string
@@ -40,9 +42,30 @@ interface SearchToolArgs {
40
42
  interface FetchToolArgs extends FetchOptions {
41
43
  url: string
42
44
  includeMetadata?: boolean
45
+ smartQuery?: string
46
+ findText?: string[]
47
+ findMode?: FindMode
43
48
  }
44
49
 
45
- async function fetchSearchResultImage(config: WebToolsConfig, url: string, signal?: AbortSignal): Promise<ToolResult> {
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>> {
46
69
  return imageImpl(
47
70
  {
48
71
  url,
@@ -87,13 +110,33 @@ function getSearchParameters(config: WebToolsConfig) {
87
110
  function getFetchParameters(config: WebToolsConfig) {
88
111
  const configured = config.webFetchProvider
89
112
  const providerId = configured && providers[configured]?.fetch ? configured : "firecrawl"
90
- const params: Record<string, TSchema> = {
113
+ const params: Record<string, TSchema> & { smartQuery?: TSchema } = {
91
114
  url: Type.String({ description: "The URL to fetch.", format: "uri" }),
92
115
  timeout: Type.Optional(Type.Integer({ description: "Request timeout in milliseconds. Defaults to 30000.", minimum: 1 })),
93
116
  includeMetadata: Type.Optional(
94
117
  Type.Boolean({
95
118
  description: "Append verbose page metadata to the markdown output. Defaults to false. Full metadata is always available in details."
96
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
+ })
97
140
  )
98
141
  }
99
142
 
@@ -101,6 +144,28 @@ function getFetchParameters(config: WebToolsConfig) {
101
144
  return Type.Object(params)
102
145
  }
103
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
+
104
169
  export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsConfig = lovelyWebConfigSpec.defaults) {
105
170
  pi.registerTool({
106
171
  name: "web_search",
@@ -128,7 +193,7 @@ export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsCo
128
193
  },
129
194
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
130
195
  try {
131
- const config = loadConfig(ctx.cwd)
196
+ const config = loadConfig(ctx.cwd, ctx)
132
197
  const searchProvider = getProvider("search", config)
133
198
  const input = params as unknown as SearchToolArgs
134
199
  onUpdate?.({
@@ -142,7 +207,7 @@ export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsCo
142
207
  if (signal?.aborted) throw new Error("Search cancelled")
143
208
 
144
209
  const first = searchResult.results[0]
145
- let fetchedImage: ToolResult | undefined
210
+ let fetchedImage: AgentToolResult<unknown> | undefined
146
211
  if (fetchResult === true && first?.url) {
147
212
  onUpdate?.({ content: [{ type: "text", text: `Fetching first result: ${first.url}` }], details: undefined })
148
213
  try {
@@ -163,18 +228,15 @@ export function registerLovelyWebSearchTool(pi: ExtensionAPI, config: WebToolsCo
163
228
  }
164
229
  }
165
230
 
166
- const result: ToolResult = {
231
+ const details = fetchedImage ? { search: searchResult.raw, image: fetchedImage.details } : searchResult.raw
232
+ const result: AgentToolResult<unknown> = {
167
233
  content: [{ type: "text", text: formatSearchOutput(searchResult.results) }, ...(fetchedImage?.content || [])],
168
- details: fetchedImage ? { search: searchResult.raw, image: fetchedImage.details } : searchResult.raw
234
+ details
169
235
  }
170
236
  onUpdate?.(result)
171
237
  return result
172
238
  } catch (error) {
173
- return {
174
- content: [{ type: "text", text: `Web search failed: ${asErrorMessage(error)}` }],
175
- details: { error: asErrorMessage(error) },
176
- isError: true
177
- }
239
+ throw new Error(`Web search failed: ${asErrorMessage(error)}`)
178
240
  }
179
241
  }
180
242
  })
@@ -187,13 +249,18 @@ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsC
187
249
  description: "Fetch a page as markdown. Metadata is verbose and opt-in.",
188
250
  promptSnippet: "Use web_fetch to fetch a URL as markdown.",
189
251
  promptGuidelines: [
190
- "Use web_fetch when you need the full readable markdown content of a known URL.",
191
- "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
+ : [])
192
259
  ],
193
260
  parameters: getFetchParameters(config),
194
261
  renderCall(args, theme, context) {
195
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0)
196
262
  const input = args as unknown as FetchToolArgs
263
+ const state = context.state as WebFetchRenderState
197
264
  const bits: string[] = []
198
265
  if (input.waitFor !== undefined) bits.push(`wait ${input.waitFor}ms`)
199
266
  if (input.maxAgeHours !== undefined) bits.push(`max age ${input.maxAgeHours}h`)
@@ -201,23 +268,45 @@ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsC
201
268
  if (input.timeout !== undefined) bits.push(`timeout ${input.timeout}ms`)
202
269
  if (input.includeMetadata) bits.push("metadata")
203
270
  const suffix = bits.length ? ` ${theme.fg("dim", `(${bits.join(", ")})`)}` : ""
204
- text.setText(`${theme.fg("toolTitle", theme.bold("web_fetch "))}${theme.fg("muted", input.url)}${suffix}`)
205
- 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)
206
284
  },
207
- 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
+ }
208
295
  return renderTextResult(result, expanded, theme, isPartial ? "Fetching..." : "No content")
209
296
  },
210
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 }
211
301
  try {
212
- const input = params as unknown as FetchToolArgs
213
- const config = loadConfig(ctx.cwd)
302
+ config = loadConfig(ctx.cwd, ctx)
214
303
  const fetchProvider = getProvider("fetch", config)
215
304
  onUpdate?.({
216
305
  content: [{ type: "text", text: `Fetching page with ${fetchProvider.label}: ${input.url}` }],
217
306
  details: undefined
218
307
  })
219
308
 
220
- const result = await fetchProvider.fetch(
309
+ result = await fetchProvider.fetch(
221
310
  resolveApiKey(fetchProvider, config),
222
311
  input.url,
223
312
  {
@@ -229,21 +318,56 @@ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsC
229
318
  signal
230
319
  )
231
320
  if (signal?.aborted) throw new Error("Fetch cancelled")
321
+ } catch (error) {
322
+ throw new Error(`Web fetch failed: ${asErrorMessage(error)}`)
323
+ }
232
324
 
233
- const metadata = input.includeMetadata && result.metadata ? `\n\nMetadata:\n${stringify(result.metadata)}` : ""
234
- const toolResult: ToolResult = {
235
- content: [{ type: "text", text: `${result.markdown}${metadata}` }],
236
- details: result.raw
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)
237
349
  }
238
- onUpdate?.(toolResult)
239
- return toolResult
240
- } catch (error) {
241
- return {
242
- content: [{ type: "text", text: `Web fetch failed: ${asErrorMessage(error)}` }],
243
- details: { error: asErrorMessage(error) },
244
- isError: true
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)
245
358
  }
359
+ extraDetails.findText = found.details
246
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
247
371
  }
248
372
  })
249
373
 
@@ -278,7 +402,7 @@ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsC
278
402
  },
279
403
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
280
404
  try {
281
- const config = loadConfig(ctx.cwd)
405
+ const config = loadConfig(ctx.cwd, ctx)
282
406
  if (!isImageEnabled(config)) throw new Error("web_image is disabled. Enable it via /lovely-web.")
283
407
  onUpdate?.({
284
408
  content: [{ type: "text", text: `Fetching image: ${params.url}` }],
@@ -296,11 +420,7 @@ export function registerLovelyWebStaticTools(pi: ExtensionAPI, config: WebToolsC
296
420
  onUpdate
297
421
  )
298
422
  } catch (error) {
299
- return {
300
- content: [{ type: "text", text: `Web image failed: ${asErrorMessage(error)}` }],
301
- details: { error: asErrorMessage(error) },
302
- isError: true
303
- }
423
+ throw new Error(`Web image failed: ${asErrorMessage(error)}`)
304
424
  }
305
425
  }
306
426
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xl0/pi-lovely-web",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Pi extension package for web_search, web_fetch, and web_image via Firecrawl, Exa, Tavily, and Brave.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,6 +28,7 @@
28
28
  "check": "bun run typecheck && bun run biome:check",
29
29
  "test": "bun run test/run.ts",
30
30
  "test:image": "bun run test/image.ts",
31
+ "test:smart": "bun run test/smart-prompt.ts",
31
32
  "test:update": "bun run test/update-references.ts",
32
33
  "release": "bun publish --access public",
33
34
  "lint": "biome lint .",
@@ -45,8 +46,9 @@
45
46
  "typescript": "^6.0.3"
46
47
  },
47
48
  "dependencies": {
48
- "@xl0/pi-lovely-config": "^0.1.0"
49
+ "@xl0/pi-lovely-config": "^0.1.1"
49
50
  },
51
+
50
52
  "peerDependencies": {
51
53
  "@earendil-works/pi-ai": ">=0.79.10",
52
54
  "@earendil-works/pi-coding-agent": ">=0.79.10",
@@ -1,7 +0,0 @@
1
- import type { ImageContent, TextContent } from "@earendil-works/pi-ai"
2
-
3
- export interface ToolResult {
4
- content: Array<TextContent | ImageContent>
5
- details: unknown
6
- isError?: boolean
7
- }