@xl0/pi-lovely-web 0.1.3 → 0.2.1
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 +62 -34
- package/extensions/lovely-web/command.ts +29 -163
- package/extensions/lovely-web/config.ts +117 -54
- package/extensions/lovely-web/constants.ts +1 -0
- package/extensions/lovely-web/format.ts +15 -4
- package/extensions/lovely-web/image.ts +49 -59
- package/extensions/lovely-web/index.ts +13 -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/tools.ts +182 -52
- package/extensions/lovely-web/types.ts +7 -0
- package/package.json +13 -5
- package/extensions/lovely-web/tool-impl.ts +0 -95
package/README.md
CHANGED
|
@@ -15,65 +15,80 @@ 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
41
|
- `web_fetch` - The single web page in markdown format
|
|
43
42
|
- `web_image` - The single image, returned as media content. Respects the Pi image resizing settings:
|
|
44
43
|
|
|
45
|
-

|
|
44
|
+

|
|
46
45
|
|
|
47
46
|
|
|
48
47
|
## Configuration
|
|
49
48
|
|
|
50
49
|
Run `/lovely-web` in Pi to configure providers interactively:
|
|
51
50
|
|
|
52
|
-

|
|
53
52
|
|
|
54
53
|
The settings are stored in `~/.pi/agent/xl0-pi-lovely-web.json` (global) or `.pi/xl0-pi-lovely-web.json` (project):
|
|
55
54
|
|
|
56
55
|
```json
|
|
57
56
|
{
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"brave": "..."
|
|
66
|
-
}
|
|
57
|
+
"webSearchProvider": "firecrawl",
|
|
58
|
+
"webFetchProvider": "firecrawl",
|
|
59
|
+
"webImageEnabled": true,
|
|
60
|
+
"firecrawlApiKey": "fc-...",
|
|
61
|
+
"exaApiKey": "...",
|
|
62
|
+
"tavilyApiKey": "...",
|
|
63
|
+
"braveApiKey": "..."
|
|
67
64
|
}
|
|
68
65
|
```
|
|
69
66
|
|
|
70
67
|
API keys can also be set via environment variables: `FIRECRAWL_API_KEY`, `EXA_API_KEY`, `TAVILY_API_KEY`, `BRAVE_API_KEY`.
|
|
71
68
|
|
|
72
|
-
Search
|
|
73
|
-
|
|
74
|
-
`
|
|
69
|
+
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
|
+
|
|
71
|
+
Old `xl0-web-tools.json` configs are migrated to `xl0-pi-lovely-web.json` on load, then deleted.
|
|
72
|
+
|
|
73
|
+
`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.
|
|
74
|
+
|
|
75
|
+
Search params:
|
|
76
|
+
|
|
77
|
+
| Provider | Extra `web_search` params |
|
|
78
|
+
|----------|---------------------------|
|
|
79
|
+
| Firecrawl | `source` selects web/news/images; `category` filters to github/research/pdf; `location`/`country` localize; `tbs` applies Google-style time filters. |
|
|
80
|
+
| Exa | `category` narrows Exa's result type; `country` localizes. |
|
|
81
|
+
| Tavily | `topic` selects general/news/finance; `includeImages` returns image URLs; `country` localizes; `timeRange` limits recency. |
|
|
82
|
+
| Brave Search | `source` selects web/news/images; `country` localizes; `searchLang` sets language; `freshness` limits recency. |
|
|
83
|
+
|
|
84
|
+
Fetch params:
|
|
85
|
+
|
|
86
|
+
| Provider | Extra `web_fetch` params |
|
|
87
|
+
|----------|--------------------------|
|
|
88
|
+
| Firecrawl | `waitFor` waits before scraping, in ms. |
|
|
89
|
+
| Exa | `maxAgeHours` allows cached page content up to that age. |
|
|
90
|
+
| Tavily | `extractDepth` selects basic or advanced extraction. |
|
|
75
91
|
|
|
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
92
|
|
|
78
93
|
`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
94
|
|
|
@@ -85,3 +100,16 @@ on `webSearch` or `webFetch` to remove that tool from Pi's active tool list. Set
|
|
|
85
100
|
| Exa | ✓ | ✓ | `x-api-key` |
|
|
86
101
|
| Tavily | ✓ | ✓ | `Authorization: Bearer` |
|
|
87
102
|
| Brave Search | ✓ | - | `X-Subscription-Token` |
|
|
103
|
+
|
|
104
|
+
## Related projects
|
|
105
|
+
|
|
106
|
+
| | |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| [Pi Lovely Dev Tools](https://github.com/xl0/pi-lovely-dev-tools) | interactive debugging helpers `/tool` `/show-sysprompt` |
|
|
109
|
+
| [Pi Lovely Codex](https://github.com/xl0/pi-lovely-codex) | GPT fast mode and Codex-style `apply_patch` |
|
|
110
|
+
| [Pi Lovely IDE](https://github.com/xl0/pi-lovely-ide) | IDE integration |
|
|
111
|
+
| [Pi Lovely Config](https://github.com/xl0/pi-lovely-config) | scoped config helpers for Pi extensions |
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
Like this work? [Hire me](https://alexey.work/cv?ref=pi-lovely-web)
|
|
@@ -1,44 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
applyToolConfig,
|
|
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 { registerLovelyWebSearchTool, registerLovelyWebStaticTools } from "./tools.js"
|
|
42
6
|
|
|
43
7
|
export function registerLovelyWebCommand(pi: ExtensionAPI) {
|
|
44
8
|
pi.registerCommand("lovely-web", {
|
|
@@ -49,130 +13,32 @@ export function registerLovelyWebCommand(pi: ExtensionAPI) {
|
|
|
49
13
|
return
|
|
50
14
|
}
|
|
51
15
|
|
|
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
|
-
}
|
|
16
|
+
try {
|
|
17
|
+
const config = loadScopedConfig(ctx.cwd)
|
|
18
|
+
notifyConfigWarnings(ctx, config.warnings)
|
|
64
19
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
label: "web_fetch",
|
|
79
|
-
currentValue: isFetchEnabled(config) ? providerLabel(config.webFetch?.provider ?? DEFAULT_PROVIDER_ID) : DISABLED_LABEL,
|
|
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)
|
|
20
|
+
await ctx.ui.custom<void>(
|
|
21
|
+
(tui, theme, _keybindings, done) =>
|
|
22
|
+
new ScopedConfigEditor({
|
|
23
|
+
tui,
|
|
24
|
+
theme,
|
|
25
|
+
config,
|
|
26
|
+
onChange(config) {
|
|
27
|
+
registerLovelyWebSearchTool(pi, config.value)
|
|
28
|
+
registerLovelyWebStaticTools(pi, config.value)
|
|
29
|
+
applyToolConfig(pi, config.value)
|
|
30
|
+
},
|
|
31
|
+
done
|
|
32
|
+
})
|
|
168
33
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
invalidate: () => container.invalidate(),
|
|
173
|
-
handleInput: (data: string) => list.handleInput(data)
|
|
174
|
-
}
|
|
175
|
-
})
|
|
34
|
+
} catch (error) {
|
|
35
|
+
ctx.ui.notify(asErrorMessage(error), "error")
|
|
36
|
+
}
|
|
176
37
|
}
|
|
177
38
|
})
|
|
178
39
|
}
|
|
40
|
+
|
|
41
|
+
function notifyConfigWarnings(ctx: ExtensionCommandContext, warnings: ReturnType<typeof loadScopedConfig>["warnings"]): void {
|
|
42
|
+
if (warnings.length === 0) return
|
|
43
|
+
ctx.ui.notify(warnings.map(warning => `${warning.path}: ${warning.message}`).join("\n"), "warning")
|
|
44
|
+
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
3
|
-
import { join, resolve } from "node:path"
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
4
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { type ConfigFromSchema, defineScopedConfig, field } 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
10
|
|
|
11
|
-
export const DEFAULT_TIMEOUT_MS = 30_000
|
|
12
11
|
export const DEFAULT_PROVIDER_ID = "firecrawl"
|
|
13
|
-
export const
|
|
12
|
+
export const CONFIG_FILE_NAME = "xl0-pi-lovely-web.json"
|
|
13
|
+
export const OLD_CONFIG_FILE_NAME = "xl0-web-tools.json"
|
|
14
|
+
export const DISABLED_VALUE = "disabled"
|
|
15
|
+
|
|
16
|
+
const searchProviderValues = ["firecrawl", "exa", "tavily", "brave"] as const
|
|
17
|
+
const fetchProviderValues = ["firecrawl", "exa", "tavily"] as const
|
|
18
|
+
const searchProviderSettingValues = [DISABLED_VALUE, ...searchProviderValues] as const
|
|
19
|
+
const fetchProviderSettingValues = [DISABLED_VALUE, ...fetchProviderValues] as const
|
|
14
20
|
|
|
15
21
|
export const providers: Record<string, Provider> = {
|
|
16
22
|
firecrawl: firecrawlProvider,
|
|
@@ -19,35 +25,78 @@ export const providers: Record<string, Provider> = {
|
|
|
19
25
|
brave: braveProvider
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
const apiKeyFields = {
|
|
29
|
+
firecrawl: "firecrawlApiKey",
|
|
30
|
+
exa: "exaApiKey",
|
|
31
|
+
tavily: "tavilyApiKey",
|
|
32
|
+
brave: "braveApiKey"
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
const lovelyWebConfigSchema = {
|
|
36
|
+
webSearchProvider: field.enum(searchProviderSettingValues, DEFAULT_PROVIDER_ID, {
|
|
37
|
+
label: "web_search",
|
|
38
|
+
description: "Search provider, or disabled to remove web_search from active tools."
|
|
39
|
+
}),
|
|
40
|
+
webFetchProvider: field.enum(fetchProviderSettingValues, DISABLED_VALUE, {
|
|
41
|
+
label: "web_fetch",
|
|
42
|
+
description: "Fetch provider, or disabled to remove web_fetch from active tools."
|
|
43
|
+
}),
|
|
44
|
+
webImageEnabled: field.boolean(true, {
|
|
45
|
+
label: "web_image",
|
|
46
|
+
description: "Enable or disable direct image URL fetching."
|
|
47
|
+
}),
|
|
48
|
+
webImageResize: field.boolean(true, {
|
|
49
|
+
label: "Resize images",
|
|
50
|
+
description: "Resize fetched images to fit within the max size limit.",
|
|
51
|
+
depth: 1,
|
|
52
|
+
visibleWhen: ({ get }) => get("webImageEnabled") === true
|
|
53
|
+
}),
|
|
54
|
+
webImageMaxSize: field.number(2000, {
|
|
55
|
+
label: "Max image size",
|
|
56
|
+
description: "Maximum longest side in pixels for resized images.",
|
|
57
|
+
min: 1,
|
|
58
|
+
step: 100,
|
|
59
|
+
depth: 1,
|
|
60
|
+
visibleWhen: ({ get }) => get("webImageEnabled") === true && get("webImageResize") === true
|
|
61
|
+
}),
|
|
62
|
+
firecrawlApiKey: field.string("", { label: "Firecrawl API key" }),
|
|
63
|
+
exaApiKey: field.string("", { label: "Exa API key" }),
|
|
64
|
+
tavilyApiKey: field.string("", { label: "Tavily API key" }),
|
|
65
|
+
braveApiKey: field.string("", { label: "Brave API key" })
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
export type WebToolsConfig = ConfigFromSchema<typeof lovelyWebConfigSchema>
|
|
69
|
+
|
|
70
|
+
export const lovelyWebConfigSpec = defineScopedConfig({
|
|
71
|
+
fileName: CONFIG_FILE_NAME,
|
|
72
|
+
schema: lovelyWebConfigSchema
|
|
73
|
+
})
|
|
23
74
|
|
|
24
75
|
export function isSearchEnabled(config: WebToolsConfig): boolean {
|
|
25
|
-
return config.
|
|
76
|
+
return config.webSearchProvider !== DISABLED_VALUE
|
|
26
77
|
}
|
|
27
78
|
|
|
28
79
|
export function isFetchEnabled(config: WebToolsConfig): boolean {
|
|
29
|
-
return config.
|
|
80
|
+
return config.webFetchProvider !== DISABLED_VALUE
|
|
30
81
|
}
|
|
31
82
|
|
|
32
83
|
export function isImageEnabled(config: WebToolsConfig): boolean {
|
|
33
|
-
return config.
|
|
84
|
+
return config.webImageEnabled
|
|
34
85
|
}
|
|
35
86
|
|
|
36
87
|
export function isImageResizeEnabled(config: WebToolsConfig): boolean {
|
|
37
|
-
return config.
|
|
88
|
+
return config.webImageResize
|
|
38
89
|
}
|
|
39
90
|
|
|
40
91
|
export function getImageMaxSize(config: WebToolsConfig): number {
|
|
41
|
-
return config.
|
|
92
|
+
return config.webImageMaxSize
|
|
42
93
|
}
|
|
43
94
|
|
|
44
95
|
function resolveProviderId(type: "search" | "fetch", config: WebToolsConfig): string {
|
|
45
96
|
if (type === "search" && !isSearchEnabled(config)) throw new Error("web_search is disabled. Enable it via /lovely-web.")
|
|
46
97
|
if (type === "fetch" && !isFetchEnabled(config)) throw new Error("web_fetch is disabled. Enable it via /lovely-web.")
|
|
47
98
|
|
|
48
|
-
const
|
|
49
|
-
const fallback = type === "fetch" && isSearchEnabled(config) ? config.webSearch?.provider : undefined
|
|
50
|
-
const id = direct ?? fallback ?? DEFAULT_PROVIDER_ID
|
|
99
|
+
const id = type === "search" ? config.webSearchProvider : config.webFetchProvider
|
|
51
100
|
if (!providers[id]) throw new Error(`Unknown provider "${id}". Available: ${Object.keys(providers).join(", ")}.`)
|
|
52
101
|
return id
|
|
53
102
|
}
|
|
@@ -67,7 +116,8 @@ export function getProvider(type: "search" | "fetch", config: WebToolsConfig): P
|
|
|
67
116
|
}
|
|
68
117
|
|
|
69
118
|
export function resolveApiKey(provider: Provider, config: WebToolsConfig): string {
|
|
70
|
-
const
|
|
119
|
+
const keyField = apiKeyFields[provider.id as keyof typeof apiKeyFields]
|
|
120
|
+
const key = keyField ? config[keyField] : undefined
|
|
71
121
|
if (key) return key
|
|
72
122
|
const envKey = process.env[provider.envApiKey]
|
|
73
123
|
if (envKey) return envKey
|
|
@@ -75,31 +125,13 @@ export function resolveApiKey(provider: Provider, config: WebToolsConfig): strin
|
|
|
75
125
|
}
|
|
76
126
|
|
|
77
127
|
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
|
-
}
|
|
128
|
+
return loadScopedConfig(cwd).value
|
|
98
129
|
}
|
|
99
130
|
|
|
100
|
-
export function
|
|
101
|
-
|
|
102
|
-
|
|
131
|
+
export function loadScopedConfig(cwd: string): typeof lovelyWebConfigSpec {
|
|
132
|
+
const config = lovelyWebConfigSpec.load(cwd)
|
|
133
|
+
migrateOldConfig(config)
|
|
134
|
+
return config
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
export function applyToolConfig(pi: ExtensionAPI, config: WebToolsConfig): void {
|
|
@@ -113,19 +145,50 @@ export function applyToolConfig(pi: ExtensionAPI, config: WebToolsConfig): void
|
|
|
113
145
|
pi.setActiveTools([...active])
|
|
114
146
|
}
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
type OldConfig = {
|
|
149
|
+
webSearch?: { provider?: string | null }
|
|
150
|
+
webFetch?: { provider?: string | null }
|
|
151
|
+
webImage?: { enabled?: boolean; resize?: boolean; maxSize?: number }
|
|
152
|
+
webApiKeys?: Record<string, string>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function migrateOldConfig(config: typeof lovelyWebConfigSpec): void {
|
|
156
|
+
for (const scope of config.scopes) {
|
|
157
|
+
const newPath = config.path(scope)
|
|
158
|
+
const oldPath = join(dirname(newPath), OLD_CONFIG_FILE_NAME)
|
|
159
|
+
if (!existsSync(oldPath)) continue
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const old = JSON.parse(readFileSync(oldPath, "utf-8")) as OldConfig
|
|
163
|
+
if (!old || typeof old !== "object" || Array.isArray(old)) throw new Error("old config must be an object")
|
|
164
|
+
const update = <Key extends keyof WebToolsConfig & string>(key: Key, value: WebToolsConfig[Key]) => {
|
|
165
|
+
if (config.scoped[scope][key] === undefined) config.update(scope, key, value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const searchProvider = old.webSearch?.provider
|
|
169
|
+
if (searchProvider === null) update("webSearchProvider", DISABLED_VALUE)
|
|
170
|
+
else if (typeof searchProvider === "string" && (searchProviderSettingValues as readonly string[]).includes(searchProvider)) {
|
|
171
|
+
update("webSearchProvider", searchProvider as WebToolsConfig["webSearchProvider"])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fetchProvider = old.webFetch?.provider
|
|
175
|
+
if (fetchProvider === null) update("webFetchProvider", DISABLED_VALUE)
|
|
176
|
+
else if (typeof fetchProvider === "string" && (fetchProviderSettingValues as readonly string[]).includes(fetchProvider)) {
|
|
177
|
+
update("webFetchProvider", fetchProvider as WebToolsConfig["webFetchProvider"])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (typeof old.webImage?.enabled === "boolean") update("webImageEnabled", old.webImage.enabled)
|
|
181
|
+
if (typeof old.webImage?.resize === "boolean") update("webImageResize", old.webImage.resize)
|
|
182
|
+
if (typeof old.webImage?.maxSize === "number" && old.webImage.maxSize >= 1) update("webImageMaxSize", old.webImage.maxSize)
|
|
183
|
+
|
|
184
|
+
for (const [providerId, key] of Object.entries(old.webApiKeys ?? {})) {
|
|
185
|
+
const keyField = apiKeyFields[providerId as keyof typeof apiKeyFields]
|
|
186
|
+
if (keyField && typeof key === "string") update(keyField, key)
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Best-effort legacy migration: malformed/stale old config should not block startup.
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(oldPath, { force: true })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
131
194
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 30_000
|
|
@@ -15,6 +15,17 @@ function truncate(text: string, maxLen: number): string {
|
|
|
15
15
|
return `${text.slice(0, maxLen)}…`
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function indent(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.split("\n")
|
|
21
|
+
.map(line => ` ${line}`)
|
|
22
|
+
.join("\n")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function field(label: string, value: string): string[] {
|
|
26
|
+
return value.includes("\n") ? [` ${label}:`, indent(value)] : [` ${label}: ${value}`]
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export function formatSearchOutput(results: SearchResult[]) {
|
|
19
30
|
if (results.length === 0) return "No results."
|
|
20
31
|
|
|
@@ -24,13 +35,13 @@ export function formatSearchOutput(results: SearchResult[]) {
|
|
|
24
35
|
const url = result.url
|
|
25
36
|
const description = result.description
|
|
26
37
|
const markdown = result.markdown?.trim()
|
|
27
|
-
const lines = [`${index + 1}
|
|
38
|
+
const lines = [`${index + 1}.`, ...field("title", title)]
|
|
28
39
|
|
|
29
|
-
if (url) lines.push(
|
|
40
|
+
if (url) lines.push(...field("url", url))
|
|
30
41
|
if (description && !(index === 0 && markdown)) {
|
|
31
|
-
lines.push(
|
|
42
|
+
lines.push(...field("desc", truncate(description, MAX_DESCRIPTION_LENGTH)))
|
|
32
43
|
}
|
|
33
|
-
if (index === 0 && markdown) lines.push("
|
|
44
|
+
if (index === 0 && markdown) lines.push(" markdown:", markdown)
|
|
34
45
|
|
|
35
46
|
return lines.join("\n")
|
|
36
47
|
})
|