webfox 4.2.2 → 4.4.0

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
@@ -25,11 +25,25 @@ configuration in your terminal, TypeScript applications, and pi.
25
25
 
26
26
  ## 🚀 Installation
27
27
 
28
- - **Terminal:** Install the `web` command:
28
+ - **Terminal:** Install the `web` command with [Homebrew](https://github.com/mavam/homebrew-tap):
29
+
30
+ ```sh
31
+ brew install mavam/tap/webfox
32
+ ```
33
+
34
+ Or use npm:
29
35
 
30
36
  ```sh
31
37
  npm install -g webfox
32
38
  ```
39
+ - **Nix:** Run the CLI directly:
40
+
41
+ ```sh
42
+ nix run github:mavam/webfox -- --help
43
+ ```
44
+
45
+ The flake exports `packages.<system>.default` and `overlays.default` for Linux
46
+ (`aarch64`, `x86_64`) and macOS (Apple Silicon).
33
47
  - **TypeScript:** Add `webfox` as an application dependency.
34
48
  - **Pi:** Install the extension:
35
49
 
@@ -37,6 +51,7 @@ configuration in your terminal, TypeScript applications, and pi.
37
51
  pi install npm:webfox
38
52
  ```
39
53
 
54
+ This installs the extension, not the `web` command on your shell's `PATH`.
40
55
  Then follow [Use with Pi](#-use-with-pi) to select a web provider and supply its
41
56
  API key.
42
57
 
@@ -134,6 +149,7 @@ Use different providers for different tasks:
134
149
  | [OpenAI] | ✔︎ | | ✔︎ | ✔︎ |
135
150
  | [Parallel] | ✔︎ | ✔︎ | | |
136
151
  | [Perplexity] | ✔︎ | | ✔︎ | ✔︎ |
152
+ | [SerpBase] | ✔︎ | | | |
137
153
  | [Serper] | ✔︎ | | | |
138
154
  | [Tavily] | ✔︎ | ✔︎ | | |
139
155
  | [Valyu] | ✔︎ | ✔︎ | ✔︎ | ✔︎ |
@@ -150,6 +166,7 @@ Use different providers for different tasks:
150
166
  [OpenAI]: ./docs/provider.md#openai
151
167
  [Parallel]: ./docs/provider.md#parallel
152
168
  [Perplexity]: ./docs/provider.md#perplexity
169
+ [SerpBase]: ./docs/provider.md#serpbase
153
170
  [Serper]: ./docs/provider.md#serper
154
171
  [Tavily]: ./docs/provider.md#tavily
155
172
  [Valyu]: ./docs/provider.md#valyu
@@ -204,6 +221,9 @@ Install the [Pi](https://pi.dev) extension:
204
221
  pi install npm:webfox
205
222
  ```
206
223
 
224
+ To also use the `web` command in your terminal, follow [Installation](#-installation).
225
+ Both installations share the same Webfox configuration.
226
+
207
227
  Add to `~/.config/webfox/config.yaml`:
208
228
 
209
229
  ```yaml
@@ -0,0 +1,183 @@
1
+ import {
2
+ SERPBASE_MODES,
3
+ serpbaseProvider,
4
+ validateOptions
5
+ } from "./chunk-VYMIR35R.js";
6
+ import "./chunk-GPACLYNT.js";
7
+ import {
8
+ trimSnippet
9
+ } from "./chunk-X6ZBIHZC.js";
10
+ import {
11
+ WebfoxError,
12
+ httpError
13
+ } from "./chunk-U4BLULLV.js";
14
+
15
+ // src/providers/serpbase/adapter.ts
16
+ var DEFAULT_BASE_URL = "https://api.serpbase.dev";
17
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([1029, 1500, 1502, 1503, 1504]);
18
+ var FEATURE_ID = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
19
+ var adapter = {
20
+ async search(request, config, context) {
21
+ const options = {
22
+ ...request.options,
23
+ mode: request.options?.mode ?? "search"
24
+ };
25
+ validateOptions(serpbaseProvider, "search", options);
26
+ const mode = options.mode;
27
+ const endpoint = SERPBASE_MODES[mode];
28
+ if (mode === "maps-detail" && !FEATURE_ID.test(request.query)) {
29
+ throw new WebfoxError(
30
+ "INVALID_INPUT",
31
+ "SerpBase maps-detail requires a feature_id (0x...:0x...) in each queries entry. First use mode maps and copy the returned feature_id; names, place_id, CID, and Maps URLs are not accepted."
32
+ );
33
+ }
34
+ const apiKey = config.credentials?.api;
35
+ if (!apiKey) throw new Error("SerpBase search is missing an API key");
36
+ const response = await fetch(
37
+ `${(config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "")}/google/${endpoint.path}`,
38
+ {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json", "X-API-Key": apiKey },
41
+ body: JSON.stringify({
42
+ ...mode === "maps-detail" ? { feature_id: request.query } : { q: request.query },
43
+ ...Object.fromEntries(
44
+ endpoint.options.flatMap(
45
+ (key) => options[key] === void 0 ? [] : [[key, options[key]]]
46
+ )
47
+ )
48
+ }),
49
+ signal: context.signal
50
+ }
51
+ );
52
+ const body = await response.text();
53
+ let payload;
54
+ try {
55
+ payload = asRecord(JSON.parse(body));
56
+ } catch {
57
+ }
58
+ const status = payload?.status;
59
+ const apiFailure = typeof status === "number" && status !== 0;
60
+ if (!response.ok || apiFailure) {
61
+ const detail = (text(payload?.error) ?? body.trim()).slice(0, 500);
62
+ const message = `SerpBase ${mode} request failed (HTTP ${response.status}${apiFailure ? `, status ${status}` : ""})${detail ? `: ${detail}` : "."}`;
63
+ if (apiFailure) {
64
+ throw new WebfoxError("PROVIDER_FAILURE", message, {
65
+ retryable: RETRYABLE_STATUSES.has(status)
66
+ });
67
+ }
68
+ throw httpError(response, message);
69
+ }
70
+ const collection = payload?.[endpoint.result];
71
+ if (!payload || status !== 0 || collection !== void 0 && (mode === "maps-detail" ? !asRecord(collection) : !Array.isArray(collection))) {
72
+ throw new WebfoxError(
73
+ "PROVIDER_FAILURE",
74
+ `SerpBase returned an invalid ${mode} response.`,
75
+ { retryable: false }
76
+ );
77
+ }
78
+ const searchContext = { ...payload };
79
+ for (const { result } of Object.values(SERPBASE_MODES))
80
+ delete searchContext[result];
81
+ const entries = mode === "maps-detail" ? collection === void 0 ? [] : [collection] : collection ?? [];
82
+ const results = entries.map((entry) => toSearchResult(entry, searchContext, mode)).filter((result) => result !== void 0);
83
+ return {
84
+ provider: "serpbase",
85
+ // No upstream count parameter or automatic pagination: one page or place.
86
+ results: results.slice(
87
+ 0,
88
+ Math.max(1, Math.trunc(request.maxResults || 0))
89
+ )
90
+ };
91
+ }
92
+ };
93
+ function toSearchResult(entry, searchContext, mode) {
94
+ const record = asRecord(entry);
95
+ if (!record || mode === "images" && isNavigationImage(record))
96
+ return void 0;
97
+ const isPlace = mode === "maps" || mode === "maps-detail";
98
+ const featureId = text(record.feature_id);
99
+ const url = isPlace ? text(record.google_maps_url) ?? text(record.url) ?? text(record.website) ?? (featureId && FEATURE_ID.test(featureId) ? `https://www.google.com/maps?ftid=${encodeURIComponent(featureId)}` : void 0) : text(record.link) ?? text(record.url) ?? (mode === "images" ? text(record.image_url) : void 0);
100
+ if (!url) return void 0;
101
+ const { title, link: _link, url: _url, snippet, ...metadata } = record;
102
+ return {
103
+ title: text(title) ?? text(record.name) ?? url,
104
+ url,
105
+ snippet: resultSnippet(record, mode),
106
+ metadata: { ...metadata, mode, searchContext }
107
+ };
108
+ }
109
+ function resultSnippet(record, mode) {
110
+ const lines = [];
111
+ const excerpt = text(record.snippet) ?? text(record.short_description) ?? text(record.description);
112
+ if (excerpt && !(mode === "videos" && /^\s*Duration:\s*Posted:\s*$/i.test(excerpt)))
113
+ lines.push(trimSnippet(excerpt));
114
+ const add = (label, value, limit = 600) => {
115
+ if (typeof value === "number" && Number.isFinite(value))
116
+ value = String(value);
117
+ if (text(value))
118
+ lines.push(`${label}: ${trimSnippet(value, limit)}`);
119
+ };
120
+ const json = (label, value) => {
121
+ if (value && typeof value === "object")
122
+ add(label, JSON.stringify(value), 1200);
123
+ };
124
+ if (mode === "images") {
125
+ add("Image URL", record.image_url, Infinity);
126
+ add("Source page", text(record.link) ?? text(record.url), Infinity);
127
+ add(
128
+ "Thumbnail",
129
+ text(record.thumbnail_url) ?? text(record.thumbnail),
130
+ Infinity
131
+ );
132
+ add("Source", text(record.source) ?? text(record.domain));
133
+ } else if (mode === "news" || mode === "videos") {
134
+ add("Source", record.source);
135
+ const published = text(record.published_at) ?? text(record.time);
136
+ add(
137
+ published && /^\d{1,3}:\d{2}(?::\d{2})?$/.test(published.trim()) ? "Time (upstream)" : "Published",
138
+ published
139
+ );
140
+ if (mode === "videos") add("Duration", record.duration);
141
+ add(
142
+ "Thumbnail",
143
+ text(record.thumbnail_url) ?? text(record.thumbnail),
144
+ Infinity
145
+ );
146
+ } else if (mode === "maps" || mode === "maps-detail") {
147
+ add("feature_id", record.feature_id, Infinity);
148
+ add("Address", record.address);
149
+ add("Rating", record.rating);
150
+ add("Reviews", record.review_count);
151
+ add("Phone", text(record.phone_international) ?? text(record.phone));
152
+ add("Website", record.website, Infinity);
153
+ if (typeof record.latitude === "number" && typeof record.longitude === "number")
154
+ add("Coordinates", `${record.latitude}, ${record.longitude}`);
155
+ if (Array.isArray(record.types))
156
+ add(
157
+ "Categories",
158
+ record.types.filter((item) => typeof item === "string").join(", ")
159
+ );
160
+ json("Hours", record.hours);
161
+ json("Open status", record.open_status);
162
+ if (mode === "maps-detail") json("Attributes", record.attributes);
163
+ }
164
+ return lines.join("\n");
165
+ }
166
+ function isNavigationImage(record) {
167
+ try {
168
+ const image = new URL(String(record.image_url));
169
+ const source = new URL(String(record.link ?? record.url));
170
+ return image.hostname === "www.gstatic.com" && image.pathname === "/m/images/icons/googleg.gif" && (source.hostname === "www.google.com" || source.hostname === "google.com") && (source.pathname === "/" || source.pathname === "/search");
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ function asRecord(value) {
176
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
177
+ }
178
+ function text(value) {
179
+ return typeof value === "string" && value.trim() ? value : void 0;
180
+ }
181
+ export {
182
+ adapter
183
+ };
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-ZVTVERW5.js";
5
5
  import {
6
6
  WEBFOX_ERROR_CODES
7
- } from "./chunk-WVHMWLKU.js";
7
+ } from "./chunk-GPACLYNT.js";
8
8
  import {
9
9
  WebfoxError
10
10
  } from "./chunk-U4BLULLV.js";
@@ -18,6 +18,7 @@ export interface CapabilityInspection {
18
18
  provider?: ProviderId;
19
19
  configured: boolean;
20
20
  optionSchema?: Record<string, unknown>;
21
+ promptGuidelines?: readonly string[];
21
22
  defaults: {
22
23
  maxResults?: number;
23
24
  options: Record<string, unknown>;
@@ -5,20 +5,22 @@ import {
5
5
  withSignal
6
6
  } from "./chunk-WCTOYZVB.js";
7
7
  import {
8
- configurationSchema,
8
+ configurationSchema
9
+ } from "./chunk-VGVUGOAV.js";
10
+ import {
9
11
  effectiveOptions,
10
12
  optionSchema,
11
13
  providers,
12
14
  selectProvider,
13
15
  validateConfiguredOptions
14
- } from "./chunk-FMYKJ7JC.js";
16
+ } from "./chunk-VYMIR35R.js";
15
17
  import {
16
18
  cancelProcessGroup,
17
19
  killProcessGroup
18
20
  } from "./chunk-ZVTVERW5.js";
19
21
  import {
20
22
  CAPABILITIES
21
- } from "./chunk-WVHMWLKU.js";
23
+ } from "./chunk-GPACLYNT.js";
22
24
  import {
23
25
  WebfoxError,
24
26
  asWebfoxError
@@ -361,7 +363,7 @@ var ExecutionRuntime = class {
361
363
  credentials;
362
364
  async execute(plan, request) {
363
365
  const { capability, definition, inputs } = plan;
364
- const timeoutMs = request.timeoutMs ?? (capability === "research" ? plan.policy.researchTimeoutMs ?? 18e5 : plan.policy.timeoutMs ?? 3e4);
366
+ const timeoutMs = request.timeoutMs ?? (capability === "research" ? plan.policy.researchTimeoutMs : plan.policy.timeoutMs) ?? definition.capabilities[capability]?.defaultTimeoutMs ?? (capability === "research" ? 18e5 : 3e4);
365
367
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647)
366
368
  throw new WebfoxError(
367
369
  "INVALID_INPUT",
@@ -689,6 +691,11 @@ function createWebfox(options = {}) {
689
691
  capability,
690
692
  provider: selected,
691
693
  configured: configured(definition, capability),
694
+ ...definition.capabilities[capability]?.promptGuidelines ? {
695
+ promptGuidelines: [
696
+ ...definition.capabilities[capability].promptGuidelines
697
+ ]
698
+ } : {},
692
699
  // Static provider schemas contain no credentials. Redacting property
693
700
  // names such as maximum_number_of_tokens would invalidate the schema.
694
701
  optionSchema: optionSchema(
@@ -9,7 +9,7 @@ function renderTextDocument(document, { includeErrors = true } = {}) {
9
9
  body = entry.value.results.length ? entry.value.results.map(
10
10
  (result, i) => `${i + 1}. ${result.title}
11
11
  ${result.url}
12
- ${result.snippet}`
12
+ ${result.snippet.replaceAll("\n", "\n ")}`
13
13
  ).join("\n\n") : "No results found.";
14
14
  else if ("text" in entry.value) body = entry.value.text;
15
15
  else body = renderContents(entry.value);
@@ -17,6 +17,7 @@ var PROVIDER_IDS = [
17
17
  "openai",
18
18
  "parallel",
19
19
  "perplexity",
20
+ "serpbase",
20
21
  "serper",
21
22
  "tavily",
22
23
  "valyu",
@@ -0,0 +1,244 @@
1
+ import {
2
+ optionSchema,
3
+ providers
4
+ } from "./chunk-VYMIR35R.js";
5
+ import {
6
+ CAPABILITIES
7
+ } from "./chunk-GPACLYNT.js";
8
+
9
+ // package.json
10
+ var package_default = {
11
+ name: "webfox",
12
+ version: "4.4.0",
13
+ description: "Search the web, extract pages, get grounded answers, and run deep research with the web CLI, a TypeScript library, and a pi extension. Bring your own providers and API keys.",
14
+ type: "module",
15
+ files: [
16
+ "dist",
17
+ "README.md",
18
+ "assets/webfox-lockup.png",
19
+ "assets/webfox-lockup-dark.png",
20
+ "docs/cli-experience.md",
21
+ "docs/provider.md",
22
+ "docs/reference.md",
23
+ "LICENSE",
24
+ "example-config.yaml",
25
+ "examples"
26
+ ],
27
+ keywords: [
28
+ "webfox",
29
+ "web-cli",
30
+ "typescript-library",
31
+ "pi-package",
32
+ "pi-extension",
33
+ "coding-agent",
34
+ "web-search",
35
+ "web-contents",
36
+ "web-answer",
37
+ "web-research",
38
+ "provider-routing",
39
+ "brave",
40
+ "cloudflare",
41
+ "custom",
42
+ "exa",
43
+ "firecrawl",
44
+ "gemini",
45
+ "linkup",
46
+ "ollama",
47
+ "openai",
48
+ "parallel",
49
+ "perplexity",
50
+ "serper",
51
+ "tavily",
52
+ "valyu"
53
+ ],
54
+ author: "mavam",
55
+ license: "MIT",
56
+ repository: {
57
+ type: "git",
58
+ url: "git+https://github.com/mavam/webfox.git"
59
+ },
60
+ engines: {
61
+ node: ">=22.0.0"
62
+ },
63
+ publishConfig: {
64
+ access: "public"
65
+ },
66
+ pi: {
67
+ extensions: [
68
+ "./dist/pi.js"
69
+ ]
70
+ },
71
+ bin: {
72
+ web: "./dist/cli.js"
73
+ },
74
+ main: "./dist/index.js",
75
+ types: "./dist/index.d.ts",
76
+ exports: {
77
+ ".": {
78
+ types: "./dist/index.d.ts",
79
+ import: "./dist/index.js"
80
+ },
81
+ "./pi": {
82
+ types: "./dist/pi.d.ts",
83
+ import: "./dist/pi.js"
84
+ },
85
+ "./config.schema.json": "./dist/config.schema.json"
86
+ },
87
+ scripts: {
88
+ build: "rm -rf dist && esbuild index=src/index.ts cli=src/cli.ts pi=src/pi.ts generate-schema=scripts/generate-schema.ts --bundle --splitting --format=esm --platform=node --target=node22 --packages=external --outdir=dist && node dist/generate-schema.js && biome format --write src/config.schema.json >&2 && rm dist/generate-schema.js && tsc --project tsconfig.build.json && cp src/config.schema.json dist/config.schema.json && chmod +x dist/cli.js",
89
+ prepare: "npm run build",
90
+ prepack: "npm run build",
91
+ check: "tsc --noEmit",
92
+ format: "biome format --write .",
93
+ "format:check": "biome format .",
94
+ "smoke:live": "npm run build && node scripts/live-smoke.mjs",
95
+ "smoke:package": "node scripts/package-smoke.mjs",
96
+ test: "vitest run",
97
+ "test:watch": "vitest"
98
+ },
99
+ dependencies: {
100
+ "@mendable/firecrawl-js": "^4.38.0",
101
+ "@perplexity-ai/perplexity_ai": "^0.38.5",
102
+ "@tavily/core": "^0.7.9",
103
+ cloudflare: "^7.1.0",
104
+ commander: "^14.0.3",
105
+ "exa-js": "^2.19.0",
106
+ "linkup-sdk": "^3.6.0",
107
+ openai: "^7.10.0",
108
+ "parallel-web": "^1.3.3",
109
+ picocolors: "^1.1.1",
110
+ typebox: "^1.3.6",
111
+ "valyu-js": "^2.10.1",
112
+ yaml: "^2.9.0"
113
+ },
114
+ devDependencies: {
115
+ "@biomejs/biome": "^2.5.4",
116
+ "@earendil-works/pi-ai": "^0.85.1",
117
+ "@earendil-works/pi-coding-agent": "^0.85.1",
118
+ "@earendil-works/pi-tui": "^0.85.1",
119
+ "@types/node": "^26.1.1",
120
+ esbuild: "^0.28.1",
121
+ typescript: "^7.0.2",
122
+ vitest: "^4.1.10"
123
+ },
124
+ peerDependencies: {
125
+ "@earendil-works/pi-coding-agent": "*",
126
+ "@earendil-works/pi-tui": "*"
127
+ },
128
+ peerDependenciesMeta: {
129
+ "@earendil-works/pi-coding-agent": {
130
+ optional: true
131
+ },
132
+ "@earendil-works/pi-tui": {
133
+ optional: true
134
+ }
135
+ }
136
+ };
137
+
138
+ // src/package-metadata.ts
139
+ var PACKAGE_NAME = package_default.name;
140
+ var PACKAGE_VERSION = package_default.version;
141
+ var CONFIG_SCHEMA_URL = `https://unpkg.com/${PACKAGE_NAME}@latest/dist/config.schema.json`;
142
+
143
+ // src/configuration/schema.ts
144
+ var text = { type: "string", minLength: 1 };
145
+ var object = (properties) => ({
146
+ type: "object",
147
+ additionalProperties: false,
148
+ properties
149
+ });
150
+ var source = {
151
+ oneOf: ["env", "value", "command"].map((key) => ({
152
+ ...object({
153
+ [key]: key === "command" ? { type: "array", minItems: 1, items: text } : text
154
+ }),
155
+ required: [key]
156
+ }))
157
+ };
158
+ var sourceMap = { type: "object", additionalProperties: source };
159
+ var command = {
160
+ ...object({
161
+ argv: { type: "array", minItems: 1, items: text },
162
+ cwd: text,
163
+ env: sourceMap
164
+ }),
165
+ required: ["argv"]
166
+ };
167
+ var positive = { type: "integer", minimum: 1 };
168
+ var timeout = { ...positive, maximum: 2147483647 };
169
+ var fields = {
170
+ credentials: sourceMap,
171
+ baseUrl: text,
172
+ accountId: source,
173
+ commands: object(
174
+ Object.fromEntries(CAPABILITIES.map((capability) => [capability, command]))
175
+ )
176
+ };
177
+ var configurationSchema = {
178
+ $schema: "https://json-schema.org/draft/2020-12/schema",
179
+ $id: CONFIG_SCHEMA_URL,
180
+ title: "webfox configuration",
181
+ ...object({
182
+ $schema: text,
183
+ defaults: object(
184
+ Object.fromEntries(
185
+ CAPABILITIES.map((capability) => [
186
+ capability,
187
+ object({
188
+ provider: {
189
+ enum: Object.values(providers).filter((provider) => provider.capabilities[capability]).map((provider) => provider.id)
190
+ },
191
+ ...capability === "search" ? { maxResults: positive } : {}
192
+ })
193
+ ])
194
+ )
195
+ ),
196
+ execution: object({
197
+ timeoutMs: timeout,
198
+ researchTimeoutMs: timeout,
199
+ retries: { type: "integer", minimum: 0 },
200
+ retryDelayMs: { type: "integer", minimum: 0 },
201
+ concurrency: positive
202
+ }),
203
+ providers: object(
204
+ Object.fromEntries(
205
+ Object.values(providers).map((provider) => [
206
+ provider.id,
207
+ object(
208
+ Object.fromEntries(
209
+ provider.fields.map((field) => [
210
+ field,
211
+ field === "options" ? object(
212
+ Object.fromEntries(
213
+ CAPABILITIES.filter(
214
+ (capability) => provider.capabilities[capability]
215
+ ).map((capability) => [
216
+ capability,
217
+ optionSchema(provider, capability, "defaults") ?? {
218
+ type: "object",
219
+ additionalProperties: provider.id === "custom"
220
+ }
221
+ ])
222
+ )
223
+ ) : field === "credentials" ? object(
224
+ Object.fromEntries(
225
+ provider.credentials.map((credential) => [
226
+ credential.name,
227
+ source
228
+ ])
229
+ )
230
+ ) : fields[field]
231
+ ])
232
+ )
233
+ )
234
+ ])
235
+ )
236
+ )
237
+ })
238
+ };
239
+
240
+ export {
241
+ PACKAGE_VERSION,
242
+ CONFIG_SCHEMA_URL,
243
+ configurationSchema
244
+ };