pi-web-kit 0.2.4 → 0.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.
@@ -1,17 +1,35 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
4
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
 
6
7
  const PACKAGE_NAME = "pi-web-kit";
7
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
8
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
9
+ const CI_ENVIRONMENT_VARIABLES = [
10
+ "APPVEYOR",
11
+ "BITBUCKET_BUILD_NUMBER",
12
+ "BUILDKITE",
13
+ "CIRCLECI",
14
+ "CODESPACES",
15
+ "DRONE",
16
+ "GITHUB_ACTIONS",
17
+ "GITLAB_CI",
18
+ "JENKINS_URL",
19
+ "NETLIFY",
20
+ "TEAMCITY_VERSION",
21
+ "TF_BUILD",
22
+ "TRAVIS",
23
+ "VERCEL",
24
+ ];
9
25
 
10
- type InstallTelemetryState = { lastReportedVersion?: string };
26
+ interface PiSettingsDocument {
27
+ enableInstallTelemetry?: unknown;
28
+ }
11
29
 
12
30
  function readJsonFile(path: string): unknown {
13
31
  try {
14
- return JSON.parse(readFileSync(path, "utf8"));
32
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
15
33
  } catch {
16
34
  return {};
17
35
  }
@@ -22,48 +40,38 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
22
40
  return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
23
41
  }
24
42
 
