@warlock.js/ai-tools 5.2.3 → 5.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-url.mjs","names":[],"sources":["../../../../../../../ai-tools/src/web/fetch-url.ts"],"sourcesContent":["import {\n guardedFetch,\n OutboundPolicyError,\n tool,\n type ToolContract,\n} from \"@warlock.js/ai\";\nimport type {\n FetchUrlExtract,\n FetchUrlInput,\n FetchUrlOptions,\n FetchUrlResult,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"fetch_url\";\n\n/** Default hard cap on response bytes read before truncation. */\nconst DEFAULT_MAX_BYTES = 1_000_000;\n\n/** Default per-request timeout in milliseconds. */\nconst DEFAULT_TIMEOUT_MS = 15_000;\n\n/** Default rendering mode for the fetched document. */\nconst DEFAULT_EXTRACT: FetchUrlExtract = \"text\";\n\n/** Input schema: `{ url }`. */\nconst inputSchema = objectSchema<FetchUrlInput>({\n url: stringField(),\n});\n\n// ============================================================\n// Lazily-loaded @mozilla/readability + jsdom (OPTIONAL peers)\n// ============================================================\n\n/**\n * Minimal structural shapes of the optional `@mozilla/readability` + `jsdom`\n * peers — only the members this file actually uses. Declared locally so the\n * package type-checks even when the peers are NOT installed (they are lazy\n * optional peers, loaded below via a `string` specifier so `tsc` never tries\n * to statically resolve them).\n */\ninterface ReadabilityArticleLike {\n readonly content?: string | null;\n readonly textContent?: string | null;\n}\ninterface ReadabilityInstanceLike {\n parse(): ReadabilityArticleLike | null;\n}\ninterface ReadabilityModuleLike {\n Readability: new (document: unknown) => ReadabilityInstanceLike;\n}\ninterface JsdomInstanceLike {\n readonly window: { readonly document: unknown };\n}\ninterface JsdomModuleLike {\n JSDOM: new (html: string, options?: { url?: string }) => JsdomInstanceLike;\n}\n\n// String specifiers typed `string` (not a literal) so `tsc` does not attempt\n// to resolve these optional peers at build time.\nconst READABILITY_MODULE_ID: string = \"@mozilla/readability\";\nconst JSDOM_MODULE_ID: string = \"jsdom\";\n\nlet ReadabilitySdk: ReadabilityModuleLike;\nlet JsdomSdk: JsdomModuleLike;\nlet isReadabilityAvailable: boolean | undefined;\nlet loadingPromise: Promise<void> | undefined;\n\nconst READABILITY_INSTALL_INSTRUCTIONS = `\nThe fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.\nInstall them with:\n\n npm install @mozilla/readability jsdom\n\nOr with your preferred package manager:\n\n pnpm add @mozilla/readability jsdom\n yarn add @mozilla/readability jsdom\n`.trim();\n\n/**\n * Settle the lazy import of `@mozilla/readability` + `jsdom` once,\n * concurrency-safe. Only needed for the `\"text\"` / `\"markdown\"` extract\n * modes — `\"html\"` returns the raw body and never loads them. A bare\n * `catch` flips the flag to `false`; the curated install string surfaces\n * at use time via {@link WebToolError}, never a raw module-resolution\n * stack trace.\n */\nfunction loadReadability(): Promise<void> {\n if (isReadabilityAvailable !== undefined) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n ReadabilitySdk = (await import(READABILITY_MODULE_ID)) as ReadabilityModuleLike;\n JsdomSdk = (await import(JSDOM_MODULE_ID)) as JsdomModuleLike;\n isReadabilityAvailable = true;\n } catch {\n isReadabilityAvailable = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * Parse the input URL, rejecting anything unparseable or not over\n * `http`/`https` with a typed {@link WebToolError}.\n */\nfunction parseUrl(raw: string): URL {\n let url: URL;\n\n try {\n url = new URL(raw);\n } catch {\n throw new WebToolError(`fetch_url received an unparseable URL: \"${raw}\".`, {\n type: \"invalid-url\",\n });\n }\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new WebToolError(\n `fetch_url only supports http(s) URLs; got \"${url.protocol}\".`,\n { type: \"invalid-url\" },\n );\n }\n\n return url;\n}\n\n/**\n * Enforce the host allowlist (an SSRF guardrail) before any network call.\n * When `allowHosts` is set, a request to a host not in it is rejected;\n * matching is exact on the URL hostname (case-insensitive).\n */\nfunction assertHostAllowed(url: URL, allowHosts?: string[]): void {\n if (!allowHosts || allowHosts.length === 0) {\n return;\n }\n\n const host = url.hostname.toLowerCase();\n const allowed = allowHosts.some((entry) => entry.toLowerCase() === host);\n\n if (!allowed) {\n throw new WebToolError(\n `fetch_url blocked host \"${url.hostname}\" — it is not in the allowHosts allowlist.`,\n { type: \"denied-host\", context: { host: url.hostname } },\n );\n }\n}\n\n/**\n * Read the response body up to `maxBytes`, stopping early once the cap is\n * reached. Returns the decoded text and whether it was truncated. When\n * the body has no stream (a stubbed `Response`), falls back to `.text()`\n * and truncates the decoded string at `maxBytes`.\n */\nasync function readBody(\n response: Response,\n maxBytes: number,\n): Promise<{ body: string; truncated: boolean }> {\n const stream = response.body;\n\n if (!stream) {\n const text = await response.text();\n\n if (text.length > maxBytes) {\n return { body: text.slice(0, maxBytes), truncated: true };\n }\n\n return { body: text, truncated: false };\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let received = 0;\n let truncated = false;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) {\n break;\n }\n\n if (value) {\n received += value.byteLength;\n chunks.push(value);\n\n if (received >= maxBytes) {\n truncated = true;\n await reader.cancel();\n break;\n }\n }\n }\n\n const merged = new Uint8Array(received);\n let offset = 0;\n\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const sliced = truncated ? merged.subarray(0, maxBytes) : merged;\n const body = new TextDecoder().decode(sliced);\n\n return { body, truncated };\n}\n\n/**\n * Render the fetched HTML into the configured `extract` mode. `\"html\"`\n * returns the raw body untouched; `\"text\"` and `\"markdown\"` run\n * `@mozilla/readability` over a `jsdom` document, throwing a curated\n * {@link WebToolError} when those optional peers are absent. `\"markdown\"`\n * applies a light heading/paragraph conversion over the extracted text.\n */\nasync function render(\n html: string,\n url: string,\n extract: FetchUrlExtract,\n): Promise<string> {\n if (extract === \"html\") {\n return html;\n }\n\n await loadReadability();\n\n if (!isReadabilityAvailable) {\n throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: \"missing-peer\" });\n }\n\n const dom = new JsdomSdk.JSDOM(html, { url });\n const article = new ReadabilitySdk.Readability(dom.window.document).parse();\n\n if (extract === \"markdown\") {\n return article?.content ? htmlToMarkdown(article.content) : (article?.textContent ?? \"\");\n }\n\n return article?.textContent ?? \"\";\n}\n\n/**\n * Minimal HTML→Markdown reduction for readability's extracted article\n * HTML — headings become `#` prefixes, paragraphs/line-breaks become\n * blank-line separated blocks, and remaining tags are stripped. This is a\n * pragmatic conversion, not a full CommonMark serializer.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<h([1-6])[^>]*>(.*?)<\\/h\\1>/gis, (_match, level: string, text: string) => {\n const hashes = \"#\".repeat(Number(level));\n\n return `\\n\\n${hashes} ${stripTags(text).trim()}\\n\\n`;\n })\n .replace(/<\\/(p|div|section|article|li)>/gi, \"\\n\\n\")\n .replace(/<br\\s*\\/?>/gi, \"\\n\")\n .replace(/<[^>]+>/g, \"\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n}\n\n/** Strip any remaining HTML tags from a fragment. */\nfunction stripTags(html: string): string {\n return html.replace(/<[^>]+>/g, \"\");\n}\n\n/**\n * Build the agent-facing `fetch_url` tool — fetch a URL over the global\n * `fetch` (Node 18+) and hand the model back rendered `content`.\n *\n * Guardrails, applied in order before/around the network call:\n * - **Private-network deny (default).** Every request — and every\n * redirect hop — goes through the framework's `guardedFetch` outbound\n * policy, which refuses private / loopback / link-local /\n * cloud-metadata addresses (and hostnames resolving to them) unless\n * `allowPrivateNetwork: true` is set. This applies even when\n * `allowHosts` is not configured, so a bare `ai.tools.fetchUrl()` is\n * not an SSRF primitive.\n * - **`allowHosts`** — when set, a request to any host not in the list is\n * rejected before the fetch, and redirect targets are held to the same\n * allowlist.\n * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`\n * (default {@link DEFAULT_TIMEOUT_MS}).\n * - **`maxBytes`** — the response body is read up to this cap and the\n * result flags `truncated` when it was cut off (default\n * {@link DEFAULT_MAX_BYTES}).\n * - **`extract`** — `\"html\"` returns the raw body; `\"text\"` (default) and\n * `\"markdown\"` run `@mozilla/readability` over `jsdom`, lazily imported\n * so they stay OPTIONAL peers — a missing peer throws the curated\n * install string.\n *\n * **Errors flow as data.** Every guardrail rejection and network failure\n * throws a typed {@link WebToolError}; the `tool()` wrapper catches it and\n * surfaces it in the returned `{ error }` field — `invoke()` never throws\n * — so the agent reads the failure and self-corrects.\n *\n * @param options - Tool-name override, byte cap, timeout, extract mode,\n * and host allowlist.\n * @returns A `ToolContract<{ url }, FetchUrlResult>`.\n *\n * @example\n * const fetchUrl = fetchUrlTool({ extract: \"text\", allowHosts: [\"docs.stripe.com\"] });\n * const { data } = await fetchUrl.invoke({ url: \"https://docs.stripe.com/api\" });\n * console.log(data?.content, data?.truncated);\n */\nexport function fetchUrlTool(\n options?: FetchUrlOptions,\n): ToolContract<FetchUrlInput, FetchUrlResult> {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const extract = options?.extract ?? DEFAULT_EXTRACT;\n const allowHosts = options?.allowHosts;\n const allowPrivateNetwork = options?.allowPrivateNetwork ?? false;\n\n // Warm the readability peers non-blockingly when the configured mode\n // needs them, so the curated install string is ready (and logged at\n // first use) without delaying construction.\n if (extract !== \"html\") {\n void loadReadability();\n }\n\n return tool<FetchUrlInput, FetchUrlResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n \"Fetch a web page or document by URL and return its main content as \" +\n \"text (readability-extracted), raw HTML, or Markdown. Use to read a \" +\n \"specific page you already have the URL for.\",\n input: inputSchema,\n async execute(input) {\n const url = parseUrl(input.url);\n assertHostAllowed(url, allowHosts);\n\n let response: Response;\n\n try {\n // The shared hardened outbound path: private-IP deny by default,\n // per-hop redirect re-validation, timeout — never a raw fetch.\n response = await guardedFetch(url.toString(), {\n allowedSchemes: [\"http\", \"https\"],\n hostAllowlist: allowHosts,\n denyPrivateIPsAfterDNS: !allowPrivateNetwork,\n timeoutMs,\n });\n } catch (cause) {\n if (cause instanceof OutboundPolicyError) {\n throw new WebToolError(`fetch_url blocked: ${cause.message}`, {\n type: \"denied-host\",\n cause,\n });\n }\n\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`fetch_url request failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n const { body, truncated } = await readBody(response, maxBytes);\n const content = await render(body, response.url || url.toString(), extract);\n\n return {\n url: response.url || url.toString(),\n status: response.status,\n content,\n truncated,\n };\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"fetch-url.mjs","names":[],"sources":["../../../../../../../ai-tools/src/web/fetch-url.ts"],"sourcesContent":["import {\n guardedFetch,\n OutboundPolicyError,\n tool,\n type ToolContract,\n} from \"@warlock.js/ai\";\nimport type {\n FetchUrlExtract,\n FetchUrlInput,\n FetchUrlOptions,\n FetchUrlResult,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"fetch_url\";\n\n/** Default hard cap on response bytes read before truncation. */\nconst DEFAULT_MAX_BYTES = 1_000_000;\n\n/** Default per-request timeout in milliseconds. */\nconst DEFAULT_TIMEOUT_MS = 15_000;\n\n/** Default rendering mode for the fetched document. */\nconst DEFAULT_EXTRACT: FetchUrlExtract = \"text\";\n\n/** Input schema: `{ url }`. */\nconst inputSchema = objectSchema<FetchUrlInput>({\n url: stringField(),\n});\n\n// ============================================================\n// Lazily-loaded @mozilla/readability + jsdom (OPTIONAL peers)\n// ============================================================\n\n/**\n * Minimal structural shapes of the optional `@mozilla/readability` + `jsdom`\n * peers — only the members this file actually uses. Declared locally so the\n * package type-checks even when the peers are NOT installed (they are lazy\n * optional peers, loaded below via a `string` specifier so `tsc` never tries\n * to statically resolve them).\n */\ninterface ReadabilityArticleLike {\n readonly content?: string | null;\n readonly textContent?: string | null;\n}\ninterface ReadabilityInstanceLike {\n parse(): ReadabilityArticleLike | null;\n}\ninterface ReadabilityModuleLike {\n Readability: new (document: unknown) => ReadabilityInstanceLike;\n}\ninterface JsdomInstanceLike {\n readonly window: { readonly document: unknown };\n}\ninterface JsdomModuleLike {\n JSDOM: new (html: string, options?: { url?: string }) => JsdomInstanceLike;\n}\n\n// String specifiers typed `string` (not a literal) so `tsc` does not attempt\n// to resolve these optional peers at build time.\nconst READABILITY_MODULE_ID: string = \"@mozilla/readability\";\nconst JSDOM_MODULE_ID: string = \"jsdom\";\n\nlet ReadabilitySdk: ReadabilityModuleLike;\nlet JsdomSdk: JsdomModuleLike;\nlet isReadabilityAvailable: boolean | undefined;\nlet loadingPromise: Promise<void> | undefined;\n\nconst READABILITY_INSTALL_INSTRUCTIONS = `\nThe fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.\nInstall them with:\n\n npm install @mozilla/readability jsdom\n\nOr with your preferred package manager:\n\n pnpm add @mozilla/readability jsdom\n yarn add @mozilla/readability jsdom\n`.trim();\n\n/**\n * Settle the lazy import of `@mozilla/readability` + `jsdom` once,\n * concurrency-safe. Only needed for the `\"text\"` / `\"markdown\"` extract\n * modes — `\"html\"` returns the raw body and never loads them. A bare\n * `catch` flips the flag to `false`; the curated install string surfaces\n * at use time via {@link WebToolError}, never a raw module-resolution\n * stack trace.\n */\nfunction loadReadability(): Promise<void> {\n if (isReadabilityAvailable !== undefined) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n ReadabilitySdk = (await import(READABILITY_MODULE_ID)) as ReadabilityModuleLike;\n JsdomSdk = (await import(JSDOM_MODULE_ID)) as JsdomModuleLike;\n isReadabilityAvailable = true;\n } catch {\n isReadabilityAvailable = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * Parse the input URL, rejecting anything unparseable or not over\n * `http`/`https` with a typed {@link WebToolError}.\n */\nfunction parseUrl(raw: string): URL {\n let url: URL;\n\n try {\n url = new URL(raw);\n } catch {\n throw new WebToolError(`fetch_url received an unparseable URL: \"${raw}\".`, {\n type: \"invalid-url\",\n });\n }\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new WebToolError(\n `fetch_url only supports http(s) URLs; got \"${url.protocol}\".`,\n { type: \"invalid-url\" },\n );\n }\n\n return url;\n}\n\n/**\n * Enforce the host allowlist (an SSRF guardrail) before any network call.\n * When `allowHosts` is set, a request to a host not in it is rejected;\n * matching is exact on the URL hostname (case-insensitive).\n */\nfunction assertHostAllowed(url: URL, allowHosts?: string[]): void {\n if (!allowHosts || allowHosts.length === 0) {\n return;\n }\n\n const host = url.hostname.toLowerCase();\n const allowed = allowHosts.some((entry) => entry.toLowerCase() === host);\n\n if (!allowed) {\n throw new WebToolError(\n `fetch_url blocked host \"${url.hostname}\" — it is not in the allowHosts allowlist.`,\n { type: \"denied-host\", context: { host: url.hostname } },\n );\n }\n}\n\n/**\n * Read the response body up to `maxBytes`, stopping early once the cap is\n * reached. Returns the decoded text and whether it was truncated. When\n * the body has no stream (a stubbed `Response`), falls back to `.text()`\n * and truncates the decoded string at `maxBytes`.\n */\nasync function readBody(\n response: Response,\n maxBytes: number,\n): Promise<{ body: string; truncated: boolean }> {\n const stream = response.body;\n\n if (!stream) {\n const text = await response.text();\n\n if (text.length > maxBytes) {\n return { body: text.slice(0, maxBytes), truncated: true };\n }\n\n return { body: text, truncated: false };\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let received = 0;\n let truncated = false;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) {\n break;\n }\n\n if (value) {\n received += value.byteLength;\n chunks.push(value);\n\n if (received >= maxBytes) {\n truncated = true;\n await reader.cancel();\n break;\n }\n }\n }\n\n const merged = new Uint8Array(received);\n let offset = 0;\n\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const sliced = truncated ? merged.subarray(0, maxBytes) : merged;\n const body = new TextDecoder().decode(sliced);\n\n return { body, truncated };\n}\n\n/**\n * Render the fetched HTML into the configured `extract` mode. `\"html\"`\n * returns the raw body untouched; `\"text\"` and `\"markdown\"` run\n * `@mozilla/readability` over a `jsdom` document, throwing a curated\n * {@link WebToolError} when those optional peers are absent. `\"markdown\"`\n * applies a light heading/paragraph conversion over the extracted text.\n */\nasync function render(\n html: string,\n url: string,\n extract: FetchUrlExtract,\n): Promise<string> {\n if (extract === \"html\") {\n return html;\n }\n\n await loadReadability();\n\n if (!isReadabilityAvailable) {\n throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: \"missing-peer\" });\n }\n\n const dom = new JsdomSdk.JSDOM(html, { url });\n const article = new ReadabilitySdk.Readability(dom.window.document).parse();\n\n if (extract === \"markdown\") {\n return article?.content ? htmlToMarkdown(article.content) : (article?.textContent ?? \"\");\n }\n\n return article?.textContent ?? \"\";\n}\n\n/**\n * Minimal HTML→Markdown reduction for readability's extracted article\n * HTML — headings become `#` prefixes, paragraphs/line-breaks become\n * blank-line separated blocks, and remaining tags are stripped. This is a\n * pragmatic conversion, not a full CommonMark serializer.\n */\nfunction htmlToMarkdown(html: string): string {\n return html\n .replace(/<h([1-6])[^>]*>(.*?)<\\/h\\1>/gis, (_match, level: string, text: string) => {\n const hashes = \"#\".repeat(Number(level));\n\n return `\\n\\n${hashes} ${stripTags(text).trim()}\\n\\n`;\n })\n .replace(/<\\/(p|div|section|article|li)>/gi, \"\\n\\n\")\n .replace(/<br\\s*\\/?>/gi, \"\\n\")\n .replace(/<[^>]+>/g, \"\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n}\n\n/** Strip any remaining HTML tags from a fragment. */\nfunction stripTags(html: string): string {\n return html.replace(/<[^>]+>/g, \"\");\n}\n\n/**\n * Build the agent-facing `fetch_url` tool — fetch a URL over the global\n * `fetch` (Node 18+) and hand the model back rendered `content`.\n *\n * Guardrails, applied in order before/around the network call:\n * - **Private-network deny (default).** Every request — and every\n * redirect hop — goes through the framework's `guardedFetch` outbound\n * policy, which refuses private / loopback / link-local /\n * cloud-metadata addresses (and hostnames resolving to them) unless\n * `allowPrivateNetwork: true` is set. This applies even when\n * `allowHosts` is not configured, so a bare `ai.tools.fetchUrl()` is\n * not an SSRF primitive.\n * - **`allowHosts`** — when set, a request to any host not in the list is\n * rejected before the fetch, and redirect targets are held to the same\n * allowlist.\n * - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`\n * (default {@link DEFAULT_TIMEOUT_MS}).\n * - **`maxBytes`** — the response body is read up to this cap and the\n * result flags `truncated` when it was cut off (default\n * {@link DEFAULT_MAX_BYTES}).\n * - **`extract`** — `\"html\"` returns the raw body; `\"text\"` (default) and\n * `\"markdown\"` run `@mozilla/readability` over `jsdom`, lazily imported\n * so they stay OPTIONAL peers — a missing peer throws the curated\n * install string.\n *\n * **Errors flow as data.** Every guardrail rejection and network failure\n * throws a typed {@link WebToolError}; the `tool()` wrapper catches it and\n * surfaces it in the returned `{ error }` field — `invoke()` never throws\n * — so the agent reads the failure and self-corrects.\n *\n * @param options - Tool-name override, byte cap, timeout, extract mode,\n * and host allowlist.\n * @returns A `ToolContract<{ url }, FetchUrlResult>`.\n *\n * @example\n * const fetchUrl = fetchUrlTool({ extract: \"text\", allowHosts: [\"docs.stripe.com\"] });\n * const { data } = await fetchUrl.invoke({ url: \"https://docs.stripe.com/api\" });\n * console.log(data?.content, data?.truncated);\n */\nexport function fetchUrlTool(\n options?: FetchUrlOptions,\n): ToolContract<FetchUrlInput, FetchUrlResult> {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const extract = options?.extract ?? DEFAULT_EXTRACT;\n const allowHosts = options?.allowHosts;\n const allowPrivateNetwork = options?.allowPrivateNetwork ?? false;\n\n // Warm the readability peers non-blockingly when the configured mode\n // needs them, so the curated install string is ready (and logged at\n // first use) without delaying construction.\n if (extract !== \"html\") {\n void loadReadability();\n }\n\n return tool<FetchUrlInput, FetchUrlResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n \"Fetch a web page or document by URL and return its main content as \" +\n \"text (readability-extracted), raw HTML, or Markdown. Use to read a \" +\n \"specific page you already have the URL for.\",\n input: inputSchema,\n async execute(input) {\n const url = parseUrl(input.url);\n assertHostAllowed(url, allowHosts);\n\n let response: Response;\n\n try {\n // The shared hardened outbound path: private-IP deny by default,\n // per-hop redirect re-validation, timeout — never a raw fetch.\n response = await guardedFetch(url.toString(), {\n allowedSchemes: [\"http\", \"https\"],\n hostAllowlist: allowHosts,\n denyPrivateIPsAfterDNS: !allowPrivateNetwork,\n timeoutMs,\n });\n } catch (cause) {\n if (cause instanceof OutboundPolicyError) {\n throw new WebToolError(`fetch_url blocked: ${cause.message}`, {\n type: \"denied-host\",\n cause,\n });\n }\n\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(`fetch_url request failed: ${message}`, {\n type: \"request-failed\",\n cause,\n });\n }\n\n const { body, truncated } = await readBody(response, maxBytes);\n const content = await render(body, response.url || url.toString(), extract);\n\n return {\n url: response.url || url.toString(),\n status: response.status,\n content,\n truncated,\n };\n },\n });\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"web-search.mjs","names":[],"sources":["../../../../../../../ai-tools/src/web/web-search.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n SearchProvider,\n WebSearchInput,\n WebSearchOptions,\n WebSearchResult,\n WebSearchResultItem,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, optionalNumberField, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"web_search\";\n\n/** Default per-call result cap when the constructor does not set one. */\nconst DEFAULT_MAX_RESULTS = 5;\n\n/**\n * Per-provider configuration — the environment variable consulted when no\n * `apiKey` is passed, plus the human-readable provider label used in\n * error messages.\n */\nconst PROVIDER_ENV: Record<SearchProvider, string> = {\n tavily: \"TAVILY_API_KEY\",\n brave: \"BRAVE_API_KEY\",\n serpapi: \"SERPAPI_API_KEY\",\n};\n\n/** Input schema: `{ query, maxResults? }`. */\nconst inputSchema = objectSchema<WebSearchInput>({\n query: stringField(),\n maxResults: optionalNumberField(),\n});\n\n/**\n * Resolve the API key from explicit options or the provider's environment\n * variable, throwing a typed {@link WebToolError} when neither is present.\n */\nfunction resolveApiKey(provider: SearchProvider, apiKey?: string): string {\n const key = apiKey ?? process.env[PROVIDER_ENV[provider]];\n\n if (!key) {\n throw new WebToolError(\n `web_search requires an API key for the \"${provider}\" provider. ` +\n `Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`,\n { type: \"missing-key\" },\n );\n }\n\n return key;\n}\n\n/**\n * Clamp the model-requested result count into `[1, max]`. An omitted /\n * non-positive request falls back to the configured default.\n */\nfunction clampResults(requested: number | undefined, max: number): number {\n if (requested === undefined || requested < 1) {\n return Math.min(DEFAULT_MAX_RESULTS, max);\n }\n\n return Math.min(Math.max(1, Math.floor(requested)), max);\n}\n\n/**\n * Shape of the relevant slice of a Tavily `/search` response. Tavily\n * returns LLM-ready `content` snippets and a relevance `score` per hit.\n */\ninterface TavilyResponse {\n results?: Array<{ title?: string; url?: string; content?: string; score?: number }>;\n}\n\n/** Shape of the relevant slice of a Brave web-search response. */\ninterface BraveResponse {\n web?: { results?: Array<{ title?: string; url?: string; description?: string }> };\n}\n\n/** Shape of the relevant slice of a SerpAPI `search.json` response. */\ninterface SerpApiResponse {\n organic_results?: Array<{ title?: string; link?: string; snippet?: string }>;\n}\n\n/**\n * Issue the provider HTTP call and return its parsed JSON, mapping a\n * non-OK status or a network failure to a typed {@link WebToolError}.\n */\nasync function fetchJson(\n url: string,\n init: RequestInit,\n provider: SearchProvider,\n): Promise<unknown> {\n let response: Response;\n\n try {\n response = await fetch(url, init);\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(\n `web_search request to the \"${provider}\" provider failed: ${message}`,\n { type: \"request-failed\", cause },\n );\n }\n\n if (!response.ok) {\n throw new WebToolError(\n `web_search \"${provider}\" provider returned HTTP ${response.status}.`,\n { type: \"request-failed\", context: { status: response.status } },\n );\n }\n\n return response.json();\n}\n\n/** Drive Tavily's `/search` HTTP API and normalize its hits. */\nasync function searchTavily(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const json = (await fetchJson(\n \"https://api.tavily.com/search\",\n {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults }),\n },\n \"tavily\",\n )) as TavilyResponse;\n\n return (json.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.content ?? \"\",\n score: hit.score,\n }));\n}\n\n/** Drive Brave's web-search HTTP API and normalize its hits. */\nasync function searchBrave(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://api.search.brave.com/res/v1/web/search\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"count\", String(maxResults));\n\n const json = (await fetchJson(\n url.toString(),\n {\n method: \"GET\",\n headers: { accept: \"application/json\", \"x-subscription-token\": apiKey },\n },\n \"brave\",\n )) as BraveResponse;\n\n return (json.web?.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.description ?? \"\",\n }));\n}\n\n/** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */\nasync function searchSerpApi(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://serpapi.com/search.json\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"num\", String(maxResults));\n url.searchParams.set(\"api_key\", apiKey);\n\n const json = (await fetchJson(url.toString(), { method: \"GET\" }, \"serpapi\")) as SerpApiResponse;\n\n return (json.organic_results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.link ?? \"\",\n snippet: hit.snippet ?? \"\",\n }));\n}\n\n/**\n * Build the agent-facing `web_search` tool over a {@link SearchProvider}.\n *\n * The provider's HTTP API is called directly through the global `fetch`\n * (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance\n * scores), Brave and SerpAPI via their `GET` endpoints. The API key is\n * taken from `options.apiKey`, falling back to the provider's environment\n * variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No\n * provider SDK is required for the HTTP path; `@tavily/core` remains an\n * optional peer for callers who prefer it, but this factory never forces\n * it to be installed.\n *\n * The model passes `{ query, maxResults? }`; `maxResults` is clamped into\n * `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).\n *\n * **Errors flow as data.** A missing key, a non-OK provider status, or a\n * network failure throws a typed {@link WebToolError}; the `tool()`\n * wrapper catches it and surfaces it in the returned `{ error }` field —\n * `invoke()` never throws — so the agent can read the failure and\n * self-correct.\n *\n * @param options - Provider selection, API key, result cap, and an\n * optional tool-name override.\n * @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.\n *\n * @example\n * const search = webSearchTool({ provider: \"tavily\" });\n * const { data } = await search.invoke({ query: \"warlock.js ai tools\" });\n * for (const hit of data?.results ?? []) console.log(hit.title, hit.url);\n */\nexport function webSearchTool(\n options: WebSearchOptions,\n): ToolContract<WebSearchInput, WebSearchResult> {\n const provider = options.provider;\n const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;\n\n return tool<WebSearchInput, WebSearchResult>({\n name: options.name ?? DEFAULT_NAME,\n description:\n \"Search the web for current information and return a ranked list of \" +\n \"results (title, URL, and a short snippet). Use for facts that may have \" +\n \"changed since training, or anything you are unsure about.\",\n input: inputSchema,\n async execute(input) {\n const apiKey = resolveApiKey(provider, options.apiKey);\n const limit = clampResults(input.maxResults, maxResults);\n\n let results: WebSearchResultItem[];\n\n switch (provider) {\n case \"tavily\":\n results = await searchTavily(input.query, limit, apiKey);\n break;\n case \"brave\":\n results = await searchBrave(input.query, limit, apiKey);\n break;\n case \"serpapi\":\n results = await searchSerpApi(input.query, limit, apiKey);\n break;\n }\n\n return { results };\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"web-search.mjs","names":[],"sources":["../../../../../../../ai-tools/src/web/web-search.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport type {\n SearchProvider,\n WebSearchInput,\n WebSearchOptions,\n WebSearchResult,\n WebSearchResultItem,\n} from \"../contracts\";\nimport { WebToolError } from \"../errors\";\nimport { objectSchema, optionalNumberField, stringField } from \"./schema\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"web_search\";\n\n/** Default per-call result cap when the constructor does not set one. */\nconst DEFAULT_MAX_RESULTS = 5;\n\n/**\n * Per-provider configuration — the environment variable consulted when no\n * `apiKey` is passed, plus the human-readable provider label used in\n * error messages.\n */\nconst PROVIDER_ENV: Record<SearchProvider, string> = {\n tavily: \"TAVILY_API_KEY\",\n brave: \"BRAVE_API_KEY\",\n serpapi: \"SERPAPI_API_KEY\",\n};\n\n/** Input schema: `{ query, maxResults? }`. */\nconst inputSchema = objectSchema<WebSearchInput>({\n query: stringField(),\n maxResults: optionalNumberField(),\n});\n\n/**\n * Resolve the API key from explicit options or the provider's environment\n * variable, throwing a typed {@link WebToolError} when neither is present.\n */\nfunction resolveApiKey(provider: SearchProvider, apiKey?: string): string {\n const key = apiKey ?? process.env[PROVIDER_ENV[provider]];\n\n if (!key) {\n throw new WebToolError(\n `web_search requires an API key for the \"${provider}\" provider. ` +\n `Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`,\n { type: \"missing-key\" },\n );\n }\n\n return key;\n}\n\n/**\n * Clamp the model-requested result count into `[1, max]`. An omitted /\n * non-positive request falls back to the configured default.\n */\nfunction clampResults(requested: number | undefined, max: number): number {\n if (requested === undefined || requested < 1) {\n return Math.min(DEFAULT_MAX_RESULTS, max);\n }\n\n return Math.min(Math.max(1, Math.floor(requested)), max);\n}\n\n/**\n * Shape of the relevant slice of a Tavily `/search` response. Tavily\n * returns LLM-ready `content` snippets and a relevance `score` per hit.\n */\ninterface TavilyResponse {\n results?: Array<{ title?: string; url?: string; content?: string; score?: number }>;\n}\n\n/** Shape of the relevant slice of a Brave web-search response. */\ninterface BraveResponse {\n web?: { results?: Array<{ title?: string; url?: string; description?: string }> };\n}\n\n/** Shape of the relevant slice of a SerpAPI `search.json` response. */\ninterface SerpApiResponse {\n organic_results?: Array<{ title?: string; link?: string; snippet?: string }>;\n}\n\n/**\n * Issue the provider HTTP call and return its parsed JSON, mapping a\n * non-OK status or a network failure to a typed {@link WebToolError}.\n */\nasync function fetchJson(\n url: string,\n init: RequestInit,\n provider: SearchProvider,\n): Promise<unknown> {\n let response: Response;\n\n try {\n response = await fetch(url, init);\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause);\n\n throw new WebToolError(\n `web_search request to the \"${provider}\" provider failed: ${message}`,\n { type: \"request-failed\", cause },\n );\n }\n\n if (!response.ok) {\n throw new WebToolError(\n `web_search \"${provider}\" provider returned HTTP ${response.status}.`,\n { type: \"request-failed\", context: { status: response.status } },\n );\n }\n\n return response.json();\n}\n\n/** Drive Tavily's `/search` HTTP API and normalize its hits. */\nasync function searchTavily(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const json = (await fetchJson(\n \"https://api.tavily.com/search\",\n {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ api_key: apiKey, query, max_results: maxResults }),\n },\n \"tavily\",\n )) as TavilyResponse;\n\n return (json.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.content ?? \"\",\n score: hit.score,\n }));\n}\n\n/** Drive Brave's web-search HTTP API and normalize its hits. */\nasync function searchBrave(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://api.search.brave.com/res/v1/web/search\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"count\", String(maxResults));\n\n const json = (await fetchJson(\n url.toString(),\n {\n method: \"GET\",\n headers: { accept: \"application/json\", \"x-subscription-token\": apiKey },\n },\n \"brave\",\n )) as BraveResponse;\n\n return (json.web?.results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.url ?? \"\",\n snippet: hit.description ?? \"\",\n }));\n}\n\n/** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */\nasync function searchSerpApi(\n query: string,\n maxResults: number,\n apiKey: string,\n): Promise<WebSearchResultItem[]> {\n const url = new URL(\"https://serpapi.com/search.json\");\n url.searchParams.set(\"q\", query);\n url.searchParams.set(\"num\", String(maxResults));\n url.searchParams.set(\"api_key\", apiKey);\n\n const json = (await fetchJson(url.toString(), { method: \"GET\" }, \"serpapi\")) as SerpApiResponse;\n\n return (json.organic_results ?? []).slice(0, maxResults).map((hit) => ({\n title: hit.title ?? \"\",\n url: hit.link ?? \"\",\n snippet: hit.snippet ?? \"\",\n }));\n}\n\n/**\n * Build the agent-facing `web_search` tool over a {@link SearchProvider}.\n *\n * The provider's HTTP API is called directly through the global `fetch`\n * (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance\n * scores), Brave and SerpAPI via their `GET` endpoints. The API key is\n * taken from `options.apiKey`, falling back to the provider's environment\n * variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No\n * provider SDK is required for the HTTP path; `@tavily/core` remains an\n * optional peer for callers who prefer it, but this factory never forces\n * it to be installed.\n *\n * The model passes `{ query, maxResults? }`; `maxResults` is clamped into\n * `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).\n *\n * **Errors flow as data.** A missing key, a non-OK provider status, or a\n * network failure throws a typed {@link WebToolError}; the `tool()`\n * wrapper catches it and surfaces it in the returned `{ error }` field —\n * `invoke()` never throws — so the agent can read the failure and\n * self-correct.\n *\n * @param options - Provider selection, API key, result cap, and an\n * optional tool-name override.\n * @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.\n *\n * @example\n * const search = webSearchTool({ provider: \"tavily\" });\n * const { data } = await search.invoke({ query: \"warlock.js ai tools\" });\n * for (const hit of data?.results ?? []) console.log(hit.title, hit.url);\n */\nexport function webSearchTool(\n options: WebSearchOptions,\n): ToolContract<WebSearchInput, WebSearchResult> {\n const provider = options.provider;\n const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;\n\n return tool<WebSearchInput, WebSearchResult>({\n name: options.name ?? DEFAULT_NAME,\n description:\n \"Search the web for current information and return a ranked list of \" +\n \"results (title, URL, and a short snippet). Use for facts that may have \" +\n \"changed since training, or anything you are unsure about.\",\n input: inputSchema,\n async execute(input) {\n const apiKey = resolveApiKey(provider, options.apiKey);\n const limit = clampResults(input.maxResults, maxResults);\n\n let results: WebSearchResultItem[];\n\n switch (provider) {\n case \"tavily\":\n results = await searchTavily(input.query, limit, apiKey);\n break;\n case \"brave\":\n results = await searchBrave(input.query, limit, apiKey);\n break;\n case \"serpapi\":\n results = await searchSerpApi(input.query, limit, apiKey);\n break;\n }\n\n return { results };\n },\n });\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "@modelcontextprotocol/sdk": "*",
23
23
  "@mozilla/readability": "*",
24
24
  "@tavily/core": "*",
25
- "@warlock.js/ai": "5.2.3",
25
+ "@warlock.js/ai": "5.3.0",
26
26
  "ajv": "*",
27
27
  "jsdom": "*"
28
28
  },
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@standard-schema/spec": "^1.0.0"
48
48
  },
49
- "version": "5.2.3",
49
+ "version": "5.3.0",
50
50
  "main": "./cjs/index.cjs",
51
51
  "module": "./esm/index.mjs",
52
52
  "types": "./esm/index.d.mts",