@xl0/pi-lovely-web 0.1.4 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -33
- package/extensions/lovely-web/command.ts +32 -163
- package/extensions/lovely-web/config.ts +178 -56
- package/extensions/lovely-web/constants.ts +1 -0
- package/extensions/lovely-web/find.ts +356 -0
- package/extensions/lovely-web/format.ts +15 -4
- package/extensions/lovely-web/image.ts +51 -62
- package/extensions/lovely-web/index.ts +16 -4
- package/extensions/lovely-web/providers/brave.ts +29 -14
- package/extensions/lovely-web/providers/exa.ts +20 -10
- package/extensions/lovely-web/providers/firecrawl.ts +37 -6
- package/extensions/lovely-web/providers/tavily.ts +44 -10
- package/extensions/lovely-web/providers/types.ts +28 -14
- package/extensions/lovely-web/render.ts +29 -3
- package/extensions/lovely-web/smart.ts +197 -0
- package/extensions/lovely-web/tools.ts +325 -75
- package/package.json +13 -4
- package/extensions/lovely-web/tool-impl.ts +0 -95
package/README.md
CHANGED
|
@@ -15,31 +15,30 @@ pi install npm:@xl0/pi-lovely-web
|
|
|
15
15
|
|
|
16
16
|
## Tools
|
|
17
17
|
|
|
18
|
-
- `web_search` - Compact search results
|
|
19
|
-
The plain-text tool output looks likes this:
|
|
18
|
+
- `web_search` - Compact search results. Set `fetchResult:true` to include markdown from the first result when `web_fetch` is configured.
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
The plain-text tool output looks like this:
|
|
21
|
+
|
|
22
|
+
> `web_search "pi coding agent harness earendil" (web, limit 5)`
|
|
22
23
|
|
|
23
24
|
```
|
|
24
|
-
1.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
and share via npm or git. Key ideas:
|
|
39
|
-
- Four modes: interactive TUI, print/JSON, RPC, and SDK (usable for embedd…
|
|
25
|
+
1.
|
|
26
|
+
title: GitHub - earendil-works/pi: AI agent toolkit
|
|
27
|
+
url: https://github.com/earendil-works/pi
|
|
28
|
+
desc: Pi is an AI agent toolkit for coding: CLI, unified LLM API, TUI/Web UI libraries, Slack bot, and vLLM pods.
|
|
29
|
+
|
|
30
|
+
2.
|
|
31
|
+
title: packages/coding-agent/README.md at main · earendil-works/pi
|
|
32
|
+
url: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md
|
|
33
|
+
desc: The page describes the pi project from earendil-works, a minimal, extensible terminal coding harness designed to adapt to your workflow.
|
|
34
|
+
|
|
35
|
+
3.
|
|
36
|
+
title: Pi Coding Agent
|
|
37
|
+
url: https://pi.dev/
|
|
38
|
+
desc: Pi Coding Agent is a minimal, highly customizable terminal coding harness.
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
- `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.
|
|
43
42
|
- `web_image` - The single image, returned as media content. Respects the Pi image resizing settings:
|
|
44
43
|
|
|
45
44
|

