@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 +11 -1
- package/extensions/lovely-web/command.ts +7 -4
- package/extensions/lovely-web/config.ts +101 -42
- package/extensions/lovely-web/find.ts +356 -0
- package/extensions/lovely-web/image.ts +6 -7
- package/extensions/lovely-web/index.ts +7 -4
- package/extensions/lovely-web/render.ts +29 -3
- package/extensions/lovely-web/smart.ts +197 -0
- package/extensions/lovely-web/tools.ts +160 -40
- package/package.json +4 -2
- package/extensions/lovely-web/types.ts +0 -7
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
|

|
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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("
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
71
|
-
|
|
72
|
-
|
|
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):
|
|
132
|
-
const config =
|
|
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:
|
|
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>):
|
|
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>):
|
|
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:
|
|
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:
|
|
95
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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 =>
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
234
|
+
details
|
|
169
235
|
}
|
|
170
236
|
onUpdate?.(result)
|
|
171
237
|
return result
|
|
172
238
|
} catch (error) {
|
|
173
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|