webfox 4.5.0 → 4.7.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
@@ -244,6 +244,95 @@ Ask Pi: **Search the web for the latest Node.js release notes.**
244
244
  Add `contents`, `answer`, or `research` defaults for more tools.
245
245
  Restart Pi after changing defaults.
246
246
 
247
+ ### Compose web calls with codemode
248
+
249
+ With Pi 0.99 or later, you can use its built-in `codemode` tool to chain web
250
+ calls, run independent calls in parallel, and filter results before they reach
251
+ the model. In `~/.pi/agent/settings.json` or your project's `.pi/settings.json`,
252
+ add:
253
+
254
+ ```json
255
+ {
256
+ "defaultTools": ["+codemode"]
257
+ }
258
+ ```
259
+
260
+ This keeps your existing tools and adds codemode. Webfox tools remain directly
261
+ callable; you don't need an MCP server or another extension. In codemode, calls
262
+ such as `tools.web_search(...)` return structured result objects, not text.
263
+ Scripts receive complete results even when the direct tool output is truncated.
264
+ Only the output you emit with `text(...)` or `return` reaches the model.
265
+
266
+ Ask Pi: **Use codemode to search for Node.js and Bun cancellation, fetch up to
267
+ five unique pages, and return a short excerpt from each.** Pi can run a script
268
+ like this, provided you have configured both `search` and `contents` defaults:
269
+
270
+ ```js
271
+ const search = await tools.web_search({
272
+ queries: ["Node.js cancellation", "Bun cancellation"],
273
+ maxResults: 3,
274
+ });
275
+
276
+ // Keep failed inputs visible while using the successful results.
277
+ for (const entry of search.results) {
278
+ if (!entry.ok) text({ input: entry.input, error: entry.error });
279
+ }
280
+
281
+ const urls = [...new Set(
282
+ search.results.flatMap(entry =>
283
+ entry.ok ? entry.value.results.map(hit => hit.url) : []
284
+ )
285
+ )].slice(0, 5);
286
+
287
+ if (!urls.length) exit();
288
+
289
+ const pages = await tools.web_contents({ urls });
290
+ text(pages.results.map(entry =>
291
+ entry.ok
292
+ ? { url: entry.value.url, excerpt: entry.value.content?.slice(0, 3000) }
293
+ : { url: entry.input, error: entry.error }
294
+ ));
295
+ ```
296
+
297
+ For independent tasks, await every call with `Promise.allSettled`. This example
298
+ requires an `answer` default and keeps one rejected call from discarding the
299
+ other call's result:
300
+
301
+ ```js
302
+ const answers = await Promise.allSettled([
303
+ tools.web_answer({ queries: ["What is an AbortSignal?"] }),
304
+ tools.web_answer({ queries: ["How does Bun handle cancellation?"] }),
305
+ ]);
306
+
307
+ for (const answer of answers) {
308
+ if (answer.status === "fulfilled") text(answer.value);
309
+ else text({ error: String(answer.reason) });
310
+ }
311
+ ```
312
+
313
+ A Webfox document with `status: "partial"` still resolves: check each result's
314
+ `ok` field before accessing `value`. Invalid arguments and blocked tool calls
315
+ reject instead. Nested calls still pass through Pi's validation and permission
316
+ hooks.
317
+
318
+ To return less data, select the fields you need rather than emit a whole
319
+ document. For example, this returns only titles and source URLs:
320
+
321
+ ```js
322
+ const search = await tools.web_search({ queries: ["Node.js release notes"] });
323
+ text(search.results.flatMap(entry =>
324
+ entry.ok
325
+ ? entry.value.results.map(({ title, url }) => ({ title, url }))
326
+ : [{ input: entry.input, error: entry.error }]
327
+ ));
328
+ ```
329
+
330
+ Webfox tools belong to the `web` namespace, which you can search from scripts
331
+ with `await searchTools("search", { namespace: "web" })`. To route all callable
332
+ tools through scripts, set `"codemode": { "mode": "only" }` in Pi's settings
333
+ alongside `defaultTools`. This setting affects all callable tools, not only
334
+ Webfox.
335
+
247
336
  ## 📚 Use with TypeScript
248
337
 
