pi-web-kit 0.2.3 → 0.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.
- package/CHANGELOG.md +34 -2
- package/README.md +34 -19
- package/SECURITY.md +2 -2
- package/extensions/index.ts +141 -43
- package/package.json +11 -8
- package/src/config.ts +4 -4
- package/src/http.ts +12 -3
- package/src/install-telemetry.ts +48 -40
- package/src/limits.ts +39 -0
- package/src/providers/brave.ts +43 -16
- package/src/providers/exa.ts +28 -11
- package/src/providers/firecrawl.ts +15 -6
- package/src/providers/index.ts +0 -3
- package/src/providers/markdown-new.ts +16 -2
- package/src/providers/tinyfish.ts +137 -17
- package/src/types.ts +12 -3
- package/src/providers/exa-mcp.ts +0 -102
package/src/install-telemetry.ts
CHANGED
|
@@ -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
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
|
65
|
+
export function reportInstallTelemetry(): void {
|
|
49
66
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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/providers/brave.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|
package/src/providers/exa.ts
CHANGED
|
@@ -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 ?? {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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({
|
|
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 ? {
|
|
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
|
-
|
|
29
|
-
|
|
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;
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
24
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
|
|
25
|
-
return {
|
|
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { asSnippet, normalizeUrls, requestJson } from "../http.js";
|
|
2
|
-
import { MAX_URL_COUNT } from "../limits.js";
|
|
3
|
-
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
|
|
1
|
+
import { asSnippet, normalizeUrls, requestJson, withoutContent } from "../http.js";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS, MAX_URL_COUNT, TINYFISH_MAX_PAGE } from "../limits.js";
|
|
3
|
+
import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig, WebSearchResult } from "../types.js";
|
|
4
|
+
import { canonicalWebUrl, urlsMatch } from "../urls.js";
|
|
4
5
|
import { requireKey } from "../config.js";
|
|
5
6
|
|
|
6
7
|
export class TinyFishProvider implements SearchProvider, FetchProvider {
|
|
@@ -8,15 +9,81 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
|
|
|
8
9
|
constructor(config: WebKitConfig) { this.key = requireKey(config, "tinyfish"); }
|
|
9
10
|
|
|
10
11
|
async search(input: SearchInput, signal?: AbortSignal) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
const desired = input.numResults ?? DEFAULT_NUM_RESULTS;
|
|
13
|
+
const firstPage = typeof input.page === "number" ? Math.max(0, Math.min(input.page, TINYFISH_MAX_PAGE)) : 0;
|
|
14
|
+
const research = input.domainType === "research_paper";
|
|
15
|
+
const dates = orderedStrings(input.afterDate, input.beforeDate);
|
|
16
|
+
const years = orderedNumbers(input.pubYearMin, input.pubYearMax);
|
|
17
|
+
const results: WebSearchResult["results"] = [];
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
let lastPage = firstPage - 1;
|
|
20
|
+
|
|
21
|
+
for (let page = firstPage; page <= TINYFISH_MAX_PAGE && results.length < desired; page++) {
|
|
22
|
+
lastPage = page;
|
|
23
|
+
const url = new URL("https://api.search.tinyfish.ai/");
|
|
24
|
+
url.searchParams.set("query", input.query);
|
|
25
|
+
url.searchParams.set("page", String(page));
|
|
26
|
+
setString(url, "purpose", input.purpose);
|
|
27
|
+
setString(url, "location", input.location);
|
|
28
|
+
setString(url, "language", input.language);
|
|
29
|
+
setDomains(url, "include_domains", input.includeDomains);
|
|
30
|
+
setDomains(url, "exclude_domains", input.excludeDomains);
|
|
31
|
+
setString(url, "domain_type", input.domainType);
|
|
32
|
+
if (research) {
|
|
33
|
+
setNumber(url, "pub_year_min", years[0]);
|
|
34
|
+
setNumber(url, "pub_year_max", years[1]);
|
|
35
|
+
} else if (typeof input.recencyMinutes === "number") {
|
|
36
|
+
setNumber(url, "recency_minutes", input.recencyMinutes);
|
|
37
|
+
} else {
|
|
38
|
+
setString(url, "after_date", dates[0]);
|
|
39
|
+
setString(url, "before_date", dates[1]);
|
|
40
|
+
}
|
|
41
|
+
const data = await requestJson<any>(url.toString(), { headers: { "X-API-Key": this.key }, signal, timeoutMs: 10000 });
|
|
42
|
+
const list = data.results ?? data.data ?? data.web ?? [];
|
|
43
|
+
if (!Array.isArray(list) || list.length === 0) break;
|
|
44
|
+
for (const r of list) {
|
|
45
|
+
const resultUrl = r.url ?? r.link;
|
|
46
|
+
const key = typeof resultUrl === "string" ? canonicalResultUrl(resultUrl) : undefined;
|
|
47
|
+
if (!key || seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
results.push({
|
|
50
|
+
title: r.title,
|
|
51
|
+
url: resultUrl,
|
|
52
|
+
snippet: asSnippet(r.snippet ?? r.description ?? r.text),
|
|
53
|
+
siteName: r.siteName ?? r.source,
|
|
54
|
+
position: results.length + 1,
|
|
55
|
+
});
|
|
56
|
+
if (results.length === desired) break;
|
|
57
|
+
}
|
|
58
|
+
if (typeof data.total_results === "number" && results.length >= data.total_results) break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (input.contextTokens && results.length) {
|
|
62
|
+
const contextCharacters = input.contextTokens * 4;
|
|
63
|
+
let extractedCharacters = 0;
|
|
64
|
+
for (let i = 0; i < results.length && extractedCharacters < contextCharacters;) {
|
|
65
|
+
// ponytail: budget for about 512 useful tokens per page; fetch another batch when pages are short.
|
|
66
|
+
const batchSize = Math.min(MAX_URL_COUNT, Math.max(1, Math.ceil((contextCharacters - extractedCharacters) / 2_048)));
|
|
67
|
+
const batch = results.slice(i, i + batchSize);
|
|
68
|
+
const fetched = await this.fetch({ urls: batch.map((item) => item.url), format: "markdown", purpose: input.purpose }, signal);
|
|
69
|
+
for (const item of batch) {
|
|
70
|
+
const page = fetched.results.find((candidate) => urlsMatch(candidate.url, item.url) || urlsMatch(candidate.metadata?.requestedUrl, item.url));
|
|
71
|
+
if (page?.content) {
|
|
72
|
+
item.content = page.content;
|
|
73
|
+
item.contentFormat = "markdown";
|
|
74
|
+
extractedCharacters += page.content.length;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
i += batch.length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
provider: "tinyfish" as const,
|
|
83
|
+
query: input.query,
|
|
84
|
+
effectiveResultLimit: lastPage === TINYFISH_MAX_PAGE && results.length < desired ? results.length : desired,
|
|
85
|
+
results,
|
|
86
|
+
};
|
|
20
87
|
}
|
|
21
88
|
|
|
22
89
|
async fetch(input: FetchInput, signal?: AbortSignal) {
|
|
@@ -25,20 +92,73 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
|
|
|
25
92
|
const data = await requestJson<any>("https://api.fetch.tinyfish.ai", {
|
|
26
93
|
method: "POST",
|
|
27
94
|
headers: { "content-type": "application/json", "X-API-Key": this.key },
|
|
28
|
-
body: JSON.stringify({
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
urls,
|
|
97
|
+
purpose: input.purpose,
|
|
98
|
+
format: input.format ?? "markdown",
|
|
99
|
+
links: input.links,
|
|
100
|
+
image_links: input.imageLinks,
|
|
101
|
+
ttl: input.refresh === true
|
|
102
|
+
? 0
|
|
103
|
+
: input.ttl ?? (typeof input.maxAgeMs === "number" ? Math.floor(input.maxAgeMs / 1_000) : undefined),
|
|
104
|
+
per_url_timeout_ms: input.perUrlTimeoutMs,
|
|
105
|
+
include_selectors: input.includeSelectors,
|
|
106
|
+
exclude_selectors: input.excludeSelectors,
|
|
107
|
+
}),
|
|
29
108
|
signal,
|
|
30
109
|
timeoutMs: 150000,
|
|
31
110
|
});
|
|
32
111
|
const list = data.results ?? data.data ?? data.pages ?? [];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
112
|
+
const errors = data.errors ?? [];
|
|
113
|
+
return { provider: "tinyfish" as const, results: urls.map((url) => {
|
|
114
|
+
const r = list.find((x: any) => urlsMatch(x.url ?? x.source_url, url));
|
|
115
|
+
const failure = errors.find((x: any) => urlsMatch(x.url, url));
|
|
116
|
+
if (!r) return { url, error: failure ? `${failure.error}${failure.status ? ` (${failure.status})` : ""}` : "No content returned by TinyFish." };
|
|
36
117
|
const content = r.text ?? r.content ?? r.markdown ?? r.html;
|
|
37
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
url: r.final_url ?? r.url ?? url,
|
|
120
|
+
content: stringifyContent(content),
|
|
121
|
+
format: input.format ?? r.format ?? "markdown",
|
|
122
|
+
title: r.title,
|
|
123
|
+
metadata: { ...withoutContent(r), requestedUrl: url, finalUrl: r.final_url },
|
|
124
|
+
error: r.error,
|
|
125
|
+
};
|
|
38
126
|
}) };
|
|
39
127
|
}
|
|
40
128
|
}
|
|
41
129
|
|
|
130
|
+
function setString(url: URL, name: string, value: unknown) {
|
|
131
|
+
if (typeof value === "string" && value) url.searchParams.set(name, value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setNumber(url: URL, name: string, value: unknown) {
|
|
135
|
+
if (typeof value === "number") url.searchParams.set(name, String(value));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function setDomains(url: URL, name: string, value: unknown) {
|
|
139
|
+
if (Array.isArray(value) && value.length) url.searchParams.set(name, value.join(","));
|
|
140
|
+
else setString(url, name, value);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function orderedStrings(a: unknown, b: unknown): [string | undefined, string | undefined] {
|
|
144
|
+
const values = [a, b].filter((value): value is string => typeof value === "string").sort();
|
|
145
|
+
return [values[0], values[1]];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function orderedNumbers(a: unknown, b: unknown): [number | undefined, number | undefined] {
|
|
149
|
+
const values = [a, b].filter((value): value is number => typeof value === "number").sort((x, y) => x - y);
|
|
150
|
+
return [values[0], values[1]];
|
|
151
|
+
}
|
|
152
|
+
|
|
42
153
|
function stringifyContent(value: unknown): string | undefined {
|
|
43
154
|
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
44
155
|
}
|
|
156
|
+
|
|
157
|
+
function canonicalResultUrl(value: string): string {
|
|
158
|
+
try {
|
|
159
|
+
const canonical = canonicalWebUrl(value);
|
|
160
|
+
return canonical.endsWith("/") ? canonical.slice(0, -1) : canonical;
|
|
161
|
+
} catch {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type SearchProviderName = "
|
|
2
|
-
export type FetchProviderName = "
|
|
1
|
+
export type SearchProviderName = "exa" | "tinyfish" | "brave" | "firecrawl";
|
|
2
|
+
export type FetchProviderName = "exa" | "tinyfish" | "markdown_new" | "firecrawl";
|
|
3
3
|
export type FetchFormat = "markdown" | "html" | "json";
|
|
4
4
|
|
|
5
5
|
export interface WebKitConfig {
|
|
@@ -12,13 +12,16 @@ export interface WebKitConfig {
|
|
|
12
12
|
export interface SearchInput {
|
|
13
13
|
query: string;
|
|
14
14
|
numResults?: number;
|
|
15
|
+
contextTokens?: number;
|
|
16
|
+
purpose?: string;
|
|
15
17
|
[key: string]: unknown;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface WebSearchResult {
|
|
19
21
|
provider: SearchProviderName;
|
|
20
22
|
query: string;
|
|
21
|
-
|
|
23
|
+
effectiveResultLimit?: number;
|
|
24
|
+
results: Array<{ title?: string; url: string; snippet?: string; content?: string; contentFormat?: "markdown" | "text"; siteName?: string; position?: number }>;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface FetchInput {
|
|
@@ -27,9 +30,15 @@ export interface FetchInput {
|
|
|
27
30
|
offset?: number;
|
|
28
31
|
limit?: number;
|
|
29
32
|
refresh?: boolean;
|
|
33
|
+
maxAgeMs?: number;
|
|
30
34
|
format?: FetchFormat;
|
|
31
35
|
links?: boolean;
|
|
32
36
|
imageLinks?: boolean;
|
|
37
|
+
purpose?: string;
|
|
38
|
+
ttl?: number;
|
|
39
|
+
perUrlTimeoutMs?: number;
|
|
40
|
+
includeSelectors?: string[];
|
|
41
|
+
excludeSelectors?: string[];
|
|
33
42
|
[key: string]: unknown;
|
|
34
43
|
}
|
|
35
44
|
|