@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 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 (list of results + markdown of the first result)
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
- > `web_search "pi coding agent harness earendil" (web, limit 5, fetch first)`
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. 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…
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
  ![web_image](https://raw.githubusercontent.com/xl0/pi-lovely-web/master/assets/web_image.png)
@@ -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
- "webSearch": { "provider": "firecrawl" },
59
- "webFetch": { "provider": "firecrawl" },
60
- "webImage": { "enabled": true },
61
- "webApiKeys": {
62
- "firecrawl": "fc-...",
63
- "exa": "...",
64
- "tavily": "...",
65
- "brave": "..."
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 and fetch default to Firecrawl and can use different providers. Set `provider:null`
73
- on `webSearch` or `webFetch` to remove that tool from Pi's active tool list. Set
74
- `webImage.enabled:false` to disable `web_image`.
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 { 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
- 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
- const scope = await ctx.ui.select("Config scope:", ["Global (~/.pi/agent/)", "Project (.pi/)"])
53
- if (scope === undefined) return
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
- await ctx.ui.custom((_tui, theme, _keybindings, done) => {
66
- const searchLabels = [DISABLED_LABEL, ...providerNames.map(id => providers[id]?.label ?? id)]
67
- const fetchLabels = [DISABLED_LABEL, ...providerNames.filter(id => providers[id]?.fetch).map(id => providers[id]?.label ?? id)]
68
- const items: SettingItem[] = [
69
- {
70
- id: "search",
71
- label: "web_search",
72
- currentValue: isSearchEnabled(config) ? providerLabel(config.webSearch?.provider ?? DEFAULT_PROVIDER_ID) : DISABLED_LABEL,
73
- description: "Search provider, or disabled to remove web_search from active tools.",
74
- submenu: (currentValue, done) => providerSubmenu("Select search provider", searchLabels, currentValue, done)
75
- },
76
- {
77
- id: "fetch",
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)
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
- container.addChild(list)
170
- return {
171
- render: (width: number) => container.render(width),
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, 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"
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, WebToolsConfig } from "./providers/types.js"
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 DISABLED_LABEL = "Disabled"
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
- export const providerNames = Object.keys(providers)
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.webSearch?.provider !== null
135
+ return config.webSearchProvider !== DISABLED_VALUE
26
136
  }
27
137
 
28
138
  export function isFetchEnabled(config: WebToolsConfig): boolean {
29
- return config.webFetch?.provider !== null
139
+ return config.webFetchProvider !== DISABLED_VALUE
30
140
  }
31
141
 
32
142
  export function isImageEnabled(config: WebToolsConfig): boolean {
33
- return config.webImage?.enabled !== false
143
+ return config.webImageEnabled
34
144
  }
35
145
 
36
146
  export function isImageResizeEnabled(config: WebToolsConfig): boolean {
37
- return config.webImage?.resize !== false
147
+ return config.webImageResize
38
148
  }
39
149
 
40
150
  export function getImageMaxSize(config: WebToolsConfig): number {
41
- return config.webImage?.maxSize ?? 2000
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 direct = type === "search" ? config.webSearch?.provider : config.webFetch?.provider
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 key = config.webApiKeys?.[provider.id]
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
- const global = readConfigFile(join(homedir(), ".pi", "agent", "xl0-pi-lovely-web.json"))
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 writeConfigFile(path: string, config: WebToolsConfig): void {
101
- mkdirSync(resolve(path, ".."), { recursive: true })
102
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8")
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
- export function providerLabel(id: string | undefined): string {
117
- return id ? (providers[id]?.label ?? id) : "(not set)"
118
- }
119
-
120
- export function providerIdFromLabel(label: string): string | undefined {
121
- return providerNames.find(id => providers[id]?.label === label)
122
- }
123
-
124
- export function maskApiKey(key: string | undefined): string {
125
- if (!key) return "(not set)"
126
- const maskLength = Math.max(5, key.length - 8)
127
- const visible = Math.max(0, key.length - maskLength)
128
- const startLength = Math.min(4, Math.ceil(visible / 2))
129
- const endLength = Math.min(4, visible - startLength)
130
- return `${key.slice(0, startLength)}${"*".repeat(maskLength)}${endLength > 0 ? key.slice(-endLength) : ""}`
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