249
338
  ```ts
@@ -266,6 +355,25 @@ The client also provides `contents`, `answer`, and `research`. See the
266
355
  [library reference](./docs/reference.md#typescript-library) for request controls,
267
356
  configuration, progress, and errors.
268
357
 
358
+ ## 🧬 Use with Pi Durable
359
+
360
+ A custom Pi Durable host can install the web tools directly:
361
+
362
+ ```ts
363
+ import { createWebfoxExtension } from "webfox/durable";
364
+
365
+ registry.install(createWebfoxExtension({ cwd: process.cwd() }));
366
+ ```
367
+
368
+ The adapter uses the shared Webfox configuration and provider-specific schemas.
369
+ Each call follows its conversation's execution environment, streams progress,
370
+ and preserves partial failures. Large text results are saved to private files.
371
+ Interrupted requests are not replayed automatically, since providers may charge
372
+ for them. Cancelling a request may not cancel a provider's already-running job.
373
+
374
+ The adapter requires Pi Durable 1.0. It does not install a CLI, register standard
375
+ Pi renderers, or provide codemode's structured-result interface.
376
+
269
377
  ## 🩺 Troubleshooting
270
378
 
271
379
  - **No provider selected:** Pass `--provider <id>` or save a default with
@@ -2,11 +2,11 @@ import {
2
2
  SERPBASE_MODES,
3
3
  serpbaseProvider,
4
4
  validateOptions
5
- } from "./chunk-KH54YH7D.js";
6
- import "./chunk-GPACLYNT.js";
5
+ } from "./chunk-NOBEWI5W.js";
7
6
  import {
8
7
  trimSnippet
9
8
  } from "./chunk-X6ZBIHZC.js";
9
+ import "./chunk-GPACLYNT.js";
10
10
  import {
11
11
  WebfoxError,
12
12
  httpError
@@ -6,14 +6,14 @@ import {
6
6
  } from "./chunk-WCTOYZVB.js";
7
7
  import {
8
8
  configurationSchema
9
- } from "./chunk-VI3473TU.js";
9
+ } from "./chunk-MLRWLHC2.js";
10
10
  import {
11
11
  effectiveOptions,
12
12
  optionSchema,
13
13
  providers,
14
14
  selectProvider,
15
15
  validateConfiguredOptions
16
- } from "./chunk-KH54YH7D.js";
16
+ } from "./chunk-NOBEWI5W.js";
17
17
  import {
18
18
  cancelProcessGroup,
19
19
  killProcessGroup
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  optionSchema,
3
3
  providers
4
- } from "./chunk-KH54YH7D.js";
4
+ } from "./chunk-NOBEWI5W.js";
5
5
  import {
6
6
  CAPABILITIES
7
7
  } from "./chunk-GPACLYNT.js";
@@ -9,7 +9,7 @@ import {
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "webfox",
12
- version: "4.5.0",
12
+ version: "4.7.0",
13
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
14
  type: "module",
15
15
  files: [
@@ -82,10 +82,14 @@ var package_default = {
82
82
  types: "./dist/pi.d.ts",
83
83
  import: "./dist/pi.js"
84
84
  },
85
+ "./durable": {
86
+ types: "./dist/durable.d.ts",
87
+ import: "./dist/durable.js"
88
+ },
85
89
  "./config.schema.json": "./dist/config.schema.json"
86
90
  },
87
91
  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",
92
+ build: "rm -rf dist && esbuild index=src/index.ts cli=src/cli.ts pi=src/pi.ts durable=src/durable.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
93
  prepare: "npm run build",
90
94
  prepack: "npm run build",
91
95
  check: "tsc --noEmit",
@@ -107,25 +111,31 @@ var package_default = {
107
111
  openai: "^7.10.0",
108
112
  "parallel-web": "^1.3.3",
109
113
  picocolors: "^1.1.1",
110
- typebox: "^1.3.6",
111
114
  "valyu-js": "^2.10.1",
112
115
  yaml: "^2.9.0"
113
116
  },
114
117
  devDependencies: {
115
118
  "@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
+ "@earendil-works/chord": "^1.0.0",
120
+ "@earendil-works/pi-ai": "^1.0.0",
121
+ "@earendil-works/pi-coding-agent": "^1.0.0",
122
+ "@earendil-works/pi-durable": "^1.0.0",
123
+ "@earendil-works/pi-tui": "^1.0.0",
119
124
  "@types/node": "^26.1.1",
120
125
  esbuild: "^0.28.1",
121
126
  typescript: "^7.0.2",
122
127
  vitest: "^4.1.10"
123
128
  },
124
129
  peerDependencies: {
130
+ "@earendil-works/pi-durable": "^1.0.0",
125
131
  "@earendil-works/pi-coding-agent": "*",
126
- "@earendil-works/pi-tui": "*"
132
+ "@earendil-works/pi-tui": "*",
133
+ typebox: "*"
127
134
  },
128
135
  peerDependenciesMeta: {
136
+ "@earendil-works/pi-durable": {
137
+ optional: true
138
+ },
129
139
  "@earendil-works/pi-coding-agent": {
130
140
  optional: true
131
141
  },
@@ -1991,7 +1991,7 @@ var serpbaseProvider = defineProvider({
1991
1991
  retrySafe: true
1992
1992
  }
1993
1993
  },
1994
- load: async () => (await import("./adapter-BQUN3ME2.js")).adapter
1994
+ load: async () => (await import("./adapter-IDKJASTU.js")).adapter
1995
1995
  });
1996
1996
 
1997
1997
  // src/providers/serper/definition.ts
@@ -0,0 +1,112 @@
1
+ // src/tool-definition.ts
2
+ import { Type } from "typebox";
3
+ function webToolParameters(inspection) {
4
+ const capability = inspection.capability;
5
+ const fields = capability === "contents" ? { urls: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) } : capability === "research" ? { input: Type.String({ minLength: 1 }) } : {
6
+ queries: Type.Array(Type.String({ minLength: 1 }), {
7
+ minItems: 1,
8
+ maxItems: 10
9
+ }),
10
+ ...capability === "search" ? { maxResults: Type.Optional(Type.Integer({ minimum: 1 })) } : {}
11
+ };
12
+ return Type.Object(
13
+ {
14
+ ...fields,
15
+ ...inspection.optionSchema ? {
16
+ options: Type.Optional(
17
+ inspection.optionSchema
18
+ )
19
+ } : {}
20
+ },
21
+ { additionalProperties: false }
22
+ );
23
+ }
24
+ var descriptions = {
25
+ search: "Search up to ten queries and return titles, URLs, and snippets in input order.",
26
+ contents: "Fetch and extract readable contents from web URLs.",
27
+ answer: "Answer up to ten questions using web-grounded evidence.",
28
+ research: "Run foreground multi-step web research and return the final report."
29
+ };
30
+
31
+ // src/pi-validation.ts
32
+ import { stripVTControlCharacters } from "node:util";
33
+ import { Check, Errors } from "typebox/value";
34
+ function isSchema(value) {
35
+ return !!value && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+ function alternatives(schema) {
38
+ return ["anyOf", "oneOf", "allOf"].flatMap(
39
+ (key) => Array.isArray(schema[key]) ? schema[key].filter(isSchema) : []
40
+ );
41
+ }
42
+ function acceptsPath(schema, path) {
43
+ if (schema === true) return true;
44
+ if (!isSchema(schema)) return false;
45
+ if (!path.length) return true;
46
+ if (alternatives(schema).some((branch) => acceptsPath(branch, path)))
47
+ return true;
48
+ const [key, ...rest] = path;
49
+ if (schema.type === "array") return acceptsPath(schema.items, rest);
50
+ if (schema.properties && Object.hasOwn(schema.properties, key))
51
+ return acceptsPath(schema.properties[key], rest);
52
+ if (isSchema(schema.patternProperties)) {
53
+ for (const [pattern, child] of Object.entries(schema.patternProperties))
54
+ if (new RegExp(pattern).test(key) && acceptsPath(child, rest))
55
+ return true;
56
+ }
57
+ if (schema.additionalProperties === false) return false;
58
+ if (schema.type && schema.type !== "object") return false;
59
+ if (alternatives(schema).length && !schema.properties && schema.additionalProperties === void 0)
60
+ return false;
61
+ return schema.additionalProperties === void 0 || acceptsPath(schema.additionalProperties, rest);
62
+ }
63
+ function displayPath(path) {
64
+ return path.map((part, index) => {
65
+ const safe = stripVTControlCharacters(part);
66
+ if (/^[a-zA-Z_][\w-]*$/.test(safe)) return `${index ? "." : ""}${safe}`;
67
+ if (/^\d+$/.test(safe)) return `[${safe}]`;
68
+ return `[${JSON.stringify(safe)}]`;
69
+ }).join("");
70
+ }
71
+ function locations(schema, name, path = []) {
72
+ if (!isSchema(schema)) return [];
73
+ const found = alternatives(schema).flatMap(
74
+ (branch) => locations(branch, name, path)
75
+ );
76
+ if (isSchema(schema.properties)) {
77
+ for (const [key, child] of Object.entries(schema.properties)) {
78
+ const next = [...path, key];
79
+ if (key === name) found.push(next);
80
+ found.push(...locations(child, name, next));
81
+ }
82
+ }
83
+ return found;
84
+ }
85
+ function prepareToolArguments(schema, args) {
86
+ if (Check(schema, args)) return args;
87
+ const messages = /* @__PURE__ */ new Set();
88
+ for (const error of Errors(schema, args)) {
89
+ if (error.keyword !== "additionalProperties") continue;
90
+ const names = error.params.additionalProperties;
91
+ if (!Array.isArray(names)) continue;
92
+ const parent = error.instancePath.split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
93
+ for (const name of names) {
94
+ if (typeof name !== "string") continue;
95
+ const path = [...parent, name];
96
+ if (acceptsPath(schema, path)) continue;
97
+ const targets = [...new Set(locations(schema, name).map(displayPath))];
98
+ const suggestion = targets.length === 1 ? ` Use ${targets[0]} instead.` : " Check the tool's parameter schema.";
99
+ messages.add(`Invalid parameter: ${displayPath(path)}.${suggestion}`);
100
+ if (messages.size === 3) break;
101
+ }
102
+ if (messages.size === 3) break;
103
+ }
104
+ if (messages.size) throw new Error([...messages].join("\n"));
105
+ return args;
106
+ }
107
+
108
+ export {
109
+ webToolParameters,
110
+ descriptions,
111
+ prepareToolArguments
112
+ };
package/dist/cli.js CHANGED
@@ -11,14 +11,14 @@ import {
11
11
  redactConfig,
12
12
  resolveConfigPath,
13
13
  setCapabilityDefault
14
- } from "./chunk-D4HCJRBE.js";
14
+ } from "./chunk-3LK52VNJ.js";
15
15
  import "./chunk-WCTOYZVB.js";
16
16
  import {
17
17
  PACKAGE_VERSION
18
- } from "./chunk-VI3473TU.js";
18
+ } from "./chunk-MLRWLHC2.js";
19
19
  import {
20
20
  validateConfiguredOptions
21
- } from "./chunk-KH54YH7D.js";
21
+ } from "./chunk-NOBEWI5W.js";
22
22
  import "./chunk-ZVTVERW5.js";
23
23
  import {
24
24
  CAPABILITIES,
@@ -0,0 +1,114 @@
1
+ import { Type, type Static } from "typebox";
2
+ import { type AnswerDocument, type ContentsDocument, type ResearchDocument, type SearchDocument } from "./domain.js";
3
+ /**
4
+ * JSON Schema of each capability's result document. This is the document the
5
+ * CLI prints with `--format json` and the library returns.
6
+ */
7
+ export declare const DOCUMENT_SCHEMAS: {
8
+ readonly search: Type.TObject<{
9
+ schemaVersion: Type.TLiteral<1>;
10
+ capability: Type.TLiteral<"search">;
11
+ provider: Type.TEnum<["brave", "cloudflare", "custom", "exa", "firecrawl", "gemini", "linkup", "ollama", "openai", "parallel", "perplexity", "serpbase", "serper", "tavily", "valyu", "youcom"]>;
12
+ status: Type.TEnum<["ok", "partial"]>;
13
+ results: Type.TArray<Type.TUnion<[Type.TObject<{
14
+ input: Type.TString;
15
+ ok: Type.TLiteral<true>;
16
+ value: Type.TObject<{
17
+ results: Type.TArray<Type.TObject<{
18
+ title: Type.TString;
19
+ url: Type.TString;
20
+ snippet: Type.TString;
21
+ score: Type.TOptional<Type.TNumber>;
22
+ metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
23
+ }>>;
24
+ }>;
25
+ }>, Type.TObject<{
26
+ input: Type.TString;
27
+ ok: Type.TLiteral<false>;
28
+ error: Type.TObject<{
29
+ code: Type.TEnum<["INVALID_CONFIG", "INVALID_INPUT", "PROVIDER_UNAVAILABLE", "PROVIDER_FAILURE", "PARTIAL_BATCH", "TIMEOUT", "CANCELLED"]>;
30
+ message: Type.TString;
31
+ retryable: Type.TOptional<Type.TBoolean>;
32
+ }>;
33
+ }>]>>;
34
+ }>;
35
+ readonly contents: Type.TObject<{
36
+ schemaVersion: Type.TLiteral<1>;
37
+ capability: Type.TLiteral<"contents">;
38
+ provider: Type.TEnum<["brave", "cloudflare", "custom", "exa", "firecrawl", "gemini", "linkup", "ollama", "openai", "parallel", "perplexity", "serpbase", "serper", "tavily", "valyu", "youcom"]>;
39
+ status: Type.TEnum<["ok", "partial"]>;
40
+ results: Type.TArray<Type.TUnion<[Type.TObject<{
41
+ input: Type.TString;
42
+ ok: Type.TLiteral<true>;
43
+ value: Type.TObject<{
44
+ url: Type.TString;
45
+ content: Type.TOptional<Type.TString>;
46
+ summary: Type.TOptional<Type.TUnknown>;
47
+ metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
48
+ }>;
49
+ }>, Type.TObject<{
50
+ input: Type.TString;
51
+ ok: Type.TLiteral<false>;
52
+ error: Type.TObject<{
53
+ code: Type.TEnum<["INVALID_CONFIG", "INVALID_INPUT", "PROVIDER_UNAVAILABLE", "PROVIDER_FAILURE", "PARTIAL_BATCH", "TIMEOUT", "CANCELLED"]>;
54
+ message: Type.TString;
55
+ retryable: Type.TOptional<Type.TBoolean>;
56
+ }>;
57
+ }>]>>;
58
+ }>;
59
+ readonly answer: Type.TObject<{
60
+ schemaVersion: Type.TLiteral<1>;
61
+ capability: Type.TLiteral<"answer">;
62
+ provider: Type.TEnum<["brave", "cloudflare", "custom", "exa", "firecrawl", "gemini", "linkup", "ollama", "openai", "parallel", "perplexity", "serpbase", "serper", "tavily", "valyu", "youcom"]>;
63
+ status: Type.TEnum<["ok", "partial"]>;
64
+ results: Type.TArray<Type.TUnion<[Type.TObject<{
65
+ input: Type.TString;
66
+ ok: Type.TLiteral<true>;
67
+ value: Type.TObject<{
68
+ text: Type.TString;
69
+ itemCount: Type.TOptional<Type.TInteger>;
70
+ metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
71
+ }>;
72
+ }>, Type.TObject<{
73
+ input: Type.TString;
74
+ ok: Type.TLiteral<false>;
75
+ error: Type.TObject<{
76
+ code: Type.TEnum<["INVALID_CONFIG", "INVALID_INPUT", "PROVIDER_UNAVAILABLE", "PROVIDER_FAILURE", "PARTIAL_BATCH", "TIMEOUT", "CANCELLED"]>;
77
+ message: Type.TString;
78
+ retryable: Type.TOptional<Type.TBoolean>;
79
+ }>;
80
+ }>]>>;
81
+ }>;
82
+ readonly research: Type.TObject<{
83
+ schemaVersion: Type.TLiteral<1>;
84
+ capability: Type.TLiteral<"research">;
85
+ provider: Type.TEnum<["brave", "cloudflare", "custom", "exa", "firecrawl", "gemini", "linkup", "ollama", "openai", "parallel", "perplexity", "serpbase", "serper", "tavily", "valyu", "youcom"]>;
86
+ status: Type.TEnum<["ok", "partial"]>;
87
+ results: Type.TArray<Type.TUnion<[Type.TObject<{
88
+ input: Type.TString;
89
+ ok: Type.TLiteral<true>;
90
+ value: Type.TObject<{
91
+ text: Type.TString;
92
+ itemCount: Type.TOptional<Type.TInteger>;
93
+ metadata: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
94
+ }>;
95
+ }>, Type.TObject<{
96
+ input: Type.TString;
97
+ ok: Type.TLiteral<false>;
98
+ error: Type.TObject<{
99
+ code: Type.TEnum<["INVALID_CONFIG", "INVALID_INPUT", "PROVIDER_UNAVAILABLE", "PROVIDER_FAILURE", "PARTIAL_BATCH", "TIMEOUT", "CANCELLED"]>;
100
+ message: Type.TString;
101
+ retryable: Type.TOptional<Type.TBoolean>;
102
+ }>;
103
+ }>]>>;
104
+ }>;
105
+ };
106
+ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
107
+ type Assert<T extends true> = T;
108
+ export type DocumentSchemaMatchesDomain = [
109
+ Assert<Same<Static<typeof DOCUMENT_SCHEMAS.search>, SearchDocument>>,
110
+ Assert<Same<Static<typeof DOCUMENT_SCHEMAS.contents>, ContentsDocument>>,
111
+ Assert<Same<Static<typeof DOCUMENT_SCHEMAS.answer>, AnswerDocument>>,
112
+ Assert<Same<Static<typeof DOCUMENT_SCHEMAS.research>, ResearchDocument>>
113
+ ];
114
+ export {};
@@ -0,0 +1,8 @@
1
+ import { type Extension } from "@earendil-works/pi-durable";
2
+ import { type WebfoxClient, type CreateWebfoxOptions } from "./index.js";
3
+ export interface DurableWebfoxOptions extends CreateWebfoxOptions {
4
+ /** Supply a client for an embedded host or deterministic tests. */
5
+ client?: WebfoxClient;
6
+ }
7
+ /** Install alongside CodingTools. Every call uses the conversation's current environment. */
8
+ export declare function createWebfoxExtension(options?: DurableWebfoxOptions): Extension;
@@ -0,0 +1,124 @@
1
+ import {
2
+ descriptions,
3
+ prepareToolArguments,
4
+ webToolParameters
5
+ } from "./chunk-VVOQKYOP.js";
6
+ import {
7
+ renderTextDocument
8
+ } from "./chunk-BTWECET5.js";
9
+ import {
10
+ createWebfox
11
+ } from "./chunk-3LK52VNJ.js";
12
+ import "./chunk-WCTOYZVB.js";
13
+ import "./chunk-MLRWLHC2.js";
14
+ import "./chunk-NOBEWI5W.js";
15
+ import "./chunk-ZVTVERW5.js";
16
+ import {
17
+ CAPABILITIES
18
+ } from "./chunk-GPACLYNT.js";
19
+ import "./chunk-U4BLULLV.js";
20
+
21
+ // src/durable.ts
22
+ import { mkdtemp, writeFile } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import {
26
+ truncateHead,
27
+ withFileMutationQueue
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import {
30
+ defineExtension,
31
+ defineTool
32
+ } from "@earendil-works/pi-durable";
33
+ function createWebfoxExtension(options = {}) {
34
+ const clients = /* @__PURE__ */ new Map();
35
+ const clientFor = (cwd) => {
36
+ if (options.client) return options.client;
37
+ let client = clients.get(cwd);
38
+ if (!client) {
39
+ client = createWebfox({ ...options, cwd });
40
+ clients.set(cwd, client);
41
+ }
42
+ return client;
43
+ };
44
+ const initialCwd = options.cwd ?? process.cwd();
45
+ const initial = clientFor(initialCwd);
46
+ const inspections = CAPABILITIES.map(
47
+ (capability) => initial.inspectCapability(capability)
48
+ ).filter((inspection) => inspection.provider);
49
+ return defineExtension({
50
+ name: "webfox",
51
+ tools: inspections.map((inspection) => {
52
+ const capability = inspection.capability;
53
+ const parameters = webToolParameters(inspection);
54
+ return defineTool({
55
+ name: `web_${capability}`,
56
+ description: [
57
+ descriptions[capability],
58
+ ...inspection.promptGuidelines ?? [],
59
+ "Output is limited to 2000 lines or 50 KiB; complete results are saved to a private file when truncated."
60
+ ].join("\n"),
61
+ parameters,
62
+ // Web requests may incur charges. A crash reports interruption rather than silently charging again.
63
+ replay: "unsafe",
64
+ prepareArguments: (args) => prepareToolArguments(parameters, args),
65
+ async execute(args, api, context) {
66
+ const client = clientFor(
67
+ api.env?.cwd ?? (await api.agent(context)).cwd ?? initialCwd
68
+ );
69
+ const request = {
70
+ provider: inspection.provider,
71
+ signal: context.abortSignal,
72
+ options: args.options,
73
+ onProgress: (event) => api.output(`${event.message}
74
+ `)
75
+ };
76
+ const result = capability === "search" ? await client.search({
77
+ ...request,
78
+ queries: args.queries,
79
+ maxResults: args.maxResults
80
+ }) : capability === "contents" ? await client.contents({
81
+ ...request,
82
+ urls: args.urls
83
+ }) : capability === "answer" ? await client.answer({
84
+ ...request,
85
+ queries: args.queries
86
+ }) : await client.research({
87
+ ...request,
88
+ input: args.input
89
+ });
90
+ const truncated = truncateHead(renderTextDocument(result));
91
+ let body = truncated.content;
92
+ let fullOutputPath;
93
+ if (truncated.truncated) {
94
+ fullOutputPath = join(
95
+ await mkdtemp(join(tmpdir(), "web-")),
96
+ "result.json"
97
+ );
98
+ await withFileMutationQueue(
99
+ fullOutputPath,
100
+ () => writeFile(fullOutputPath, JSON.stringify(result), {
101
+ mode: 384
102
+ })
103
+ );
104
+ body += `
105
+
106
+ Full results: ${fullOutputPath}`;
107
+ }
108
+ return {
109
+ content: [{ type: "text", text: body }],
110
+ isError: result.status === "partial",
111
+ details: {
112
+ status: result.status,
113
+ capability,
114
+ ...fullOutputPath ? { fullOutputPath } : {}
115
+ }
116
+ };
117
+ }
118
+ });
119
+ })
120
+ });
121
+ }
122
+ export {
123
+ createWebfoxExtension
124
+ };
package/dist/index.js CHANGED
@@ -6,14 +6,14 @@ import {
6
6
  resolveConfigPath,
7
7
  setCapabilityDefault,
8
8
  validateConfig
9
- } from "./chunk-D4HCJRBE.js";
9
+ } from "./chunk-3LK52VNJ.js";
10
10
  import "./chunk-WCTOYZVB.js";
11
11
  import {
12
12
  CONFIG_SCHEMA_URL
13
- } from "./chunk-VI3473TU.js";
13
+ } from "./chunk-MLRWLHC2.js";
14
14
  import {
15
15
  validateConfiguredOptions
16
- } from "./chunk-KH54YH7D.js";
16
+ } from "./chunk-NOBEWI5W.js";
17
17
  import "./chunk-ZVTVERW5.js";
18
18
  import {
19
19
  CAPABILITIES,
package/dist/pi.js CHANGED
@@ -1,16 +1,23 @@
1
+ import {
2
+ descriptions,
3
+ prepareToolArguments,
4
+ webToolParameters
5
+ } from "./chunk-VVOQKYOP.js";
1
6
  import {
2
7
  renderTextDocument
3
8
  } from "./chunk-BTWECET5.js";
4
9
  import {
5
10
  createWebfox,
6
11
  resolveConfigPath
7
- } from "./chunk-D4HCJRBE.js";
12
+ } from "./chunk-3LK52VNJ.js";
8
13
  import "./chunk-WCTOYZVB.js";
9
- import "./chunk-VI3473TU.js";
10
- import "./chunk-KH54YH7D.js";
14
+ import "./chunk-MLRWLHC2.js";
15
+ import "./chunk-NOBEWI5W.js";
11
16
  import "./chunk-ZVTVERW5.js";
12
17
  import {
13
- CAPABILITIES
18
+ CAPABILITIES,
19
+ PROVIDER_IDS,
20
+ WEBFOX_ERROR_CODES
14
21
  } from "./chunk-GPACLYNT.js";
15
22
  import {
16
23
  WebfoxError
@@ -24,7 +31,6 @@ import {
24
31
  truncateHead,
25
32
  withFileMutationQueue
26
33
  } from "@earendil-works/pi-coding-agent";
27
- import { Type } from "typebox";
28
34
 
29
35
  // src/pi-render.ts
30
36
  import { stripVTControlCharacters as stripVTControlCharacters3 } from "node:util";
@@ -186,11 +192,11 @@ function link(url, theme) {
186
192
  return label;
187
193
  }
188
194
  function renderSearchResult(details, fallback, theme) {
189
- const document = isRecord2(details) && searchDocument(details.result) ? details.result : void 0;
195
+ const document2 = isRecord2(details) && searchDocument(details.result) ? details.result : void 0;
190
196
  return {
191
197
  render(width) {
192
198
  if (width <= 0) return [];
193
- if (!document) {
199
+ if (!document2) {
194
200
  const text = plainText(fallback).split("\n").map((line) => {
195
201
  const urlLine = /^(\s*)(https?:\/\/\S+)(\s*)$/.exec(line);
196
202
  return urlLine ? urlLine[1] + link(urlLine[2], theme) + urlLine[3] : theme.fg("toolOutput", line);
@@ -209,9 +215,9 @@ function renderSearchResult(details, fallback, theme) {
209
215
  )
210
216
  );
211
217
  };
212
- document.results.forEach((entry, index) => {
218
+ document2.results.forEach((entry, index) => {
213
219
  if (index) lines.push("");
214
- if (document.results.length > 1) {
220
+ if (document2.results.length > 1) {
215
221
  append(
216
222
  theme.fg(
217
223
  "mdHeading",
@@ -304,8 +310,8 @@ var WebCall = class {
304
310
  };
305
311
  function renderWebResult(result, options, theme, isError, capability) {
306
312
  const text = result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
307
- const metadata = result.details;
308
- const isSearch = (capability ?? metadata?.capability ?? metadata?.result?.capability) === "search";
313
+ const metadata2 = result.details;
314
+ const isSearch = (capability ?? metadata2?.capability ?? metadata2?.result?.capability) === "search";
309
315
  const body = () => isSearch ? renderSearchResult(result.details, text, theme) : new Markdown(text, 0, 0, getMarkdownTheme());
310
316
  const rows = statusRows(result.details);
311
317
  if (rows?.length) {
@@ -340,83 +346,6 @@ function renderWebResult(result, options, theme, isError, capability) {
340
346
  };
341
347
  }
342
348
 
343
- // src/pi-validation.ts
344
- import { stripVTControlCharacters as stripVTControlCharacters4 } from "node:util";
345
- import { Check, Errors } from "typebox/value";
346
- function isSchema(value) {
347
- return !!value && typeof value === "object" && !Array.isArray(value);
348
- }
349
- function alternatives(schema) {
350
- return ["anyOf", "oneOf", "allOf"].flatMap(
351
- (key) => Array.isArray(schema[key]) ? schema[key].filter(isSchema) : []
352
- );
353
- }
354
- function acceptsPath(schema, path) {
355
- if (schema === true) return true;
356
- if (!isSchema(schema)) return false;
357
- if (!path.length) return true;
358
- if (alternatives(schema).some((branch) => acceptsPath(branch, path)))
359
- return true;
360
- const [key, ...rest] = path;
361
- if (schema.type === "array") return acceptsPath(schema.items, rest);
362
- if (schema.properties && Object.hasOwn(schema.properties, key))
363
- return acceptsPath(schema.properties[key], rest);
364
- if (isSchema(schema.patternProperties)) {
365
- for (const [pattern, child] of Object.entries(schema.patternProperties))
366
- if (new RegExp(pattern).test(key) && acceptsPath(child, rest))
367
- return true;
368
- }
369
- if (schema.additionalProperties === false) return false;
370
- if (schema.type && schema.type !== "object") return false;
371
- if (alternatives(schema).length && !schema.properties && schema.additionalProperties === void 0)
372
- return false;
373
- return schema.additionalProperties === void 0 || acceptsPath(schema.additionalProperties, rest);
374
- }
375
- function displayPath(path) {
376
- return path.map((part, index) => {
377
- const safe = stripVTControlCharacters4(part);
378
- if (/^[a-zA-Z_][\w-]*$/.test(safe)) return `${index ? "." : ""}${safe}`;
379
- if (/^\d+$/.test(safe)) return `[${safe}]`;
380
- return `[${JSON.stringify(safe)}]`;
381
- }).join("");
382
- }
383
- function locations(schema, name, path = []) {
384
- if (!isSchema(schema)) return [];
385
- const found = alternatives(schema).flatMap(
386
- (branch) => locations(branch, name, path)
387
- );
388
- if (isSchema(schema.properties)) {
389
- for (const [key, child] of Object.entries(schema.properties)) {
390
- const next = [...path, key];
391
- if (key === name) found.push(next);
392
- found.push(...locations(child, name, next));
393
- }
394
- }
395
- return found;
396
- }
397
- function prepareToolArguments(schema, args) {
398
- if (Check(schema, args)) return args;
399
- const messages = /* @__PURE__ */ new Set();
400
- for (const error of Errors(schema, args)) {
401
- if (error.keyword !== "additionalProperties") continue;
402
- const names = error.params.additionalProperties;
403
- if (!Array.isArray(names)) continue;
404
- const parent = error.instancePath.split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
405
- for (const name of names) {
406
- if (typeof name !== "string") continue;
407
- const path = [...parent, name];
408
- if (acceptsPath(schema, path)) continue;
409
- const targets = [...new Set(locations(schema, name).map(displayPath))];
410
- const suggestion = targets.length === 1 ? ` Use ${targets[0]} instead.` : " Check the tool's parameter schema.";
411
- messages.add(`Invalid parameter: ${displayPath(path)}.${suggestion}`);
412
- if (messages.size === 3) break;
413
- }
414
- if (messages.size === 3) break;
415
- }
416
- if (messages.size) throw new Error([...messages].join("\n"));
417
- return args;
418
- }
419
-
420
349
  // src/pi-diagnostics.ts
421
350
  import { homedir } from "node:os";
422
351
  import { sep } from "node:path";
@@ -435,6 +364,85 @@ function configurationDiagnostic(error) {
435
364
  ].join("\n");
436
365
  }
437
366
 
367
+ // src/document-schema.ts
368
+ import { Type } from "typebox";
369
+ var closed = { additionalProperties: false };
370
+ var metadata = Type.Record(Type.String(), Type.Unknown(), {
371
+ description: "Provider-specific fields."
372
+ });
373
+ var searchResult = Type.Object(
374
+ {
375
+ title: Type.String(),
376
+ url: Type.String(),
377
+ snippet: Type.String(),
378
+ score: Type.Optional(Type.Number()),
379
+ metadata: Type.Optional(metadata)
380
+ },
381
+ closed
382
+ );
383
+ var contentsAnswer = Type.Object(
384
+ {
385
+ url: Type.String({ description: "Final URL after redirects." }),
386
+ content: Type.Optional(Type.String()),
387
+ summary: Type.Optional(Type.Unknown()),
388
+ metadata: Type.Optional(metadata)
389
+ },
390
+ closed
391
+ );
392
+ var textAnswer = Type.Object(
393
+ {
394
+ text: Type.String(),
395
+ itemCount: Type.Optional(Type.Integer()),
396
+ metadata: Type.Optional(metadata)
397
+ },
398
+ closed
399
+ );
400
+ var serializedError = Type.Object(
401
+ {
402
+ code: Type.Enum(WEBFOX_ERROR_CODES),
403
+ message: Type.String(),
404
+ retryable: Type.Optional(Type.Boolean())
405
+ },
406
+ closed
407
+ );
408
+ function inputResult(value) {
409
+ const input = Type.String({
410
+ description: "The query, URL, or brief this entry answers."
411
+ });
412
+ return Type.Union([
413
+ Type.Object({ input, ok: Type.Literal(true), value }, closed),
414
+ Type.Object(
415
+ { input, ok: Type.Literal(false), error: serializedError },
416
+ closed
417
+ )
418
+ ]);
419
+ }
420
+ function document(capability, value) {
421
+ return Type.Object(
422
+ {
423
+ schemaVersion: Type.Literal(1),
424
+ capability: Type.Literal(capability),
425
+ provider: Type.Enum(PROVIDER_IDS),
426
+ status: Type.Enum(["ok", "partial"], {
427
+ description: "`partial` when at least one input failed."
428
+ }),
429
+ results: Type.Array(inputResult(value), {
430
+ description: "One entry per input, in input order."
431
+ })
432
+ },
433
+ closed
434
+ );
435
+ }
436
+ var DOCUMENT_SCHEMAS = {
437
+ search: document(
438
+ "search",
439
+ Type.Object({ results: Type.Array(searchResult) }, closed)
440
+ ),
441
+ contents: document("contents", contentsAnswer),
442
+ answer: document("answer", textAnswer),
443
+ research: document("research", textAnswer)
444
+ };
445
+
438
446
  // src/pi.ts
439
447
  function webExtension(pi) {
440
448
  const clients = /* @__PURE__ */ new Map();
@@ -475,34 +483,27 @@ function webExtension(pi) {
475
483
  for (const inspection of selected) {
476
484
  const capability = inspection.capability;
477
485
  const provider = inspection.provider;
478
- const fields = capability === "contents" ? { urls: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) } : capability === "research" ? { input: Type.String({ minLength: 1 }) } : {
479
- queries: Type.Array(Type.String({ minLength: 1 }), {
480
- minItems: 1,
481
- maxItems: 10
482
- }),
483
- ...capability === "search" ? { maxResults: Type.Optional(Type.Integer({ minimum: 1 })) } : {}
484
- };
485
- const parameters = Type.Object(
486
- {
487
- ...fields,
488
- ...inspection.optionSchema ? {
489
- options: Type.Optional(
490
- inspection.optionSchema
491
- )
492
- } : {}
493
- },
494
- { additionalProperties: false }
495
- );
486
+ const parameters = webToolParameters(inspection);
496
487
  pi.registerTool({
497
488
  name: `web_${capability}`,
489
+ namespace: {
490
+ name: "web",
491
+ description: "Search, extract pages, answer, and research the web."
492
+ },
498
493
  label: `Web ${capability[0].toUpperCase()}${capability.slice(1)}`,
499
494
  // Keep provider guidance with the tool schema, including with custom system prompts.
500
495
  description: [
501
496
  descriptions[capability],
502
497
  ...inspection.promptGuidelines ?? [],
503
- "Output is truncated to 2000 lines or 50 KiB; full results are saved to a file when truncated."
498
+ "Model-facing text is truncated to 2000 lines or 50 KiB; full results are saved to a file when truncated. Codemode scripts receive complete structured results."
504
499
  ].join("\n"),
505
500
  parameters,
501
+ // Let permission extensions and tool loadouts treat web access as a
502
+ // read-only call that reaches the open web.
503
+ annotations: { readOnlyHint: true, openWorldHint: true },
504
+ // Programmatic callers such as codemode scripts receive the result
505
+ // document itself instead of parsing the rendered text.
506
+ outputSchema: DOCUMENT_SCHEMAS[capability],
506
507
  prepareArguments(args) {
507
508
  return prepareToolArguments(parameters, args);
508
509
  },
@@ -550,6 +551,7 @@ function webExtension(pi) {
550
551
  queries: params.queries,
551
552
  maxResults: params.maxResults
552
553
  }) : capability === "answer" ? await client.answer({ ...request, queries: params.queries }) : capability === "contents" ? await client.contents({ ...request, urls: params.urls }) : await client.research({ ...request, input: params.input });
554
+ const json = JSON.stringify(result);
553
555
  const text = renderTextDocument(result);
554
556
  const truncated = truncateHead(text);
555
557
  let body = truncated.content;
@@ -561,7 +563,7 @@ function webExtension(pi) {
561
563
  );
562
564
  await withFileMutationQueue(
563
565
  fullOutputPath,
564
- () => writeFile(fullOutputPath, JSON.stringify(result), { mode: 384 })
566
+ () => writeFile(fullOutputPath, json, { mode: 384 })
565
567
  );
566
568
  body += `
