@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 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 (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
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
- ![web_image](assets/web_image.png)
44
+ ![web_image](https://raw.githubusercontent.com/xl0/pi-lovely-web/master/assets/web_image.png)
46
45
 
47
46
 
48
47
  ## Configuration
49
48
 
50
49
  Run `/lovely-web` in Pi to configure providers interactively:
51
50
 
52
- ![alt text](assets/settings.png)
51
+ ![settings](https://raw.githubusercontent.com/xl0/pi-lovely-web/master/assets/settings.png)
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
- "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
+ "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 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`.
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 { 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 { 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
- 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
- }
16
+ try {
17
+ const config = loadScopedConfig(ctx.cwd)
18
+ notifyConfigWarnings(ctx, config.warnings)
64
19
 
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)
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
- 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
- })
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, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
- import { homedir } from "node:os"
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, WebToolsConfig } from "./providers/types.js"
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 DISABLED_LABEL = "Disabled"
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
- export const providerNames = Object.keys(providers)
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.webSearch?.provider !== null
76
+ return config.webSearchProvider !== DISABLED_VALUE
26
77
  }
27
78
 
28
79
  export function isFetchEnabled(config: WebToolsConfig): boolean {
29
- return config.webFetch?.provider !== null
80
+ return config.webFetchProvider !== DISABLED_VALUE
30
81
  }
31
82
 
32
83
  export function isImageEnabled(config: WebToolsConfig): boolean {
33
- return config.webImage?.enabled !== false
84
+ return config.webImageEnabled
34
85
  }
35
86
 
36
87
  export function isImageResizeEnabled(config: WebToolsConfig): boolean {
37
- return config.webImage?.resize !== false
88
+ return config.webImageResize
38
89
  }
39
90
 
40
91
  export function getImageMaxSize(config: WebToolsConfig): number {
41
- return config.webImage?.maxSize ?? 2000
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 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
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 key = config.webApiKeys?.[provider.id]
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
- 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
- }
128
+ return loadScopedConfig(cwd).value
98
129
  }
99
130
 
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")
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
- 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) : ""}`
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}. ${title}`]
38
+ const lines = [`${index + 1}.`, ...field("title", title)]
28
39
 
29
- if (url) lines.push(` ${url}`)
40
+ if (url) lines.push(...field("url", url))
30
41
  if (description && !(index === 0 && markdown)) {
31
- lines.push(` ${truncate(description, MAX_DESCRIPTION_LENGTH)}`)
42
+ lines.push(...field("desc", truncate(description, MAX_DESCRIPTION_LENGTH)))
32
43
  }
33
- if (index === 0 && markdown) lines.push("", " Markdown:", markdown)
44
+ if (index === 0 && markdown) lines.push(" markdown:", markdown)
34
45
 
35
46
  return lines.join("\n")
36
47
  })