|
|
@@ -55,25 +54,51 @@ The settings are stored in `~/.pi/agent/xl0-pi-lovely-web.json` (global) or `.pi
|
|
|
55
54
|
|
|
56
55
|
```json
|
|
57
56
|
{
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
"webSearchProvider": "firecrawl",
|
|
58
|
+
"webFetchProvider": "firecrawl",
|
|
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...",
|
|
64
|
+
"firecrawlApiKey": "fc-...",
|
|
65
|
+
"exaApiKey": "...",
|
|
66
|
+
"tavilyApiKey": "...",
|
|
67
|
+
"braveApiKey": "..."
|
|
67
68
|
}
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
API keys can also be set via environment variables: `FIRECRAWL_API_KEY`, `EXA_API_KEY`, `TAVILY_API_KEY`, `BRAVE_API_KEY`.
|
|
71
72
|
|
|
72
|
-
Search
|
|
73
|
-
|
|
74
|
-
`
|
|
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`.
|
|
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
|
+
|
|
81
|
+
Old `xl0-web-tools.json` configs are migrated to `xl0-pi-lovely-web.json` on load, then deleted.
|
|
82
|
+
|
|
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.
|
|
84
|
+
|
|
85
|
+
Search params:
|
|
86
|
+
|
|
87
|
+
| Provider | Extra `web_search` params |
|
|
88
|
+
|----------|---------------------------|
|
|
89
|
+
| Firecrawl | `source` selects web/news/images; `category` filters to github/research/pdf; `location`/`country` localize; `tbs` applies Google-style time filters. |
|
|
90
|
+
| Exa | `category` narrows Exa's result type; `country` localizes. |
|
|
91
|
+
| Tavily | `topic` selects general/news/finance; `includeImages` returns image URLs; `country` localizes; `timeRange` limits recency. |
|
|
92
|
+
| Brave Search | `source` selects web/news/images; `country` localizes; `searchLang` sets language; `freshness` limits recency. |
|
|
93
|
+
|
|
94
|
+
Fetch params:
|
|
95
|
+
|
|
96
|
+
| Provider | Extra `web_fetch` params |
|
|
97
|
+
|----------|--------------------------|
|
|
98
|
+
| Firecrawl | `waitFor` waits before scraping, in ms. |
|
|
99
|
+
| Exa | `maxAgeHours` allows cached page content up to that age. |
|
|
100
|
+
| Tavily | `extractDepth` selects basic or advanced extraction. |
|
|
75
101
|
|
|
76
|
-
`waitFor` is provider-specific: Firecrawl supports it as an extra pre-capture delay; Exa and Tavily ignore it and `web_fetch` returns a warning if supplied.
|
|
77
102
|
|
|
78
103
|
`web_image` fetches a direct image URL without provider config/API keys and returns a short text note plus image content to vision-capable models, matching Pi's `read` image behavior. Supported MIME types: PNG, JPEG, WebP, GIF. Defaults to a 5 MB download cap and resizes through Pi's inline image helper before returning content.
|
|
79
104
|
|
|
@@ -85,3 +110,16 @@ on `webSearch` or `webFetch` to remove that tool from Pi's active tool list. Set
|
|
|
85
110
|
| Exa | ✓ | ✓ | `x-api-key` |
|
|
86
111
|
| Tavily | ✓ | ✓ | `Authorization: Bearer` |
|
|
87
112
|
| Brave Search | ✓ | - | `X-Subscription-Token` |
|
|
113
|
+
|
|
114
|
+
## Related projects
|
|
115
|
+
|
|
116
|
+
| | |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| [Pi Lovely Dev Tools](https://github.com/xl0/pi-lovely-dev-tools) | interactive debugging helpers `/tool` `/show-sysprompt` |
|
|
119
|
+
| [Pi Lovely Codex](https://github.com/xl0/pi-lovely-codex) | GPT fast mode and Codex-style `apply_patch` |
|
|
120
|
+
| [Pi Lovely IDE](https://github.com/xl0/pi-lovely-ide) | IDE integration |
|
|
121
|
+
| [Pi Lovely Config](https://github.com/xl0/pi-lovely-config) | scoped config helpers for Pi extensions |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
Like this work? [Hire me](https://alexey.work/cv?ref=pi-lovely-web)
|
|
@@ -1,44 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
DEFAULT_PROVIDER_ID,
|
|
8
|
-
DISABLED_LABEL,
|
|
9
|
-
getImageMaxSize,
|
|
10
|
-
isFetchEnabled,
|
|
11
|
-
isImageEnabled,
|
|
12
|
-
isImageResizeEnabled,
|
|
13
|
-
isSearchEnabled,
|
|
14
|
-
loadConfig,
|
|
15
|
-
maskApiKey,
|
|
16
|
-
providerIdFromLabel,
|
|
17
|
-
providerLabel,
|
|
18
|
-
providerNames,
|
|
19
|
-
providers,
|
|
20
|
-
readConfigFile,
|
|
21
|
-
writeConfigFile
|
|
22
|
-
} from "./config.js"
|
|
23
|
-
|
|
24
|
-
function providerSubmenu(title: string, labels: string[], currentValue: string, done: (selectedValue?: string) => void) {
|
|
25
|
-
const container = new Container()
|
|
26
|
-
container.addChild(new Text(title, 1, 1))
|
|
27
|
-
const list = new SelectList(
|
|
28
|
-
labels.map(label => ({ value: label, label })),
|
|
29
|
-
Math.min(labels.length, 10),
|
|
30
|
-
getSelectListTheme()
|
|
31
|
-
)
|
|
32
|
-
list.setSelectedIndex(Math.max(0, labels.indexOf(currentValue)))
|
|
33
|
-
list.onSelect = item => done(item.value)
|
|
34
|
-
list.onCancel = () => done(undefined)
|
|
35
|
-
container.addChild(list)
|
|
36
|
-
return {
|
|
37
|
-
render: (width: number) => container.render(width),
|
|
38
|
-
invalidate: () => container.invalidate(),
|
|
39
|
-
handleInput: (data: string) => list.handleInput(data)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { ScopedConfigEditor } from "@xl0/pi-lovely-config"
|
|
3
|
+
import { applyToolConfig, loadScopedConfig } from "./config.js"
|
|
4
|
+
import { asErrorMessage } from "./format.js"
|
|
5
|
+
import { resetSmartConfigState, validateSmartConfig } from "./smart.js"
|
|
6
|
+
import { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
|
|
42
7
|
|
|
43
8
|
export function registerLovelyWebCommand(pi: ExtensionAPI) {
|
|
44
9
|
pi.registerCommand("lovely-web", {
|
|
@@ -49,130 +14,34 @@ export function registerLovelyWebCommand(pi: ExtensionAPI) {
|
|
|
49
14
|
return
|
|
50
15
|
}
|
|
51
16
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const configPath = scope.startsWith("Global")
|
|
56
|
-
? join(homedir(), ".pi", "agent", "xl0-pi-lovely-web.json")
|
|
57
|
-
: resolve(ctx.cwd, ".pi", "xl0-pi-lovely-web.json")
|
|
58
|
-
|
|
59
|
-
const config = readConfigFile(configPath)
|
|
60
|
-
const save = () => {
|
|
61
|
-
writeConfigFile(configPath, config)
|
|
62
|
-
applyToolConfig(pi, loadConfig(ctx.cwd))
|
|
63
|
-
}
|
|
17
|
+
try {
|
|
18
|
+
const config = loadScopedConfig(ctx.cwd, ctx)
|
|
19
|
+
notifyConfigWarnings(ctx, config.warnings)
|
|
64
20
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
description: "Fetch provider, or disabled to remove web_fetch from active tools.",
|
|
81
|
-
submenu: (currentValue, done) => providerSubmenu("Select fetch provider", fetchLabels, currentValue, done)
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
id: "image",
|
|
85
|
-
label: "web_image",
|
|
86
|
-
currentValue: isImageEnabled(config) ? "enabled" : "disabled",
|
|
87
|
-
description: "Enable or disable direct image URL fetching.",
|
|
88
|
-
values: ["enabled", "disabled"]
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
id: "image-resize",
|
|
92
|
-
label: "Resize images",
|
|
93
|
-
currentValue: isImageResizeEnabled(config) ? "on" : "off",
|
|
94
|
-
description: "Resize fetched images to fit within the max size limit.",
|
|
95
|
-
values: ["on", "off"]
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
id: "image-max-size",
|
|
99
|
-
label: "Max image size",
|
|
100
|
-
currentValue: String(getImageMaxSize(config)),
|
|
101
|
-
description: "Maximum longest side in pixels for resized images.",
|
|
102
|
-
submenu: (_currentValue: string, done: (selectedValue?: string) => void) =>
|
|
103
|
-
new ExtensionInputComponent(
|
|
104
|
-
"Max image size (px):",
|
|
105
|
-
"Enter size",
|
|
106
|
-
value => {
|
|
107
|
-
const n = Number(value)
|
|
108
|
-
if (!Number.isFinite(n) || n < 1) {
|
|
109
|
-
done(undefined)
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
config.webImage ??= {}
|
|
113
|
-
config.webImage.maxSize = n
|
|
114
|
-
save()
|
|
115
|
-
done(String(n))
|
|
116
|
-
},
|
|
117
|
-
() => done(undefined),
|
|
118
|
-
{ tui: _tui }
|
|
119
|
-
)
|
|
120
|
-
},
|
|
121
|
-
...providerNames.map(id => ({
|
|
122
|
-
id: `key:${id}`,
|
|
123
|
-
label: `${providers[id]?.label ?? id} API key`,
|
|
124
|
-
currentValue: maskApiKey(config.webApiKeys?.[id]),
|
|
125
|
-
description: `Set API key for ${providers[id]?.label ?? id}.`,
|
|
126
|
-
submenu: (_currentValue: string, done: (selectedValue?: string) => void) =>
|
|
127
|
-
new ExtensionInputComponent(
|
|
128
|
-
`API key for ${providers[id]?.label ?? id}${config.webApiKeys?.[id] ? ` (current: ${maskApiKey(config.webApiKeys[id])})` : ""}:`,
|
|
129
|
-
"Enter API key",
|
|
130
|
-
value => {
|
|
131
|
-
config.webApiKeys ??= {}
|
|
132
|
-
config.webApiKeys[id] = value
|
|
133
|
-
save()
|
|
134
|
-
done(maskApiKey(value))
|
|
135
|
-
},
|
|
136
|
-
() => done(undefined),
|
|
137
|
-
{ tui: _tui }
|
|
138
|
-
)
|
|
139
|
-
}))
|
|
140
|
-
]
|
|
141
|
-
const container = new Container()
|
|
142
|
-
container.addChild(new Text(theme.fg("accent", theme.bold("Lovely Web")), 1, 1))
|
|
143
|
-
const list = new SettingsList(
|
|
144
|
-
items,
|
|
145
|
-
Math.min(items.length, 12),
|
|
146
|
-
getSettingsListTheme(),
|
|
147
|
-
(id, newValue) => {
|
|
148
|
-
if (id === "search") {
|
|
149
|
-
if (newValue === DISABLED_LABEL) config.webSearch = { provider: null }
|
|
150
|
-
else {
|
|
151
|
-
const providerId = providerIdFromLabel(newValue)
|
|
152
|
-
if (providerId) config.webSearch = { provider: providerId }
|
|
153
|
-
}
|
|
154
|
-
} else if (id === "fetch") {
|
|
155
|
-
if (newValue === DISABLED_LABEL) config.webFetch = { provider: null }
|
|
156
|
-
else {
|
|
157
|
-
const providerId = providerIdFromLabel(newValue)
|
|
158
|
-
if (providerId) config.webFetch = { provider: providerId }
|
|
159
|
-
}
|
|
160
|
-
} else if (id === "image") {
|
|
161
|
-
config.webImage = { ...config.webImage, enabled: newValue === "enabled" }
|
|
162
|
-
} else if (id === "image-resize") {
|
|
163
|
-
config.webImage = { ...config.webImage, resize: newValue === "on" }
|
|
164
|
-
}
|
|
165
|
-
save()
|
|
166
|
-
},
|
|
167
|
-
() => done(undefined)
|
|
21
|
+
await ctx.ui.custom<void>(
|
|
22
|
+
(tui, theme, _keybindings, done) =>
|
|
23
|
+
new ScopedConfigEditor({
|
|
24
|
+
tui,
|
|
25
|
+
theme,
|
|
26
|
+
config,
|
|
27
|
+
onChange(config) {
|
|
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)
|
|
33
|
+
},
|
|
34
|
+
done
|
|
35
|
+
})
|
|
168
36
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
invalidate: () => container.invalidate(),
|
|
173
|
-
handleInput: (data: string) => list.handleInput(data)
|
|
174
|
-
}
|
|
175
|
-
})
|
|
37
|
+
} catch (error) {
|
|
38
|
+
ctx.ui.notify(asErrorMessage(error), "error")
|
|
39
|
+
}
|
|
176
40
|
}
|
|
177
41
|
})
|
|
178
42
|
}
|
|
43
|
+
|
|
44
|
+
function notifyConfigWarnings(ctx: ExtensionCommandContext, warnings: ReturnType<typeof loadScopedConfig>["warnings"]): void {
|
|
45
|
+
if (warnings.length === 0) return
|
|
46
|
+
ctx.ui.notify(warnings.map(warning => `${warning.path}: ${warning.message}`).join("\n"), "warning")
|
|
47
|
+
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import type
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
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
|
-
import type { Provider
|
|
9
|
+
import type { Provider } from "./providers/types.js"
|
|
10
|
+
import { SMART_SYSTEM_PROMPT } from "./smart.js"
|
|
10
11
|
|
|
11
|
-
export const DEFAULT_TIMEOUT_MS = 30_000
|
|
12
12
|
export const DEFAULT_PROVIDER_ID = "firecrawl"
|
|
13
|
-
export const
|
|
13
|
+
export const CONFIG_FILE_NAME = "xl0-pi-lovely-web.json"
|
|
14
|
+
export const OLD_CONFIG_FILE_NAME = "xl0-web-tools.json"
|
|
15
|
+
export const DISABLED_VALUE = "disabled"
|
|
16
|
+
|
|
17
|
+
const searchProviderValues = ["firecrawl", "exa", "tavily", "brave"] as const
|
|
18
|
+
const fetchProviderValues = ["firecrawl", "exa", "tavily"] as const
|
|
19
|
+
const searchProviderSettingValues = [DISABLED_VALUE, ...searchProviderValues] as const
|
|
20
|
+
const fetchProviderSettingValues = [DISABLED_VALUE, ...fetchProviderValues] as const
|
|
21
|
+
type SmartModelConfigContext = Pick<ExtensionContext, "model" | "modelRegistry">
|
|
14
22
|
|
|
15
23
|
export const providers: Record<string, Provider> = {
|
|
16
24
|
firecrawl: firecrawlProvider,
|
|
@@ -19,35 +27,135 @@ export const providers: Record<string, Provider> = {
|
|
|
19
27
|
brave: braveProvider
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
const apiKeyFields = {
|
|
31
|
+
firecrawl: "firecrawlApiKey",
|
|
32
|
+
exa: "exaApiKey",
|
|
33
|
+
tavily: "tavilyApiKey",
|
|
34
|
+
brave: "braveApiKey"
|
|
35
|
+
} as const
|
|
36
|
+
|
|
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,
|
|
62
|
+
depth: 1,
|
|
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()
|
|
122
|
+
|
|
123
|
+
export type WebToolsConfig = ConfigFromSchema<typeof lovelyWebConfigSchema>
|
|
124
|
+
|
|
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
|
+
}
|
|
23
133
|
|
|
24
134
|
export function isSearchEnabled(config: WebToolsConfig): boolean {
|
|
25
|
-
return config.
|
|
135
|
+
return config.webSearchProvider !== DISABLED_VALUE
|
|
26
136
|
}
|
|
27
137
|
|
|
28
138
|
export function isFetchEnabled(config: WebToolsConfig): boolean {
|
|
29
|
-
return config.
|
|
139
|
+
return config.webFetchProvider !== DISABLED_VALUE
|
|
30
140
|
}
|
|
31
141
|
|
|
32
142
|
export function isImageEnabled(config: WebToolsConfig): boolean {
|
|
33
|
-
return config.
|
|
143
|
+
return config.webImageEnabled
|
|
34
144
|
}
|
|
35
145
|
|
|
36
146
|
export function isImageResizeEnabled(config: WebToolsConfig): boolean {
|
|
37
|
-
return config.
|
|
147
|
+
return config.webImageResize
|
|
38
148
|
}
|
|
39
149
|
|
|
40
150
|
export function getImageMaxSize(config: WebToolsConfig): number {
|
|
41
|
-
return config.
|
|
151
|
+
return config.webImageMaxSize
|
|
42
152
|
}
|
|
43
153
|
|
|
44
154
|
function resolveProviderId(type: "search" | "fetch", config: WebToolsConfig): string {
|
|
45
155
|
if (type === "search" && !isSearchEnabled(config)) throw new Error("web_search is disabled. Enable it via /lovely-web.")
|
|
46
156
|
if (type === "fetch" && !isFetchEnabled(config)) throw new Error("web_fetch is disabled. Enable it via /lovely-web.")
|
|
47
157
|
|
|
48
|
-
const
|
|
49
|
-
const fallback = type === "fetch" && isSearchEnabled(config) ? config.webSearch?.provider : undefined
|
|
50
|
-
const id = direct ?? fallback ?? DEFAULT_PROVIDER_ID
|
|
158
|
+
const id = type === "search" ? config.webSearchProvider : config.webFetchProvider
|
|
51
159
|
if (!providers[id]) throw new Error(`Unknown provider "${id}". Available: ${Object.keys(providers).join(", ")}.`)
|
|
52
160
|
return id
|
|
53
161
|
}
|
|
@@ -67,39 +175,22 @@ export function getProvider(type: "search" | "fetch", config: WebToolsConfig): P
|
|
|
67
175
|
}
|
|
68
176
|
|
|
69
177
|
export function resolveApiKey(provider: Provider, config: WebToolsConfig): string {
|
|
70
|
-
const
|
|
178
|
+
const keyField = apiKeyFields[provider.id as keyof typeof apiKeyFields]
|
|
179
|
+
const key = keyField ? config[keyField] : undefined
|
|
71
180
|
if (key) return key
|
|
72
181
|
const envKey = process.env[provider.envApiKey]
|
|
73
182
|
if (envKey) return envKey
|
|
74
183
|
throw new Error(`No API key for ${provider.label}. Set it via /lovely-web or set the ${provider.envApiKey} environment variable.`)
|
|
75
184
|
}
|
|
76
185
|
|
|
77
|
-
export function loadConfig(cwd: string): WebToolsConfig {
|
|
78
|
-
|
|
79
|
-
const project = readConfigFile(resolve(cwd, ".pi", "xl0-pi-lovely-web.json"))
|
|
80
|
-
return {
|
|
81
|
-
...global,
|
|
82
|
-
...project,
|
|
83
|
-
webSearch: { provider: DEFAULT_PROVIDER_ID, ...global.webSearch, ...project.webSearch },
|
|
84
|
-
webFetch: { provider: DEFAULT_PROVIDER_ID, ...global.webFetch, ...project.webFetch },
|
|
85
|
-
webImage: { ...global.webImage, ...project.webImage },
|
|
86
|
-
webApiKeys: { ...global.webApiKeys, ...project.webApiKeys }
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function readConfigFile(path: string): WebToolsConfig {
|
|
91
|
-
try {
|
|
92
|
-
if (!existsSync(path)) return {}
|
|
93
|
-
const raw = readFileSync(path, "utf-8")
|
|
94
|
-
return JSON.parse(raw) as WebToolsConfig
|
|
95
|
-
} catch {
|
|
96
|
-
return {}
|
|
97
|
-
}
|
|
186
|
+
export function loadConfig(cwd: string, ctx?: SmartModelConfigContext): WebToolsConfig {
|
|
187
|
+
return loadScopedConfig(cwd, ctx).value
|
|
98
188
|
}
|
|
99
189
|
|
|
100
|
-
export function
|
|
101
|
-
|
|
102
|
-
|
|
190
|
+
export function loadScopedConfig(cwd: string, ctx?: SmartModelConfigContext): ScopedConfig<WebToolsConfig> {
|
|
191
|
+
const config = createLovelyWebConfigSpec(ctx).load(cwd)
|
|
192
|
+
migrateOldConfig(config)
|
|
193
|
+
return config
|
|
103
194
|
}
|
|
104
195
|
|
|
105
196
|
export function applyToolConfig(pi: ExtensionAPI, config: WebToolsConfig): void {
|
|
@@ -113,19 +204,50 @@ export function applyToolConfig(pi: ExtensionAPI, config: WebToolsConfig): void
|
|
|
113
204
|
pi.setActiveTools([...active])
|
|
114
205
|
}
|
|
115
206
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
207
|
+
type OldConfig = {
|
|
208
|
+
webSearch?: { provider?: string | null }
|
|
209
|
+
webFetch?: { provider?: string | null }
|
|
210
|
+
webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
|
|
211
|
+
webApiKeys?: Record<string, string>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function migrateOldConfig(config: ScopedConfig<WebToolsConfig>): void {
|
|
215
|
+
for (const scope of config.scopes) {
|
|
216
|
+
const newPath = config.path(scope)
|
|
217
|
+
const oldPath = join(dirname(newPath), OLD_CONFIG_FILE_NAME)
|
|
218
|
+
if (!existsSync(oldPath)) continue
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const old = JSON.parse(readFileSync(oldPath, "utf-8")) as OldConfig
|
|
222
|
+
if (!old || typeof old !== "object" || Array.isArray(old)) throw new Error("old config must be an object")
|
|
223
|
+
const update = <Key extends keyof WebToolsConfig & string>(key: Key, value: WebToolsConfig[Key]) => {
|
|
224
|
+
if (config.scoped[scope][key] === undefined) config.update(scope, key, value)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const searchProvider = old.webSearch?.provider
|
|
228
|
+
if (searchProvider === null) update("webSearchProvider", DISABLED_VALUE)
|
|
229
|
+
else if (typeof searchProvider === "string" && (searchProviderSettingValues as readonly string[]).includes(searchProvider)) {
|
|
230
|
+
update("webSearchProvider", searchProvider as WebToolsConfig["webSearchProvider"])
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fetchProvider = old.webFetch?.provider
|
|
234
|
+
if (fetchProvider === null) update("webFetchProvider", DISABLED_VALUE)
|
|
235
|
+
else if (typeof fetchProvider === "string" && (fetchProviderSettingValues as readonly string[]).includes(fetchProvider)) {
|
|
236
|
+
update("webFetchProvider", fetchProvider as WebToolsConfig["webFetchProvider"])
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof old.webImage?.enabled === "boolean") update("webImageEnabled", old.webImage.enabled)
|
|
240
|
+
if (typeof old.webImage?.resize === "boolean") update("webImageResize", old.webImage.resize)
|
|
241
|
+
if (typeof old.webImage?.maxSize === "number" && old.webImage.maxSize >= 1) update("webImageMaxSize", old.webImage.maxSize)
|
|
242
|
+
|
|
243
|
+
for (const [providerId, key] of Object.entries(old.webApiKeys ?? {})) {
|
|
244
|
+
const keyField = apiKeyFields[providerId as keyof typeof apiKeyFields]
|
|
245
|
+
if (keyField && typeof key === "string") update(keyField, key)
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// Best-effort legacy migration: malformed/stale old config should not block startup.
|
|
249
|
+
} finally {
|
|
250
|
+
rmSync(oldPath, { force: true })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
131
253
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 30_000
|