567
569
 
@@ -569,8 +571,11 @@ Full results: ${fullOutputPath}`;
569
571
  }
570
572
  return {
571
573
  content: [{ type: "text", text: body }],
574
+ // Pi keeps this out of the model's context and the session, so the
575
+ // text truncation above does not apply. Scripts get every result.
576
+ structuredContent: JSON.parse(json),
577
+ isError: result.status === "partial",
572
578
  details: {
573
- webProviderResult: true,
574
579
  status: result.status,
575
580
  webInputStatus: true,
576
581
  capability,
@@ -584,17 +589,7 @@ Full results: ${fullOutputPath}`;
584
589
  }
585
590
  });
586
591
  }
587
- pi.on("tool_result", (event) => {
588
- if (event.details && typeof event.details === "object" && "webProviderResult" in event.details && event.details.webProviderResult === true && "status" in event.details && event.details.status === "partial")
589
- return { isError: true };
590
- });
591
592
  }
592
- var descriptions = {
593
- search: "Search up to ten queries and return titles, URLs, and snippets in input order.",
594
- contents: "Fetch and extract readable contents from web URLs.",
595
- answer: "Answer up to ten questions using web-grounded evidence.",
596
- research: "Run foreground multi-step web research and return the final report."
597
- };
598
593
  export {
599
594
  webExtension as default
600
595
  };
