@xl0/pi-lovely-web 0.1.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/LICENSE +21 -0
- package/README.md +87 -0
- package/extensions/lovely-web/command.ts +177 -0
- package/extensions/lovely-web/config.ts +134 -0
- package/extensions/lovely-web/format.ts +38 -0
- package/extensions/lovely-web/image.ts +167 -0
- package/extensions/lovely-web/index.ts +12 -0
- package/extensions/lovely-web/providers/brave.ts +103 -0
- package/extensions/lovely-web/providers/exa.ts +112 -0
- package/extensions/lovely-web/providers/firecrawl.ts +91 -0
- package/extensions/lovely-web/providers/http.ts +21 -0
- package/extensions/lovely-web/providers/tavily.ts +131 -0
- package/extensions/lovely-web/providers/types.ts +31 -0
- package/extensions/lovely-web/render.ts +23 -0
- package/extensions/lovely-web/tool-impl.ts +95 -0
- package/extensions/lovely-web/tools.ts +182 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xl0
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# pi-lovely-web
|
|
2
|
+
|
|
3
|
+
Lovely Pi extension for accessing the web.
|
|
4
|
+
|
|
5
|
+
## Supply chain
|
|
6
|
+
|
|
7
|
+
This extension uses plain REST API - not the provider-specific packages.
|
|
8
|
+
We add zero dependencies, minimizing the supply chain attack surface.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@xl0/pi-lovely-web
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Tools
|
|
17
|
+
|
|
18
|
+
- `web_search` - Compact search results (list of results + markdown of the first result)
|
|
19
|
+
The plain-text tool output looks likes this:
|
|
20
|
+
|
|
21
|
+
> `web_search "pi coding agent harness earendil" (web, limit 5, fetch first)`
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
1. GitHub - earendil-works/pi: AI agent toolkit
|
|
25
|
+
https://github.com/earendil-works/pi
|
|
26
|
+
Markdown:
|
|
27
|
+
[markdown of the first hit if `fetch_first` is not false]
|
|
28
|
+
|
|
29
|
+
2. packages/coding-agent/README.md at main · earendil-works/pi
|
|
30
|
+
https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md
|
|
31
|
+
The page describes the pi project from earendil-works, a minimal, extensible terminal coding harness designed to adapt to your workflow. Key points:
|
|
32
|
+
- Pi is an AI agent toolkit for coding: CLI, unified LLM API, TUI/Web UI libraries, Slack bot, and vLLM pods.
|
|
33
|
+
- Core idea: extendable with TypeScript E…
|
|
34
|
+
|
|
35
|
+
3. Pi Coding Agent
|
|
36
|
+
https://pi.dev/
|
|
37
|
+
Pi Coding Agent is a minimal, highly customizable terminal coding harness. It adapts to your workflow with extensible packages (extensions, skills, prompts, and themes) that you can bundle
|
|
38
|
+
and share via npm or git. Key ideas:
|
|
39
|
+
- Four modes: interactive TUI, print/JSON, RPC, and SDK (usable for embedd…
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `web_fetch` - The single web page in markdown format
|
|
43
|
+
- `web_image` - The single image, returned as media content. Respects the Pi image resizing settings:
|
|
44
|
+
|
|
45
|
+

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

|
|
53
|
+
|
|
54
|
+
The settings are stored in `~/.pi/agent/xl0-pi-lovely-web.json` (global) or `.pi/xl0-pi-lovely-web.json` (project):
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"webSearch": { "provider": "firecrawl", "enabled": true },
|
|
59
|
+
"webFetch": { "provider": "firecrawl", "enabled": true },
|
|
60
|
+
"webImage": { "enabled": true },
|
|
61
|
+
"webApiKeys": {
|
|
62
|
+
"firecrawl": "fc-...",
|
|
63
|
+
"exa": "...",
|
|
64
|
+
"tavily": "...",
|
|
65
|
+
"brave": "..."
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
API keys can also be set via environment variables: `FIRECRAWL_API_KEY`, `EXA_API_KEY`, `TAVILY_API_KEY`, `BRAVE_API_KEY`.
|
|
71
|
+
|
|
72
|
+
Search and fetch can use different providers. If only `webSearch.provider` is set,
|
|
73
|
+
`web_fetch` falls back to it when that provider supports fetch. Set `enabled:false`
|
|
74
|
+
on `webSearch`, `webFetch`, or `webImage` to remove that tool from Pi's active tool list.
|
|
75
|
+
|
|
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
|
+
|
|
78
|
+
`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
|
+
|
|
80
|
+
## Providers
|
|
81
|
+
|
|
82
|
+
| Provider | Search | Fetch | Auth |
|
|
83
|
+
|---------------|--------|-------|------|
|
|
84
|
+
| Firecrawl | ✓ | ✓ | `Authorization: Bearer` |
|
|
85
|
+
| Exa | ✓ | ✓ | `x-api-key` |
|
|
86
|
+
| Tavily | ✓ | ✓ | `Authorization: Bearer` |
|
|
87
|
+
| Brave Search | ✓ | - | `X-Subscription-Token` |
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import { type ExtensionAPI, ExtensionInputComponent, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { Container, SelectList, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui"
|
|
5
|
+
import {
|
|
6
|
+
applyToolConfig,
|
|
7
|
+
DISABLED_LABEL,
|
|
8
|
+
getImageMaxSize,
|
|
9
|
+
isFetchEnabled,
|
|
10
|
+
isImageEnabled,
|
|
11
|
+
isImageResizeEnabled,
|
|
12
|
+
isSearchEnabled,
|
|
13
|
+
loadConfig,
|
|
14
|
+
maskApiKey,
|
|
15
|
+
providerIdFromLabel,
|
|
16
|
+
providerLabel,
|
|
17
|
+
providerNames,
|
|
18
|
+
providers,
|
|
19
|
+
readConfigFile,
|
|
20
|
+
writeConfigFile
|
|
21
|
+
} from "./config.js"
|
|
22
|
+
|
|
23
|
+
function providerSubmenu(title: string, labels: string[], currentValue: string, done: (selectedValue?: string) => void) {
|
|
24
|
+
const container = new Container()
|
|
25
|
+
container.addChild(new Text(title, 1, 1))
|
|
26
|
+
const list = new SelectList(
|
|
27
|
+
labels.map(label => ({ value: label, label })),
|
|
28
|
+
Math.min(labels.length, 10),
|
|
29
|
+
getSelectListTheme()
|
|
30
|
+
)
|
|
31
|
+
list.setSelectedIndex(Math.max(0, labels.indexOf(currentValue)))
|
|
32
|
+
list.onSelect = item => done(item.value)
|
|
33
|
+
list.onCancel = () => done(undefined)
|
|
34
|
+
container.addChild(list)
|
|
35
|
+
return {
|
|
36
|
+
render: (width: number) => container.render(width),
|
|
37
|
+
invalidate: () => container.invalidate(),
|
|
38
|
+
handleInput: (data: string) => list.handleInput(data)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerLovelyWebCommand(pi: ExtensionAPI) {
|
|
43
|
+
pi.registerCommand("lovely-web", {
|
|
44
|
+
description: "Configure Lovely Web search, fetch, and image tools",
|
|
45
|
+
async handler(_args, ctx) {
|
|
46
|
+
if (!ctx.hasUI) {
|
|
47
|
+
ctx.ui.notify("The /lovely-web command is only available in interactive mode.", "warning")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scope = await ctx.ui.select("Config scope:", ["Global (~/.pi/agent/)", "Project (.pi/)"])
|
|
52
|
+
if (scope === undefined) return
|
|
53
|
+
|
|
54
|
+
const configPath = scope.startsWith("Global")
|
|
55
|
+
? join(homedir(), ".pi", "agent", "xl0-pi-lovely-web.json")
|
|
56
|
+
: resolve(ctx.cwd, ".pi", "xl0-pi-lovely-web.json")
|
|
57
|
+
|
|
58
|
+
const config = readConfigFile(configPath)
|
|
59
|
+
const save = () => {
|
|
60
|
+
writeConfigFile(configPath, config)
|
|
61
|
+
applyToolConfig(pi, loadConfig(ctx.cwd))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
65
|
+
const searchLabels = [DISABLED_LABEL, ...providerNames.map(id => providers[id]?.label ?? id)]
|
|
66
|
+
const fetchLabels = [DISABLED_LABEL, ...providerNames.filter(id => providers[id]?.fetch).map(id => providers[id]?.label ?? id)]
|
|
67
|
+
const items: SettingItem[] = [
|
|
68
|
+
{
|
|
69
|
+
id: "search",
|
|
70
|
+
label: "web_search",
|
|
71
|
+
currentValue: isSearchEnabled(config) ? providerLabel(config.webSearch?.provider) : DISABLED_LABEL,
|
|
72
|
+
description: "Search provider, or disabled to remove web_search from active tools.",
|
|
73
|
+
submenu: (currentValue, done) => providerSubmenu("Select search provider", searchLabels, currentValue, done)
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "fetch",
|
|
77
|
+
label: "web_fetch",
|
|
78
|
+
currentValue: isFetchEnabled(config) ? providerLabel(config.webFetch?.provider) : DISABLED_LABEL,
|
|
79
|
+
description: "Fetch provider, or disabled to remove web_fetch from active tools.",
|
|
80
|
+
submenu: (currentValue, done) => providerSubmenu("Select fetch provider", fetchLabels, currentValue, done)
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "image",
|
|
84
|
+
label: "web_image",
|
|
85
|
+
currentValue: isImageEnabled(config) ? "enabled" : "disabled",
|
|
86
|
+
description: "Enable or disable direct image URL fetching.",
|
|
87
|
+
values: ["enabled", "disabled"]
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "image-resize",
|
|
91
|
+
label: "Resize images",
|
|
92
|
+
currentValue: isImageResizeEnabled(config) ? "enabled" : "disabled",
|
|
93
|
+
description: "Resize fetched images to fit within the max size limit.",
|
|
94
|
+
values: ["enabled", "disabled"]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "image-max-size",
|
|
98
|
+
label: "Max image size",
|
|
99
|
+
currentValue: String(getImageMaxSize(config)),
|
|
100
|
+
description: "Maximum longest side in pixels for resized images.",
|
|
101
|
+
submenu: (_currentValue: string, done: (selectedValue?: string) => void) =>
|
|
102
|
+
new ExtensionInputComponent(
|
|
103
|
+
"Max image size (px):",
|
|
104
|
+
"Enter size",
|
|
105
|
+
value => {
|
|
106
|
+
const n = Number(value)
|
|
107
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
108
|
+
done(undefined)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
config.webImage ??= {}
|
|
112
|
+
config.webImage.maxSize = n
|
|
113
|
+
save()
|
|
114
|
+
done(String(n))
|
|
115
|
+
},
|
|
116
|
+
() => done(undefined),
|
|
117
|
+
{ tui: _tui }
|
|
118
|
+
)
|
|
119
|
+
},
|
|
120
|
+
...providerNames.map(id => ({
|
|
121
|
+
id: `key:${id}`,
|
|
122
|
+
label: `${providers[id]?.label ?? id} API key`,
|
|
123
|
+
currentValue: maskApiKey(config.webApiKeys?.[id]),
|
|
124
|
+
description: `Set API key for ${providers[id]?.label ?? id}.`,
|
|
125
|
+
submenu: (_currentValue: string, done: (selectedValue?: string) => void) =>
|
|
126
|
+
new ExtensionInputComponent(
|
|
127
|
+
`API key for ${providers[id]?.label ?? id}${config.webApiKeys?.[id] ? ` (current: ${maskApiKey(config.webApiKeys[id])})` : ""}:`,
|
|
128
|
+
"Enter API key",
|
|
129
|
+
value => {
|
|
130
|
+
config.webApiKeys ??= {}
|
|
131
|
+
config.webApiKeys[id] = value
|
|
132
|
+
save()
|
|
133
|
+
done(maskApiKey(value))
|
|
134
|
+
},
|
|
135
|
+
() => done(undefined),
|
|
136
|
+
{ tui: _tui }
|
|
137
|
+
)
|
|
138
|
+
}))
|
|
139
|
+
]
|
|
140
|
+
const container = new Container()
|
|
141
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Lovely Web")), 1, 1))
|
|
142
|
+
const list = new SettingsList(
|
|
143
|
+
items,
|
|
144
|
+
Math.min(items.length, 12),
|
|
145
|
+
getSettingsListTheme(),
|
|
146
|
+
(id, newValue) => {
|
|
147
|
+
if (id === "search") {
|
|
148
|
+
if (newValue === DISABLED_LABEL) config.webSearch = { ...config.webSearch, enabled: false }
|
|
149
|
+
else {
|
|
150
|
+
const providerId = providerIdFromLabel(newValue)
|
|
151
|
+
if (providerId) config.webSearch = { provider: providerId, enabled: true }
|
|
152
|
+
}
|
|
153
|
+
} else if (id === "fetch") {
|
|
154
|
+
if (newValue === DISABLED_LABEL) config.webFetch = { ...config.webFetch, enabled: false }
|
|
155
|
+
else {
|
|
156
|
+
const providerId = providerIdFromLabel(newValue)
|
|
157
|
+
if (providerId) config.webFetch = { provider: providerId, enabled: true }
|
|
158
|
+
}
|
|
159
|
+
} else if (id === "image") {
|
|
160
|
+
config.webImage = { ...config.webImage, enabled: newValue === "enabled" }
|
|
161
|
+
} else if (id === "image-resize") {
|
|
162
|
+
config.webImage = { ...config.webImage, resize: newValue === "enabled" }
|
|
163
|
+
}
|
|
164
|
+
save()
|
|
165
|
+
},
|
|
166
|
+
() => done(undefined)
|
|
167
|
+
)
|
|
168
|
+
container.addChild(list)
|
|
169
|
+
return {
|
|
170
|
+
render: (width: number) => container.render(width),
|
|
171
|
+
invalidate: () => container.invalidate(),
|
|
172
|
+
handleInput: (data: string) => list.handleInput(data)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join, resolve } from "node:path"
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
5
|
+
import { braveProvider } from "./providers/brave.js"
|
|
6
|
+
import { exaProvider } from "./providers/exa.js"
|
|
7
|
+
import { firecrawlProvider } from "./providers/firecrawl.js"
|
|
8
|
+
import { tavilyProvider } from "./providers/tavily.js"
|
|
9
|
+
import type { Provider, WebToolsConfig } from "./providers/types.js"
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TIMEOUT_MS = 30_000
|
|
12
|
+
export const DISABLED_LABEL = "Disabled"
|
|
13
|
+
|
|
14
|
+
export const providers: Record<string, Provider> = {
|
|
15
|
+
firecrawl: firecrawlProvider,
|
|
16
|
+
exa: exaProvider,
|
|
17
|
+
tavily: tavilyProvider,
|
|
18
|
+
brave: braveProvider
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const providerNames = Object.keys(providers)
|
|
22
|
+
|
|
23
|
+
export function isSearchEnabled(config: WebToolsConfig): boolean {
|
|
24
|
+
return config.webSearch?.enabled !== false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isFetchEnabled(config: WebToolsConfig): boolean {
|
|
28
|
+
return config.webFetch?.enabled !== false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isImageEnabled(config: WebToolsConfig): boolean {
|
|
32
|
+
return config.webImage?.enabled !== false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isImageResizeEnabled(config: WebToolsConfig): boolean {
|
|
36
|
+
return config.webImage?.resize !== false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getImageMaxSize(config: WebToolsConfig): number {
|
|
40
|
+
return config.webImage?.maxSize ?? 2000
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveProviderId(type: "search" | "fetch", config: WebToolsConfig): string {
|
|
44
|
+
if (type === "search" && !isSearchEnabled(config)) throw new Error("web_search is disabled. Enable it via /lovely-web.")
|
|
45
|
+
if (type === "fetch" && !isFetchEnabled(config)) throw new Error("web_fetch is disabled. Enable it via /lovely-web.")
|
|
46
|
+
|
|
47
|
+
const direct = type === "search" ? config.webSearch?.provider : config.webFetch?.provider
|
|
48
|
+
const fallback = type === "fetch" && isSearchEnabled(config) ? config.webSearch?.provider : undefined
|
|
49
|
+
const id = direct || fallback
|
|
50
|
+
if (!id) {
|
|
51
|
+
const hint = type === "search" ? "webSearch.provider" : "webFetch.provider"
|
|
52
|
+
throw new Error(`No ${type} provider configured. Set ${hint} via /lovely-web.`)
|
|
53
|
+
}
|
|
54
|
+
if (!providers[id]) throw new Error(`Unknown provider "${id}". Available: ${Object.keys(providers).join(", ")}.`)
|
|
55
|
+
return id
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getProvider(type: "fetch", config: WebToolsConfig): Provider & { fetch: NonNullable<Provider["fetch"]> }
|
|
59
|
+
export function getProvider(type: "search", config: WebToolsConfig): Provider
|
|
60
|
+
export function getProvider(type: "search" | "fetch", config: WebToolsConfig): Provider {
|
|
61
|
+
const id = resolveProviderId(type, config)
|
|
62
|
+
const provider = providers[id]
|
|
63
|
+
if (!provider) throw new Error(`Provider "${id}" not found.`)
|
|
64
|
+
if (type === "fetch" && !provider.fetch) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${provider.label} does not support fetching pages. Configure a fetch-capable provider (e.g. firecrawl, exa, tavily) via /lovely-web.`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return provider as Provider & { fetch: NonNullable<Provider["fetch"]> }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveApiKey(provider: Provider, config: WebToolsConfig): string {
|
|
73
|
+
const key = config.webApiKeys?.[provider.id]
|
|
74
|
+
if (key) return key
|
|
75
|
+
const envKey = process.env[provider.envApiKey]
|
|
76
|
+
if (envKey) return envKey
|
|
77
|
+
throw new Error(`No API key for ${provider.label}. Set it via /lovely-web or set the ${provider.envApiKey} environment variable.`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadConfig(cwd: string): WebToolsConfig {
|
|
81
|
+
const global = readConfigFile(join(homedir(), ".pi", "agent", "xl0-pi-lovely-web.json"))
|
|
82
|
+
const project = readConfigFile(resolve(cwd, ".pi", "xl0-pi-lovely-web.json"))
|
|
83
|
+
return {
|
|
84
|
+
...global,
|
|
85
|
+
...project,
|
|
86
|
+
webSearch: { ...global.webSearch, ...project.webSearch },
|
|
87
|
+
webFetch: { ...global.webFetch, ...project.webFetch },
|
|
88
|
+
webImage: { ...global.webImage, ...project.webImage },
|
|
89
|
+
webApiKeys: { ...global.webApiKeys, ...project.webApiKeys }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readConfigFile(path: string): WebToolsConfig {
|
|
94
|
+
try {
|
|
95
|
+
if (!existsSync(path)) return {}
|
|
96
|
+
const raw = readFileSync(path, "utf-8")
|
|
97
|
+
return JSON.parse(raw) as WebToolsConfig
|
|
98
|
+
} catch {
|
|
99
|
+
return {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeConfigFile(path: string, config: WebToolsConfig): void {
|
|
104
|
+
mkdirSync(resolve(path, ".."), { recursive: true })
|
|
105
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function applyToolConfig(pi: ExtensionAPI, config: WebToolsConfig): void {
|
|
109
|
+
const active = new Set(pi.getActiveTools())
|
|
110
|
+
if (isSearchEnabled(config)) active.add("web_search")
|
|
111
|
+
else active.delete("web_search")
|
|
112
|
+
if (isFetchEnabled(config)) active.add("web_fetch")
|
|
113
|
+
else active.delete("web_fetch")
|
|
114
|
+
if (isImageEnabled(config)) active.add("web_image")
|
|
115
|
+
else active.delete("web_image")
|
|
116
|
+
pi.setActiveTools([...active])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function providerLabel(id: string | undefined): string {
|
|
120
|
+
return id ? (providers[id]?.label ?? id) : "(not set)"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function providerIdFromLabel(label: string): string | undefined {
|
|
124
|
+
return providerNames.find(id => providers[id]?.label === label)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function maskApiKey(key: string | undefined): string {
|
|
128
|
+
if (!key) return "(not set)"
|
|
129
|
+
const maskLength = Math.max(5, key.length - 8)
|
|
130
|
+
const visible = Math.max(0, key.length - maskLength)
|
|
131
|
+
const startLength = Math.min(4, Math.ceil(visible / 2))
|
|
132
|
+
const endLength = Math.min(4, visible - startLength)
|
|
133
|
+
return `${key.slice(0, startLength)}${"*".repeat(maskLength)}${endLength > 0 ? key.slice(-endLength) : ""}`
|
|
134
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SearchResult } from "./providers/types.js"
|
|
2
|
+
|
|
3
|
+
export function stringify(value: unknown) {
|
|
4
|
+
return JSON.stringify(value, null, 2)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function asErrorMessage(error: unknown) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_DESCRIPTION_LENGTH = 300
|
|
12
|
+
|
|
13
|
+
function truncate(text: string, maxLen: number): string {
|
|
14
|
+
if (text.length <= maxLen) return text
|
|
15
|
+
return `${text.slice(0, maxLen)}…`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatSearchOutput(results: SearchResult[]) {
|
|
19
|
+
if (results.length === 0) return "No results."
|
|
20
|
+
|
|
21
|
+
return results
|
|
22
|
+
.map((result, index) => {
|
|
23
|
+
const title = result.title || "Untitled"
|
|
24
|
+
const url = result.url
|
|
25
|
+
const description = result.description
|
|
26
|
+
const markdown = result.markdown?.trim()
|
|
27
|
+
const lines = [`${index + 1}. ${title}`]
|
|
28
|
+
|
|
29
|
+
if (url) lines.push(` ${url}`)
|
|
30
|
+
if (description && !(index === 0 && markdown)) {
|
|
31
|
+
lines.push(` ${truncate(description, MAX_DESCRIPTION_LENGTH)}`)
|
|
32
|
+
}
|
|
33
|
+
if (index === 0 && markdown) lines.push("", " Markdown:", markdown)
|
|
34
|
+
|
|
35
|
+
return lines.join("\n")
|
|
36
|
+
})
|
|
37
|
+
.join("\n\n")
|
|
38
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { formatDimensionNote, type ResizedImage, resizeImage } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { getImageDimensions } from "@earendil-works/pi-tui"
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS } from "./config.js"
|
|
4
|
+
import type { ToolResult } from "./tool-impl.js"
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 5_000_000
|
|
7
|
+
export const MAX_IMAGE_BYTES = 20_000_000
|
|
8
|
+
const SUPPORTED_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"])
|
|
9
|
+
|
|
10
|
+
async function fetchImageContent(
|
|
11
|
+
url: string,
|
|
12
|
+
opts: { timeout: number; maxBytes: number },
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
): Promise<{ data: string; mimeType: string; bytes: number; contentLength?: number }> {
|
|
15
|
+
const controller = new AbortController()
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), opts.timeout)
|
|
17
|
+
const abort = () => controller.abort()
|
|
18
|
+
if (signal?.aborted) controller.abort()
|
|
19
|
+
else signal?.addEventListener("abort", abort, { once: true })
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, { signal: controller.signal })
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text()
|
|
25
|
+
throw new Error(`Image request failed (${res.status}): ${text}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mimeType = res.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
|
|
29
|
+
if (!mimeType || !SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
|
|
30
|
+
throw new Error(`Unsupported image content-type: ${mimeType || "missing"}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const contentLength = res.headers.get("content-length")
|
|
34
|
+
const parsedContentLength = contentLength ? Number(contentLength) : undefined
|
|
35
|
+
if (parsedContentLength !== undefined && parsedContentLength > opts.maxBytes) {
|
|
36
|
+
throw new Error(`Image too large: ${parsedContentLength} bytes exceeds ${opts.maxBytes}`)
|
|
37
|
+
}
|
|
38
|
+
if (!res.body) throw new Error("Image response had no body")
|
|
39
|
+
|
|
40
|
+
let bytes = 0
|
|
41
|
+
const chunks: Uint8Array[] = []
|
|
42
|
+
const reader = res.body.getReader()
|
|
43
|
+
while (true) {
|
|
44
|
+
const { done, value } = await reader.read()
|
|
45
|
+
if (done) break
|
|
46
|
+
bytes += value.byteLength
|
|
47
|
+
if (bytes > opts.maxBytes) {
|
|
48
|
+
await reader.cancel()
|
|
49
|
+
throw new Error(`Image too large: exceeded ${opts.maxBytes} bytes`)
|
|
50
|
+
}
|
|
51
|
+
chunks.push(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
data: Buffer.concat(chunks).toString("base64"),
|
|
56
|
+
mimeType,
|
|
57
|
+
bytes,
|
|
58
|
+
...(parsedContentLength !== undefined ? { contentLength: parsedContentLength } : {})
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
signal?.removeEventListener("abort", abort)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function imageImpl(
|
|
67
|
+
params: {
|
|
68
|
+
url: string
|
|
69
|
+
timeout?: number | undefined
|
|
70
|
+
maxBytes?: number | undefined
|
|
71
|
+
resize?: boolean | undefined
|
|
72
|
+
maxSize?: number | undefined
|
|
73
|
+
},
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
onUpdate?: (result: ToolResult) => void
|
|
76
|
+
): Promise<ToolResult> {
|
|
77
|
+
const maxBytes = params.maxBytes ?? DEFAULT_MAX_IMAGE_BYTES
|
|
78
|
+
if (maxBytes > MAX_IMAGE_BYTES) throw new Error(`maxBytes cannot exceed ${MAX_IMAGE_BYTES}`)
|
|
79
|
+
|
|
80
|
+
const image = await fetchImageContent(params.url, { timeout: params.timeout ?? DEFAULT_TIMEOUT_MS, maxBytes }, signal)
|
|
81
|
+
if (signal?.aborted) throw new Error("Image fetch cancelled")
|
|
82
|
+
|
|
83
|
+
const originalDimensions = getImageDimensions(image.data, image.mimeType) ?? undefined
|
|
84
|
+
|
|
85
|
+
if (params.resize === false) {
|
|
86
|
+
// Re-encode to sanitize, but don't downscale.
|
|
87
|
+
const validated = (await resizeImage(
|
|
88
|
+
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
89
|
+
{ maxWidth: Infinity, maxHeight: Infinity }
|
|
90
|
+
)) as ResizedImage | null
|
|
91
|
+
if (!validated) {
|
|
92
|
+
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded]`
|
|
93
|
+
const result: ToolResult = {
|
|
94
|
+
content: [{ type: "text" as const, text: note }],
|
|
95
|
+
details: {
|
|
96
|
+
url: params.url,
|
|
97
|
+
mimeType: image.mimeType,
|
|
98
|
+
bytes: image.bytes,
|
|
99
|
+
contentLength: image.contentLength
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
onUpdate?.(result)
|
|
103
|
+
return result
|
|
104
|
+
}
|
|
105
|
+
const dimensions = { widthPx: validated.width, heightPx: validated.height }
|
|
106
|
+
const note = `Fetched image [${validated.mimeType}]`
|
|
107
|
+
const result: ToolResult = {
|
|
108
|
+
content: [
|
|
109
|
+
{ type: "text" as const, text: note },
|
|
110
|
+
{ type: "image" as const, data: validated.data, mimeType: validated.mimeType }
|
|
111
|
+
],
|
|
112
|
+
details: {
|
|
113
|
+
url: params.url,
|
|
114
|
+
mimeType: validated.mimeType,
|
|
115
|
+
bytes: image.bytes,
|
|
116
|
+
contentLength: image.contentLength,
|
|
117
|
+
dimensions,
|
|
118
|
+
originalDimensions,
|
|
119
|
+
wasResized: validated.wasResized
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
onUpdate?.(result)
|
|
123
|
+
return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resized = (await resizeImage(
|
|
127
|
+
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
128
|
+
{ maxWidth: params.maxSize ?? 2000, maxHeight: params.maxSize ?? 2000 }
|
|
129
|
+
)) as ResizedImage | null
|
|
130
|
+
|
|
131
|
+
if (!resized) {
|
|
132
|
+
const note = `Fetched image [${image.mimeType}]\n[Image omitted: could not be decoded or resized below the inline image size limit.]`
|
|
133
|
+
const result: ToolResult = {
|
|
134
|
+
content: [{ type: "text" as const, text: note }],
|
|
135
|
+
details: {
|
|
136
|
+
url: params.url,
|
|
137
|
+
mimeType: image.mimeType,
|
|
138
|
+
bytes: image.bytes,
|
|
139
|
+
contentLength: image.contentLength,
|
|
140
|
+
dimensions: originalDimensions
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
onUpdate?.(result)
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dimensionNote = formatDimensionNote(resized)
|
|
148
|
+
const note = `Fetched image [${resized.mimeType}]${dimensionNote ? `\n${dimensionNote}` : ""}`
|
|
149
|
+
const dimensions = { widthPx: resized.width, heightPx: resized.height }
|
|
150
|
+
const result: ToolResult = {
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text" as const, text: note },
|
|
153
|
+
{ type: "image" as const, data: resized.data, mimeType: resized.mimeType }
|
|
154
|
+
],
|
|
155
|
+
details: {
|
|
156
|
+
url: params.url,
|
|
157
|
+
mimeType: resized.mimeType,
|
|
158
|
+
bytes: image.bytes,
|
|
159
|
+
contentLength: image.contentLength,
|
|
160
|
+
dimensions,
|
|
161
|
+
originalDimensions,
|
|
162
|
+
wasResized: resized.wasResized
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
onUpdate?.(result)
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { registerLovelyWebCommand } from "./command.js"
|
|
3
|
+
import { applyToolConfig, loadConfig } from "./config.js"
|
|
4
|
+
import { registerLovelyWebTools } from "./tools.js"
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
8
|
+
applyToolConfig(pi, loadConfig(ctx.cwd))
|
|
9
|
+
})
|
|
10
|
+
registerLovelyWebTools(pi)
|
|
11
|
+
registerLovelyWebCommand(pi)
|
|
12
|
+
}
|