@mammothb/pi-web 6.0.3 → 6.1.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/package.json +1 -1
- package/src/config.ts +166 -2
- package/src/lib/format.ts +27 -0
- package/src/lib/providers/index.ts +12 -0
- package/src/lib/providers/searxng.ts +11 -8
- package/src/lib/providers/unsloth.ts +1346 -0
- package/src/websearch.ts +2 -1
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,8 +1,104 @@
|
|
|
1
1
|
import { loadPiConfig } from "@mammothb/pi-shared";
|
|
2
2
|
|
|
3
|
+
export const ALL_UNSLOTH_ENGINES = [
|
|
4
|
+
"duckduckgo",
|
|
5
|
+
"brave",
|
|
6
|
+
"google",
|
|
7
|
+
"mojeek",
|
|
8
|
+
"yahoo",
|
|
9
|
+
"yandex",
|
|
10
|
+
"wikipedia",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type UnslothEngineId = (typeof ALL_UNSLOTH_ENGINES)[number];
|
|
14
|
+
|
|
15
|
+
function assertEngineArray(
|
|
16
|
+
value: unknown,
|
|
17
|
+
label: string,
|
|
18
|
+
): asserts value is unknown[] {
|
|
19
|
+
if (!Array.isArray(value)) {
|
|
20
|
+
throw new Error(`Unsloth: ${label} must be an array`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateEngineIds(ids: unknown[]): void {
|
|
25
|
+
for (const id of ids) {
|
|
26
|
+
if (
|
|
27
|
+
typeof id !== "string" ||
|
|
28
|
+
!(ALL_UNSLOTH_ENGINES as readonly string[]).includes(id)
|
|
29
|
+
) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Unknown unsloth engine: "${String(id)}". Valid: ${ALL_UNSLOTH_ENGINES.join(", ")}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dedupeEngines(ids: UnslothEngineId[]): UnslothEngineId[] {
|
|
38
|
+
const deduped: UnslothEngineId[] = [];
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
if (!seen.has(id)) {
|
|
42
|
+
seen.add(id);
|
|
43
|
+
deduped.push(id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return deduped;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveAllowlist(engines: unknown[]): UnslothEngineId[] {
|
|
50
|
+
assertEngineArray(engines, "engines");
|
|
51
|
+
validateEngineIds(engines);
|
|
52
|
+
const deduped = dedupeEngines(engines as UnslothEngineId[]);
|
|
53
|
+
if (deduped.length === 0) {
|
|
54
|
+
throw new Error("Unsloth: no engines enabled");
|
|
55
|
+
}
|
|
56
|
+
return deduped;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveBlocklist(disabled: unknown[]): UnslothEngineId[] {
|
|
60
|
+
assertEngineArray(disabled, "disabledEngines");
|
|
61
|
+
validateEngineIds(disabled);
|
|
62
|
+
const disabledSet = new Set(disabled as string[]);
|
|
63
|
+
const filtered = (ALL_UNSLOTH_ENGINES as readonly string[]).filter(
|
|
64
|
+
(e) => !disabledSet.has(e),
|
|
65
|
+
) as UnslothEngineId[];
|
|
66
|
+
if (filtered.length === 0) {
|
|
67
|
+
throw new Error("Unsloth: no engines enabled");
|
|
68
|
+
}
|
|
69
|
+
return filtered;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const UNSLOTH_REGION_RE = /^[a-z]{2}-[a-z]{2}$/;
|
|
73
|
+
|
|
74
|
+
export function resolveUnslothEngines(
|
|
75
|
+
cfg?: {
|
|
76
|
+
engines?: UnslothEngineId[];
|
|
77
|
+
disabledEngines?: UnslothEngineId[];
|
|
78
|
+
} | null,
|
|
79
|
+
): UnslothEngineId[] {
|
|
80
|
+
if (!cfg) {
|
|
81
|
+
return [...ALL_UNSLOTH_ENGINES];
|
|
82
|
+
}
|
|
83
|
+
const hasEngines = cfg.engines !== undefined;
|
|
84
|
+
const hasDisabled = cfg.disabledEngines !== undefined;
|
|
85
|
+
if (hasEngines && hasDisabled) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Unsloth: engines and disabledEngines are mutually exclusive",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (hasEngines) {
|
|
91
|
+
return resolveAllowlist(cfg.engines as unknown[]);
|
|
92
|
+
}
|
|
93
|
+
if (hasDisabled) {
|
|
94
|
+
return resolveBlocklist(cfg.disabledEngines as unknown[]);
|
|
95
|
+
}
|
|
96
|
+
return [...ALL_UNSLOTH_ENGINES];
|
|
97
|
+
}
|
|
98
|
+
|
|
3
99
|
export interface WebsearchConfig {
|
|
4
100
|
/** Which provider to use. */
|
|
5
|
-
provider: "exa-mcp" | "searxng";
|
|
101
|
+
provider: "exa-mcp" | "searxng" | "unsloth";
|
|
6
102
|
/** Exa MCP provider configuration. */
|
|
7
103
|
exaMcp: {
|
|
8
104
|
/** MCP server URL */
|
|
@@ -23,6 +119,19 @@ export interface WebsearchConfig {
|
|
|
23
119
|
*/
|
|
24
120
|
script?: string;
|
|
25
121
|
};
|
|
122
|
+
/** Unsloth provider configuration (direct multi-engine scraper). */
|
|
123
|
+
unsloth?: {
|
|
124
|
+
/** Per-engine fetch timeout in ms (default 10_000). */
|
|
125
|
+
timeoutMs?: number;
|
|
126
|
+
/** Region string, e.g. "us-en" (default "us-en"). */
|
|
127
|
+
region?: string;
|
|
128
|
+
/** SafeSearch level (default "moderate"). */
|
|
129
|
+
safesearch?: "on" | "moderate" | "off";
|
|
130
|
+
/** Allowlist — when set, only these engines run. Mutually exclusive with disabledEngines. */
|
|
131
|
+
engines?: UnslothEngineId[];
|
|
132
|
+
/** Blocklist — these engines are removed. Mutually exclusive with engines. */
|
|
133
|
+
disabledEngines?: UnslothEngineId[];
|
|
134
|
+
};
|
|
26
135
|
/** Request timeout in milliseconds */
|
|
27
136
|
timeoutMs: number;
|
|
28
137
|
/** Default values for search parameters */
|
|
@@ -45,6 +154,7 @@ export const DEFAULT_CONFIG: WebsearchConfig = {
|
|
|
45
154
|
safesearch: 0,
|
|
46
155
|
script: undefined,
|
|
47
156
|
},
|
|
157
|
+
unsloth: undefined,
|
|
48
158
|
timeoutMs: 25_000,
|
|
49
159
|
defaults: {
|
|
50
160
|
numResults: 8,
|
|
@@ -58,6 +168,54 @@ export const DEFAULT_CONFIG: WebsearchConfig = {
|
|
|
58
168
|
* Deep-merge two configs. Arrays and primitives from `override` replace those
|
|
59
169
|
* in `base`. Objects are merged recursively.
|
|
60
170
|
*/
|
|
171
|
+
function isValidProvider(value: string): value is WebsearchConfig["provider"] {
|
|
172
|
+
return value === "exa-mcp" || value === "searxng" || value === "unsloth";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateUnslothRegion(region: unknown): void {
|
|
176
|
+
if (
|
|
177
|
+
typeof region !== "string" ||
|
|
178
|
+
!UNSLOTH_REGION_RE.test(region.toLowerCase())
|
|
179
|
+
) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Unsloth: region must match xx-yy (e.g. us-en), got "${String(region)}"`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateUnslothSafeSearch(value: unknown): void {
|
|
187
|
+
if (value !== "on" && value !== "moderate" && value !== "off") {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Unsloth: safesearch must be "on", "moderate", or "off", got "${String(value)}"`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function mergeUnsloth(
|
|
195
|
+
base: WebsearchConfig["unsloth"],
|
|
196
|
+
overrideUnsloth: Record<string, unknown>,
|
|
197
|
+
): WebsearchConfig["unsloth"] {
|
|
198
|
+
const mergedUnsloth: Record<string, unknown> = {
|
|
199
|
+
...(base ?? {}),
|
|
200
|
+
...overrideUnsloth,
|
|
201
|
+
};
|
|
202
|
+
if ("region" in overrideUnsloth) {
|
|
203
|
+
validateUnslothRegion(overrideUnsloth["region"]);
|
|
204
|
+
}
|
|
205
|
+
if ("safesearch" in overrideUnsloth) {
|
|
206
|
+
validateUnslothSafeSearch(overrideUnsloth["safesearch"]);
|
|
207
|
+
}
|
|
208
|
+
const hasEngines = overrideUnsloth["engines"] !== undefined;
|
|
209
|
+
const hasDisabled = overrideUnsloth["disabledEngines"] !== undefined;
|
|
210
|
+
if (hasEngines && !hasDisabled) {
|
|
211
|
+
delete mergedUnsloth["disabledEngines"];
|
|
212
|
+
}
|
|
213
|
+
if (hasDisabled && !hasEngines) {
|
|
214
|
+
delete mergedUnsloth["engines"];
|
|
215
|
+
}
|
|
216
|
+
return mergedUnsloth as WebsearchConfig["unsloth"];
|
|
217
|
+
}
|
|
218
|
+
|
|
61
219
|
function mergeConfig(
|
|
62
220
|
base: WebsearchConfig,
|
|
63
221
|
override: Record<string, unknown>,
|
|
@@ -66,7 +224,7 @@ function mergeConfig(
|
|
|
66
224
|
|
|
67
225
|
if (
|
|
68
226
|
typeof override.provider === "string" &&
|
|
69
|
-
(override.provider
|
|
227
|
+
isValidProvider(override.provider)
|
|
70
228
|
) {
|
|
71
229
|
merged.provider = override.provider;
|
|
72
230
|
}
|
|
@@ -82,6 +240,12 @@ function mergeConfig(
|
|
|
82
240
|
...(override.searxng as Record<string, unknown>),
|
|
83
241
|
};
|
|
84
242
|
}
|
|
243
|
+
if (override.unsloth && typeof override.unsloth === "object") {
|
|
244
|
+
merged.unsloth = mergeUnsloth(
|
|
245
|
+
base.unsloth,
|
|
246
|
+
override.unsloth as Record<string, unknown>,
|
|
247
|
+
); // needed: narrows unknown to indexable record
|
|
248
|
+
}
|
|
85
249
|
if (override.defaults && typeof override.defaults === "object") {
|
|
86
250
|
merged.defaults = {
|
|
87
251
|
...base.defaults,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared result formatting for all search providers.
|
|
3
|
+
*
|
|
4
|
+
* Providers return structured results; the tool layer appends the trailer so
|
|
5
|
+
* the payload grammar is identical regardless of provider.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FormattableResult {
|
|
9
|
+
title: string;
|
|
10
|
+
href: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Appended by the WebSearch tool to every successful provider result. */
|
|
15
|
+
export const SNIPPET_TRAILER =
|
|
16
|
+
"\n\n---\n\nIMPORTANT: These are only short snippets. " +
|
|
17
|
+
'To get the full page content, call WebFetch with the url parameter (e.g. {"url": "<URL>"}).';
|
|
18
|
+
|
|
19
|
+
export function formatSearchResults(results: FormattableResult[]): string {
|
|
20
|
+
const parts = results.map((result) => {
|
|
21
|
+
const title = result.title.replace(/\s+/g, " ");
|
|
22
|
+
const href = result.href.trim();
|
|
23
|
+
const snippet = result.body.replace(/\s+/g, " ");
|
|
24
|
+
return `Title: ${title}\nURL: ${href}\nSnippet: ${snippet}`;
|
|
25
|
+
});
|
|
26
|
+
return parts.join("\n\n---\n\n");
|
|
27
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { WebsearchConfig } from "../../config";
|
|
2
|
+
import { resolveUnslothEngines } from "../../config";
|
|
2
3
|
import type { SearchProvider } from "../types";
|
|
3
4
|
import { createExaMcpProvider } from "./exa-mcp";
|
|
4
5
|
import { createSearxngProvider } from "./searxng";
|
|
6
|
+
import { createUnslothProvider } from "./unsloth";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Create a search provider based on the current configuration.
|
|
@@ -22,6 +24,16 @@ export function createProvider(config: WebsearchConfig): SearchProvider {
|
|
|
22
24
|
timeoutMs: config.timeoutMs,
|
|
23
25
|
});
|
|
24
26
|
}
|
|
27
|
+
case "unsloth": {
|
|
28
|
+
const engines = resolveUnslothEngines(config.unsloth);
|
|
29
|
+
return createUnslothProvider({
|
|
30
|
+
timeoutMs: config.unsloth?.timeoutMs ?? 10_000,
|
|
31
|
+
overallTimeoutMs: config.timeoutMs,
|
|
32
|
+
region: config.unsloth?.region ?? "us-en",
|
|
33
|
+
safesearch: config.unsloth?.safesearch ?? "moderate",
|
|
34
|
+
engines,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
25
37
|
default: {
|
|
26
38
|
throw new Error(`Unknown provider: ${config.provider}`);
|
|
27
39
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatSearchResults } from "../format";
|
|
1
2
|
import type { SearchArgs, SearchProvider } from "../types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -136,20 +137,22 @@ export function createSearxngProvider(config: SearxngConfig): SearchProvider {
|
|
|
136
137
|
|
|
137
138
|
const data = (await response.json()) as SearxngResponse;
|
|
138
139
|
const results = (data.results ?? [])
|
|
139
|
-
.filter(
|
|
140
|
+
.filter(
|
|
141
|
+
(r): r is typeof r & { url: string } =>
|
|
142
|
+
typeof r.url === "string",
|
|
143
|
+
)
|
|
140
144
|
.slice(0, args.numResults)
|
|
141
|
-
.map((r
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
});
|
|
145
|
+
.map((r) => ({
|
|
146
|
+
title: typeof r.title === "string" ? r.title : "Untitled",
|
|
147
|
+
href: r.url,
|
|
148
|
+
body: typeof r.content === "string" ? r.content : "",
|
|
149
|
+
}));
|
|
147
150
|
|
|
148
151
|
if (results.length === 0) {
|
|
149
152
|
return "";
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
return results
|
|
155
|
+
return formatSearchResults(results);
|
|
153
156
|
},
|
|
154
157
|
controller.signal,
|
|
155
158
|
timeoutMs,
|