@@ -0,0 +1,4 @@
1
+ import { type TObject } from "typebox";
2
+ import type { Capability, WebfoxClient } from "./index.js";
3
+ export declare function webToolParameters(inspection: ReturnType<WebfoxClient["inspectCapability"]>): TObject;
4
+ export declare const descriptions: Record<Capability, string>;
package/docs/reference.md CHANGED
@@ -179,3 +179,10 @@ extension forwards cancellation and progress, marks partial results as errors,
179
179
  and truncates tool output at 2,000 lines or 50 KiB, with full results saved to a
180
180
  temporary file. It needs no globally installed `web` command and starts no
181
181
  background research jobs.
182
+
183
+ Each tool declares the [JSON result document](./cli-experience.md#json-results)
184
+ as its output schema and returns that document as structured content. Scripts
185
+ that call the tools through Pi's codemode receive it as an object instead of
186
+ parsing text, including the completed inputs of a partial result. Structured
187
+ content is never truncated, and Pi neither sends it to the model nor stores it
188
+ in the session.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webfox",
3
- "version": "4.5.0",
3
+ "version": "4.7.0",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "files": [
@@ -73,10 +73,14 @@
73
73
  "types": "./dist/pi.d.ts",
74
74
  "import": "./dist/pi.js"
75
75
  },
76
+ "./durable": {
77
+ "types": "./dist/durable.d.ts",
78
+ "import": "./dist/durable.js"
79
+ },
76
80
  "./config.schema.json": "./dist/config.schema.json"
77
81
  },