25
- function isInstallTelemetryEnabled(): boolean {
26
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
27
- if (isTruthyEnvFlag(process.env.CI) || isTruthyEnvFlag(process.env.GITHUB_ACTIONS)) return false;
28
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
29
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as { enableInstallTelemetry?: unknown };
30
- return settings.enableInstallTelemetry !== false;
43
+ function isPresentEnvFlag(value: string | undefined): boolean {
44
+ if (!value) return false;
45
+ const normalized = value.toLowerCase();
46
+ return normalized !== "0" && normalized !== "false" && normalized !== "no";
31
47
  }
32
48
 
33
- function getPackageVersion(): string {
34
- try {
35
- const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
36
- return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
37
- } catch {
38
- return "0.0.0";
39
- }
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
53
+
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
40
58
  }
41
59
 
42
- function getInstallTelemetryUserAgent(version: string): string {
43
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
44
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
45
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
60
+ function getPackageVersion(): string {
61
+ const packageJson = readJsonFile(fileURLToPath(new URL("../package.json", import.meta.url))) as { version?: unknown };
62
+ return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
46
63
  }
47
64
 
48
- export async function reportInstallTelemetry(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
49
66
  try {
50
- if (!isInstallTelemetryEnabled()) return;
51
-
52
- const version = getPackageVersion();
53
- const telemetryDir = join(getAgentDir(), "extensions");
54
- const statePath = join(telemetryDir, "pi-web-kit-install.json");
55
- const state = readJsonFile(statePath) as InstallTelemetryState;
56
- if (state.lastReportedVersion === version) return;
57
-
58
- await mkdir(telemetryDir, { recursive: true });
59
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`);
60
-
61
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
62
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
63
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
64
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
65
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "pi-web-kit-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
66
74
  } catch {
67
- // Best-effort install telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
68
76
  }
69
77
  }
package/src/limits.ts CHANGED
@@ -1,8 +1,19 @@
1
+ import type { SearchProviderName } from "./types.js";
2
+
1
3
  export const MAX_QUERY_COUNT = 5;
2
4
  export const MAX_URL_COUNT = 10;
3
5
  export const MAX_URL_LENGTH = 2_048;
4
6
  export const MAX_NUM_RESULTS = 20;
5
7
  export const DEFAULT_NUM_RESULTS = 10;
8
+ export const DEFAULT_SEARCH_CONTEXT_TOKENS = 8_192;
9
+ export const MAX_SEARCH_CONTEXT_TOKENS = 10_000;
10
+ export const SEARCH_RESULT_LIMITS: Readonly<Record<SearchProviderName, number | undefined>> = {
11
+ exa: 100,
12
+ brave: 50,
13
+ firecrawl: 100,
14
+ tinyfish: undefined,
15
+ };
16
+ export const TINYFISH_MAX_PAGE = 10;
6
17
  export const MAX_OFFSET = 10_000_000;
7
18
  export const MAX_LIMIT = 100_000;
8
19
  export const DEFAULT_FETCH_LIMIT = 30_000;
@@ -12,3 +23,31 @@ export const FETCH_CACHE_MAX_ENTRIES = 100;
12
23
  export const FETCH_CACHE_MAX_BYTES = 20 * 1024 * 1024;
13
24
  export const FETCH_CACHE_TTL_MS = 30 * 60 * 1000;
14
25
  export const FETCH_CONCURRENCY = 3;
26
+
27
+ export function capSearchResultLimit(provider: SearchProviderName, requested: number): number {
28
+ return Math.min(requested, SEARCH_RESULT_LIMITS[provider] ?? requested);
29
+ }
30
+
31
+ export function applySearchContextBudget(results: Array<{ content?: string; [key: string]: unknown }>, maxCharacters: number) {
32
+ let remaining = maxCharacters;
33
+ let remainingContentResults = results.filter((result) => typeof result.content === "string").length;
34
+ let contextCharacters = 0;
35
+ let omittedContextCharacters = 0;
36
+ const bounded = results.map((result) => {
37
+ if (typeof result.content !== "string") return result;
38
+ const share = Math.max(0, Math.floor(remaining / remainingContentResults));
39
+ const content = safePrefix(result.content, share);
40
+ remaining -= content.length;
41
+ remainingContentResults--;
42
+ contextCharacters += content.length;
43
+ omittedContextCharacters += result.content.length - content.length;
44
+ return { ...result, content };
45
+ });
46
+ return { results: bounded, contextCharacters, omittedContextCharacters };
47
+ }
48
+
49
+ export function safePrefix(value: string, maxChars: number): string {
50
+ let end = Math.min(value.length, maxChars);
51
+ if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end--;
52
+ return value.slice(0, end);
53
+ }
package/src/output.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { Type, type TSchema } from "typebox";
2
+ import { Check } from "typebox/value";
3
+
4
+ const object = (properties: Record<string, TSchema>) => Type.Object(properties, { additionalProperties: false });
5
+ const string = () => Type.Optional(Type.String());
6
+ const number = () => Type.Optional(Type.Number());
7
+ const boolean = () => Type.Optional(Type.Boolean());
8
+ const truncated = object({ truncated: Type.Literal(true), message: Type.String() });
9
+ const output = (schema: TSchema) => Type.Union([schema, truncated]);
10
+
11
+ // Only explicitly selected fields cross the tool boundary. Provider metadata,
12
+ // cache keys, response headers and Context7's untyped `rules` are not a contract.
13
+ export const WEB_OUTPUT_SCHEMAS: Record<string, TSchema> = {
14
+ web_search: output(object({
15
+ provider: Type.String(),
16
+ queries: Type.Array(object({
17
+ query: Type.String(), requestedResultLimit: Type.Integer(), effectiveResultLimit: Type.Integer(),
18
+ requestedContextTokens: Type.Optional(Type.Integer()), effectiveContextTokens: Type.Integer(),
19
+ contextCharacters: Type.Integer(), omittedContextCharacters: Type.Optional(Type.Integer()),
20
+ resultCount: Type.Integer(), omittedResultCount: Type.Optional(Type.Integer()),
21
+ results: Type.Array(object({
22
+ url: Type.String(), title: string(), snippet: string(), content: string(),
23
+ contentFormat: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("text")])),
24
+ siteName: string(), position: number(),
25
+ })),
26
+ })),
27
+ })),
28
+ web_fetch: output(object({
29
+ provider: Type.String(),
30
+ results: Type.Array(Type.Union([
31
+ object({ url: Type.String(), error: Type.String() }),
32
+ object({
33
+ url: Type.String(), fetchedUrl: Type.String(), title: string(), content: Type.String(),
34
+ format: Type.Union([Type.Literal("markdown"), Type.Literal("html"), Type.Literal("json")]),
35
+ cached: Type.Boolean(), refreshed: Type.Boolean(),
36
+ range: object({
37
+ offset: Type.Integer(), limit: Type.Integer(), returned: Type.Integer(), total: Type.Integer(),
38
+ truncated: Type.Boolean(), hasPrevious: Type.Boolean(), hasNext: Type.Boolean(),
39
+ nextOffset: Type.Optional(Type.Integer()),
40
+ }),
41
+ }),
42
+ ])),
43
+ })),
44
+ library_search: output(object({
45
+ provider: Type.Literal("context7"), libraryName: Type.String(), query: Type.String(),
46
+ searchFilterApplied: boolean(),
47
+ results: Type.Array(object({
48
+ id: Type.String(), title: string(), description: string(), branch: string(),
49
+ lastUpdateDate: string(), state: string(), totalTokens: number(), totalSnippets: number(),
50
+ stars: number(), trustScore: number(), benchmarkScore: number(),
51
+ versions: Type.Optional(Type.Array(Type.String())),
52
+ })),
53
+ })),
54
+ library_docs: output(object({
55
+ provider: Type.Literal("context7"), libraryId: Type.String(), query: Type.String(),
56
+ codeSnippets: Type.Array(object({
57
+ codeTitle: string(), codeDescription: string(), codeLanguage: string(), codeTokens: number(),
58
+ codeId: string(), pageTitle: string(), sourceFile: string(), isDynamic: boolean(),
59
+ codeList: Type.Optional(Type.Array(object({ language: Type.String(), code: Type.String() }))),
60
+ })),
61
+ infoSnippets: Type.Array(object({
62
+ pageId: string(), breadcrumb: string(), content: Type.String(), contentTokens: number(),
63
+ })),
64
+ })),
65
+ code_search: output(object({
66
+ provider: Type.Literal("exa"), query: Type.String(), response: Type.String(),
67
+ resultsCount: number(), searchTime: number(), outputTokens: number(), requestId: string(),
68
+ })),
69
+ };
70
+
71
+ export const WEB_TOOL_METADATA = {
72
+ namespace: {
73
+ name: "web",
74
+ description: "Bounded web research, library documentation and code examples.",
75
+ instructions: "Keep existing tool names: web_search, web_fetch, library_search, library_docs, code_search. Await describeTool(name) for provider-specific inputs and output types. Calls return objects, not JSON strings. Fetch failures are per-item error strings; check them before using content. A top-level truncated:true/message result asks you to refine the request. Validation, cancellation and request failures reject. Parallel independent reads are supported; provider requests can incur costs. Availability depends on configured providers and active tools. These hints do not grant approval.",
76
+ },
77
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
78
+ } as const;
79
+
80
+ /** Project to an allowlist before bounding or publishing. Invalid required data fails closed. */
81
+ export function projectOutput(schema: TSchema, value: unknown): any {
82
+ const shape = schema as TSchema & {
83
+ anyOf?: TSchema[]; type?: string; properties?: Record<string, TSchema>;
84
+ required?: string[]; items?: TSchema;
85
+ };
86
+ if (shape.anyOf) {
87
+ for (const variant of shape.anyOf) {
88
+ try { return projectOutput(variant, value); } catch { /* try the next declared shape */ }
89
+ }
90
+ throw new Error("Provider returned an invalid result shape.");
91
+ }
92
+ let projected = value;
93
+ if (shape.type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
94
+ projected = Object.fromEntries(Object.entries(shape.properties ?? {}).flatMap(([key, field]) => {
95
+ if (!Object.hasOwn(value, key) || (value as any)[key] == null) return [];
96
+ try { return [[key, projectOutput(field as TSchema, (value as any)[key])]]; }
97
+ catch {
98
+ if (shape.required?.includes(key)) throw new Error("Provider returned an invalid result shape.");
99
+ return [];
100
+ }
101
+ }));
102
+ } else if (shape.type === "array" && shape.items && Array.isArray(value)) {
103
+ projected = value.map(item => projectOutput(shape.items!, item));
104
+ }
105
+ if (!Check(schema, projected)) throw new Error("Provider returned an invalid result shape.");
106
+ return projected;
107
+ }
108
+
109
+ export function redactText(text: string, secrets: readonly string[] = []): string {
110
+ let redacted = text;
111
+ for (const secret of secrets.filter(Boolean).sort((a, b) => b.length - a.length)) {
112
+ redacted = redacted.split(secret).join("*".repeat(secret.length));
113
+ }
114
+ return redacted
115
+ .replace(/(https?:\/\/)([^\s/@"<>]+)@/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}@`)
116
+ // Header context distinguishes credentials from prose such as "Basic
117
+ // authentication" or "Bearer token", without exempting short credentials.
118
+ .replace(/(\b(?:proxy-)?authorization\b["']?\s*[:=]\s*["'`]?(?:Bearer|Basic)[ \t]+)([A-Za-z0-9._~+/=-]+)/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}`)
119
+ .replace(/([?&](?:api[-_]?key|access[-_]?token|token|secret|password)=)([^&#\s"'<>]+)/gi, (_match, prefix, secret) => `${prefix}${"*".repeat(secret.length)}`);
120
+ }
121
+
122
+ export function redactOutput(value: any, secrets: readonly string[]): any {
123
+ if (typeof value === "string") return redactText(value, secrets);
124
+ if (Array.isArray(value)) return value.map(item => redactOutput(item, secrets));
125
+ if (value && typeof value === "object") {
126
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactOutput(item, secrets)]));
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /** Provider error bodies are not page content and must not become tool output. */
132
+ export function publicPageError(error: unknown): string {
133
+ if (typeof error === "string" && (
134
+ /^Provider request failed \(HTTP \d{3}\)\.$/.test(error)
135
+ || /^Request timed out after \d+ms$/.test(error)
136
+ || /^No content returned(?: by (?:TinyFish|Exa(?: contents endpoint)?))?\.$/.test(error)
137
+ || error === "Provider returned invalid JSON."
138
+ )) return error;
139
+ return "Provider could not fetch this page.";
140
+ }
@@ -1,4 +1,4 @@
1
- import { asSnippet, requestJson } from "../http.js";
1
+ import { asSnippet, asText, requestJson } from "../http.js";
2
2
  import type { SearchInput, SearchProvider, WebKitConfig } from "../types.js";
3
3
  import { requireKey } from "../config.js";
4
4
 
@@ -7,24 +7,51 @@ export class BraveProvider implements SearchProvider {
7
7
  constructor(config: WebKitConfig) { this.key = requireKey(config, "brave"); }
8
8
 
9
9
  async search(input: SearchInput, signal?: AbortSignal) {
10
- const url = new URL("https://api.search.brave.com/res/v1/llm/context");
11
- url.searchParams.set("q", input.query);
10
+ const params: Record<string, string | number | boolean | string[]> = { q: boundedQuery(input.query) };
12
11
  if (input.numResults) {
13
- const count = String(Math.max(1, Math.min(input.numResults, 20)));
14
- url.searchParams.set("count", count);
15
- url.searchParams.set("maximum_number_of_urls", String(input.maxUrls ?? count));
12
+ params.count = input.numResults;
13
+ params.maximum_number_of_urls = input.numResults;
14
+ }
15
+ if (input.contextTokens) params.maximum_number_of_tokens = Math.max(1_024, Math.min(input.contextTokens, 32_768));
16
+ if (typeof input.country === "string") params.country = input.country;
17
+ if (typeof input.searchLang === "string") params.search_lang = input.searchLang;
18
+ if (typeof input.safesearch === "string") params.safesearch = input.safesearch;
19
+ if (typeof input.freshness === "string") params.freshness = input.freshness;
20
+ if (typeof input.spellcheck === "boolean") params.spellcheck = input.spellcheck;
21
+ if (typeof input.contextThresholdMode === "string") params.context_threshold_mode = input.contextThresholdMode;
22
+ if (typeof input.maxSnippets === "number") params.maximum_number_of_snippets = input.maxSnippets;
23
+ if (typeof input.maxTokensPerUrl === "number") params.maximum_number_of_tokens_per_url = input.maxTokensPerUrl;
24
+ if (typeof input.maxSnippetsPerUrl === "number") params.maximum_number_of_snippets_per_url = input.maxSnippetsPerUrl;
25
+ if (typeof input.goggles === "string" || Array.isArray(input.goggles)) params.goggles = input.goggles;
26
+
27
+ const url = new URL("https://api.search.brave.com/res/v1/llm/context");
28
+ for (const [name, value] of Object.entries(params)) {
29
+ if (Array.isArray(value)) for (const item of value) url.searchParams.append(name, item);
30
+ else url.searchParams.set(name, String(value));
16
31
  }
17
- if (typeof input.country === "string") url.searchParams.set("country", input.country);
18
- if (typeof input.searchLang === "string") url.searchParams.set("search_lang", input.searchLang);
19
- if (typeof input.uiLang === "string") url.searchParams.set("ui_lang", input.uiLang);
20
- if (typeof input.safesearch === "string") url.searchParams.set("safesearch", input.safesearch);
21
- if (typeof input.freshness === "string") url.searchParams.set("freshness", input.freshness);
22
- const data = await requestJson<any>(url.toString(), { headers: { "X-Subscription-Token": this.key, accept: "application/json" }, signal, timeoutMs: 30000 });
32
+ const usePost = input.goggles != null || url.toString().length > 2_000;
33
+ const data = await requestJson<any>(usePost ? `${url.origin}${url.pathname}` : url.toString(), {
34
+ method: usePost ? "POST" : undefined,
35
+ headers: { "X-Subscription-Token": this.key, accept: "application/json", ...(usePost ? { "content-type": "application/json" } : {}) },
36
+ body: usePost ? JSON.stringify(params) : undefined,
37
+ signal,
38
+ timeoutMs: 30000,
39
+ });
23
40
  const sources = data.sources ?? data.web?.results ?? [];
24
41
  const snippets = data.grounding?.generic ?? [];
25
- const rows = sources.length ? sources : snippets;
26
- return { provider: "brave" as const, query: input.query, results: rows.map((r: any, i: number) => ({
27
- title: r.title ?? r.name, url: r.url, snippet: asSnippet(r.snippets ?? r.description ?? snippets[i]?.snippets), siteName: r.site_name ?? r.source, position: i + 1,
28
- })).filter((r: any) => r.url) };
42
+ const sourceRows = Array.isArray(sources)
43
+ ? sources
44
+ : Object.entries(sources).map(([url, source]: [string, any]) => ({ ...source, url }));
45
+ const rows = snippets.length ? snippets : sourceRows;
46
+ return { provider: "brave" as const, query: input.query, results: rows.map((r: any, i: number) => {
47
+ const content = asText(r.snippets ?? r.snippet ?? r.description ?? snippets[i]?.snippets);
48
+ return {
49
+ title: r.title ?? r.name, url: r.url, snippet: asSnippet(content), content, contentFormat: content ? "markdown" as const : undefined, siteName: r.site_name ?? r.source, position: i + 1,
50
+ };
51
+ }).filter((r: any) => r.url) };
29
52
  }
30
53
  }
54
+
55
+ function boundedQuery(query: string): string {
56
+ return query.trim().split(/\s+/).slice(0, 50).join(" ").slice(0, 400);
57
+ }
@@ -1,4 +1,4 @@
1
- import { asSnippet, normalizeUrls, requestJson } from "../http.js";
1
+ import { asSnippet, asText, normalizeUrls, requestJson, withoutContent } from "../http.js";
2
2
  import { DEFAULT_NUM_RESULTS } from "../limits.js";
3
3
  import { urlsMatch } from "../urls.js";
4
4
  import type { ExaCodeInput, ExaCodeResult, FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
@@ -14,10 +14,15 @@ export class ExaProvider implements SearchProvider, FetchProvider {
14
14
  }
15
15
 
16
16
  async search(input: SearchInput, signal?: AbortSignal) {
17
+ const textCharacters = input.contextTokens ? Math.min(100_000, Math.max(1, Math.floor(input.contextTokens * 4 / (input.numResults ?? DEFAULT_NUM_RESULTS)))) : undefined;
17
18
  const body = {
18
19
  query: input.query,
19
20
  numResults: input.numResults ?? DEFAULT_NUM_RESULTS,
20
- contents: input.contents ?? { highlights: true },
21
+ contents: input.contents ?? {
22
+ highlights: input.purpose ? { query: input.purpose } : true,
23
+ ...(textCharacters ? { text: { maxCharacters: textCharacters } } : {}),
24
+ ...(typeof input.maxAgeHours === "number" ? { maxAgeHours: input.maxAgeHours } : {}),
25
+ },
21
26
  includeDomains: input.includeDomains,
22
27
  excludeDomains: input.excludeDomains,
23
28
  startPublishedDate: input.startPublishedDate,
@@ -37,13 +42,18 @@ export class ExaProvider implements SearchProvider, FetchProvider {
37
42
  return {
38
43
  provider: "exa" as const,
39
44
  query: input.query,
40
- results: (data.results ?? []).map((r: any, i: number) => ({
41
- title: r.title,
42
- url: r.url,
43
- snippet: asSnippet(r.highlights ?? r.summary ?? r.text),
44
- siteName: r.author ?? r.publishedDate,
45
- position: i + 1,
46
- })).filter((r: any) => r.url),
45
+ results: (data.results ?? []).map((r: any, i: number) => {
46
+ const content = asText(r.text ?? r.highlights ?? r.summary);
47
+ return {
48
+ title: r.title,
49
+ url: r.url,
50
+ snippet: asSnippet(content),
51
+ content,
52
+ contentFormat: content ? "markdown" as const : undefined,
53
+ siteName: r.author ?? r.publishedDate,
54
+ position: i + 1,
55
+ };
56
+ }).filter((r: any) => r.url),
47
57
  };
48
58
  }
49
59
 
@@ -53,7 +63,14 @@ export class ExaProvider implements SearchProvider, FetchProvider {
53
63
  const data = await requestJson<any>("https://api.exa.ai/contents", {
54
64
  method: "POST",
55
65
  headers: this.headers(),
56
- body: JSON.stringify({ urls, text: true, highlights: false }),
66
+ body: JSON.stringify({
67
+ urls,
68
+ text: { maxCharacters: 100_000 },
69
+ highlights: false,
70
+ maxAgeHours: input.refresh === true
71
+ ? 0
72
+ : input.maxAgeHours ?? (typeof input.maxAgeMs === "number" ? Math.floor(input.maxAgeMs / 3_600_000) : undefined),
73
+ }),
57
74
  signal,
58
75
  timeoutMs: 45000,
59
76
  });
@@ -61,7 +78,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
61
78
  const primary: WebFetchResult = { provider: "exa", results: urls.map((url, i) => {
62
79
  const r: any = list.find((item: any) => urlsMatch(item.url, url)) ?? list[i];
63
80
  if (!r) return { url, error: "No content returned by Exa contents endpoint." };
64
- return { url: r.url ?? url, title: r.title, content: r.text ?? r.summary ?? "", format: "markdown" as const, metadata: r };
81
+ return { url: r.url ?? url, title: r.title, content: r.text ?? r.summary ?? "", format: "markdown" as const, metadata: withoutContent(r) };
65
82
  }) };
66
83
  return applyExaFetchFallbacks(this.config, input, urls, primary, signal);
67
84
  }
@@ -1,4 +1,4 @@
1
- import { asSnippet, mapConcurrent, normalizeUrls, requestJson } from "../http.js";
1
+ import { asSnippet, asText, mapConcurrent, normalizeUrls, requestJson } from "../http.js";
2
2
  import { DEFAULT_NUM_RESULTS, FETCH_CONCURRENCY } from "../limits.js";
3
3
  import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
4
4
  import { requireKey } from "../config.js";
@@ -20,13 +20,22 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
20
20
  excludeDomains: input.excludeDomains,
21
21
  categories: input.categories,
22
22
  tbs: input.tbs,
23
- scrapeOptions: input.scrapeOptions ?? (input.scrape === true ? { formats: ["markdown"] } : undefined),
23
+ scrapeOptions: input.scrapeOptions ?? (input.scrape === true || input.contextTokens ? {
24
+ formats: ["markdown"],
25
+ onlyMainContent: true,
26
+ maxAge: input.maxAge,
27
+ } : undefined),
24
28
  }),
25
29
  });
26
30
  const list = data.data?.web ?? data.web ?? data.data ?? [];
27
- return { provider: "firecrawl" as const, query: input.query, results: list.map((r: any, i: number) => ({
28
- title: r.title, url: r.url, snippet: asSnippet(r.description ?? r.markdown ?? r.content), siteName: r.siteName, position: i + 1,
29
- })).filter((r: any) => r.url) };
31
+ return { provider: "firecrawl" as const, query: input.query, results: list.map((r: any, i: number) => {
32
+ const content = asText(r.markdown ?? r.content ?? r.highlights);
33
+ return {
34
+ title: r.title, url: r.url, snippet: asSnippet(r.highlights ?? r.description ?? content), content,
35
+ contentFormat: content ? (r.markdown ? "markdown" as const : "text" as const) : undefined,
36
+ siteName: r.siteName, position: i + 1,
37
+ };
38
+ }).filter((r: any) => r.url) };
30
39
  }
31
40
 
32
41
  async fetch(input: FetchInput, signal?: AbortSignal) {
@@ -44,7 +53,7 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
44
53
  waitFor: input.waitFor,
45
54
  mobile: input.mobile,
46
55
  location: input.location,
47
- maxAge: input.maxAge,
56
+ maxAge: input.refresh === true ? 0 : input.maxAge ?? input.maxAgeMs,
48
57
  }),
49
58
  });
50
59
  const d = data.data ?? data;
@@ -2,7 +2,6 @@ import { validateFetchProvider, validateSearchProvider } from "../config.js";
2
2
  import type { FetchProvider, SearchProvider, WebKitConfig } from "../types.js";
3
3
  import { BraveProvider } from "./brave.js";
4
4
  import { Context7Provider } from "./context7.js";
5
- import { ExaMcpProvider } from "./exa-mcp.js";
6
5
  import { ExaProvider } from "./exa.js";
7
6
  import { FirecrawlProvider } from "./firecrawl.js";
8
7
  import { MarkdownNewProvider } from "./markdown-new.js";
@@ -11,7 +10,6 @@ import { TinyFishProvider } from "./tinyfish.js";
11
10
  export function createSearchProvider(config: WebKitConfig): SearchProvider {
12
11
  validateSearchProvider(config.provider_search);
13
12
  switch (config.provider_search) {
14
- case "exa_mcp": return new ExaMcpProvider(config);
15
13
  case "exa": return new ExaProvider(config);
16
14
  case "tinyfish": return new TinyFishProvider(config);
17
15
  case "brave": return new BraveProvider(config);
@@ -22,7 +20,6 @@ export function createSearchProvider(config: WebKitConfig): SearchProvider {
22
20
  export function createFetchProvider(config: WebKitConfig): FetchProvider {
23
21
  validateFetchProvider(config.provider_fetch);
24
22
  switch (config.provider_fetch) {
25
- case "exa_mcp": return new ExaMcpProvider(config);
26
23
  case "exa": return new ExaProvider(config);
27
24
  case "tinyfish": return new TinyFishProvider(config);
28
25
  case "markdown_new": return new MarkdownNewProvider(config);
@@ -15,14 +15,22 @@ export class MarkdownNewProvider implements FetchProvider {
15
15
  body: JSON.stringify({
16
16
  url,
17
17
  method: input.method ?? this.config.markdownNew.method,
18
- retainImages: input.retainImages ?? this.config.markdownNew.retainImages,
18
+ retain_images: input.retainImages ?? this.config.markdownNew.retainImages,
19
19
  }),
20
20
  signal,
21
21
  timeoutMs: 45_000,
22
22
  });
23
23
  const text = await res.text();
24
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
25
- return { url, content: text, format: "markdown" as const };
24
+ if (!res.ok) throw new Error(`Provider request failed (HTTP ${res.status}).`);
25
+ return {
26
+ url,
27
+ content: text,
28
+ format: "markdown" as const,
29
+ metadata: {
30
+ estimatedTokens: numberHeader(res.headers.get("x-markdown-tokens")),
31
+ conversionMethod: res.headers.get("x-markdown-method") ?? undefined,
32
+ },
33
+ };
26
34
  } catch (e) {
27
35
  return { url, error: e instanceof Error ? e.message : String(e) };
28
36
  }
@@ -30,3 +38,9 @@ export class MarkdownNewProvider implements FetchProvider {
30
38
  return { provider: "markdown_new" as const, results };
31
39
  }
32
40
  }
41
+
42
+ function numberHeader(value: string | null): number | undefined {
43
+ if (value == null) return undefined;
44
+ const parsed = Number(value);
45
+ return Number.isFinite(parsed) ? parsed : undefined;
46
+ }