78
82
  "scripts": {
79
- "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",
83
+ "build": "rm -rf dist && esbuild index=src/index.ts cli=src/cli.ts pi=src/pi.ts durable=src/durable.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",
80
84
  "prepare": "npm run build",
81
85
  "prepack": "npm run build",
82
86
  "check": "tsc --noEmit",
@@ -98,25 +102,31 @@
98
102
  "openai": "^7.10.0",
99
103
  "parallel-web": "^1.3.3",
100
104
  "picocolors": "^1.1.1",
101
- "typebox": "^1.3.6",
102
105
  "valyu-js": "^2.10.1",
103
106
  "yaml": "^2.9.0"
104
107
  },
105
108
  "devDependencies": {
106
109
  "@biomejs/biome": "^2.5.4",
107
- "@earendil-works/pi-ai": "^0.85.1",
108
- "@earendil-works/pi-coding-agent": "^0.85.1",
109
- "@earendil-works/pi-tui": "^0.85.1",
110
+ "@earendil-works/chord": "^1.0.0",
111
+ "@earendil-works/pi-ai": "^1.0.0",
112
+ "@earendil-works/pi-coding-agent": "^1.0.0",
113
+ "@earendil-works/pi-durable": "^1.0.0",
114
+ "@earendil-works/pi-tui": "^1.0.0",
110
115
  "@types/node": "^26.1.1",
111
116
  "esbuild": "^0.28.1",
112
117
  "typescript": "^7.0.2",
113
118
  "vitest": "^4.1.10"
114
119
  },
115
120
  "peerDependencies": {
121
+ "@earendil-works/pi-durable": "^1.0.0",
116
122
  "@earendil-works/pi-coding-agent": "*",
117
- "@earendil-works/pi-tui": "*"
123
+ "@earendil-works/pi-tui": "*",
124
+ "typebox": "*"
118
125
  },
119
126
  "peerDependenciesMeta": {
127
+ "@earendil-works/pi-durable": {
128
+ "optional": true
129
+ },
120
130
  "@earendil-works/pi-coding-agent": {
121
131
  "optional": true
122
132
  },