@oh-my-pi/pi-coding-agent 17.1.1 → 17.1.3
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 +40 -0
- package/dist/cli.js +6317 -4085
- package/dist/types/cli/bench-cli.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +6 -3
- package/dist/types/config/settings-schema.d.ts +4 -0
- package/dist/types/launch/broker-list-order.test.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -6
- package/dist/types/modes/types.d.ts +8 -5
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/structured-subagent.d.ts +3 -0
- package/dist/types/task/types.d.ts +11 -11
- package/dist/types/thinking.d.ts +13 -0
- package/dist/types/tools/auto-generated-guard.d.ts +5 -2
- package/dist/types/tools/eval-format/index.d.ts +7 -0
- package/dist/types/tools/eval-format/javascript.d.ts +2 -0
- package/dist/types/tools/eval-format/julia.d.ts +2 -0
- package/dist/types/tools/eval-format/python.d.ts +2 -0
- package/dist/types/tools/eval-format/ruby.d.ts +2 -0
- package/dist/types/tools/memory-render.d.ts +1 -4
- package/dist/types/tools/resolve.d.ts +7 -0
- package/dist/types/tools/todo.d.ts +4 -1
- package/dist/types/web/search/providers/base.d.ts +16 -0
- package/dist/types/web/search/providers/brave.d.ts +2 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
- package/dist/types/web/search/providers/gemini.d.ts +3 -0
- package/dist/types/web/search/providers/jina.d.ts +2 -0
- package/dist/types/web/search/providers/kagi.d.ts +2 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/parallel.d.ts +2 -0
- package/dist/types/web/search/providers/perplexity.d.ts +3 -0
- package/dist/types/web/search/providers/searxng.d.ts +10 -0
- package/dist/types/web/search/providers/tavily.d.ts +8 -0
- package/dist/types/web/search/query.d.ts +190 -0
- package/package.json +12 -12
- package/src/cli/bench-cli.ts +12 -1
- package/src/cli/web-search-cli.ts +7 -0
- package/src/config/model-resolver.ts +17 -6
- package/src/config/settings-schema.ts +5 -0
- package/src/debug/raw-sse-buffer.ts +157 -30
- package/src/edit/hashline/filesystem.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/export/share.ts +4 -3
- package/src/launch/broker-list-order.test.ts +89 -0
- package/src/launch/broker.ts +49 -8
- package/src/modes/controllers/input-controller.ts +8 -8
- package/src/modes/interactive-mode.ts +60 -5
- package/src/modes/types.ts +9 -4
- package/src/modes/utils/ui-helpers.ts +7 -8
- package/src/prompts/system/resolve-device-reminder.md +1 -1
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/ast-edit.md +2 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/task.md +5 -2
- package/src/prompts/tools/web-search.md +2 -0
- package/src/sdk.ts +8 -4
- package/src/session/agent-session.ts +14 -0
- package/src/session/queued-messages.ts +7 -1
- package/src/session/stream-guards.ts +1 -1
- package/src/task/executor.ts +17 -9
- package/src/task/index.ts +14 -34
- package/src/task/structured-subagent.ts +4 -0
- package/src/task/types.ts +15 -13
- package/src/thinking.ts +27 -0
- package/src/tools/ast-edit.ts +4 -1
- package/src/tools/auto-generated-guard.ts +18 -5
- package/src/tools/bash.ts +13 -0
- package/src/tools/browser/render.ts +2 -1
- package/src/tools/eval-format/index.ts +24 -0
- package/src/tools/eval-format/javascript.ts +952 -0
- package/src/tools/eval-format/julia.ts +446 -0
- package/src/tools/eval-format/python.ts +544 -0
- package/src/tools/eval-format/ruby.ts +380 -0
- package/src/tools/eval-render.ts +12 -6
- package/src/tools/memory-render.ts +11 -2
- package/src/tools/render-utils.ts +8 -3
- package/src/tools/resolve.ts +10 -1
- package/src/tools/todo.ts +58 -6
- package/src/tools/write.ts +1 -1
- package/src/web/search/index.ts +28 -5
- package/src/web/search/providers/anthropic.ts +62 -4
- package/src/web/search/providers/base.ts +16 -0
- package/src/web/search/providers/brave.ts +30 -3
- package/src/web/search/providers/codex.ts +14 -1
- package/src/web/search/providers/duckduckgo.ts +23 -1
- package/src/web/search/providers/ecosia.ts +5 -1
- package/src/web/search/providers/exa.ts +28 -1
- package/src/web/search/providers/firecrawl.ts +37 -3
- package/src/web/search/providers/gemini.ts +12 -2
- package/src/web/search/providers/google.ts +2 -1
- package/src/web/search/providers/jina.ts +31 -8
- package/src/web/search/providers/kagi.ts +10 -1
- package/src/web/search/providers/kimi.ts +16 -1
- package/src/web/search/providers/mojeek.ts +14 -1
- package/src/web/search/providers/parallel.ts +48 -2
- package/src/web/search/providers/perplexity.ts +94 -6
- package/src/web/search/providers/searxng.ts +145 -9
- package/src/web/search/providers/startpage.ts +8 -2
- package/src/web/search/providers/synthetic.ts +7 -1
- package/src/web/search/providers/tavily.ts +52 -3
- package/src/web/search/providers/tinyfish.ts +6 -1
- package/src/web/search/providers/xai.ts +49 -2
- package/src/web/search/providers/zai.ts +19 -1
- package/src/web/search/query.ts +850 -0
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* searxng.basicUsername - Optional RFC 7617 Basic auth username
|
|
15
15
|
* searxng.basicPassword - Optional RFC 7617 Basic auth password
|
|
16
16
|
* searxng.categories - Optional comma-separated categories filter
|
|
17
|
+
* searxng.engines - Optional comma-separated engine names or shortcuts
|
|
18
|
+
* (e.g. "duckduckgo, br, sp"); shortcuts resolve via
|
|
19
|
+
* the instance's /config endpoint
|
|
17
20
|
* searxng.language - Optional language code (e.g. en, zh-CN)
|
|
18
21
|
*
|
|
19
22
|
* Environment variable fallbacks:
|
|
@@ -22,6 +25,11 @@
|
|
|
22
25
|
* SEARXNG_BASIC_USERNAME - Optional RFC 7617 Basic auth username
|
|
23
26
|
* SEARXNG_BASIC_PASSWORD - Optional RFC 7617 Basic auth password
|
|
24
27
|
*
|
|
28
|
+
* Bang syntax in queries is passed through: `!ddg foo` selects an engine or
|
|
29
|
+
* category server-side and the bang token is stripped from the upstream query.
|
|
30
|
+
* External bangs (`!!g`) are removed client-side because SearXNG answers them
|
|
31
|
+
* with an HTTP redirect even for JSON requests.
|
|
32
|
+
*
|
|
25
33
|
* Reference: https://docs.searxng.org/dev/search_api.html
|
|
26
34
|
*/
|
|
27
35
|
|
|
@@ -30,6 +38,8 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
|
|
|
30
38
|
import { settings } from "../../../config/settings";
|
|
31
39
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
32
40
|
import { SearchProviderError } from "../../../web/search/types";
|
|
41
|
+
import type { StructuredQuery } from "../query";
|
|
42
|
+
import { formatScraperQuery, parseSearchQuery } from "../query";
|
|
33
43
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
34
44
|
import type { SearchParams } from "./base";
|
|
35
45
|
import { SearchProvider } from "./base";
|
|
@@ -73,6 +83,11 @@ interface SearXNGAuth {
|
|
|
73
83
|
value: string;
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
/** Subset of the SearXNG /config payload used for engine shortcut resolution. */
|
|
87
|
+
interface SearXNGConfig {
|
|
88
|
+
engines?: Array<{ name?: string; shortcut?: string }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
/** Find SearXNG endpoint from settings or environment. */
|
|
77
92
|
function findEndpoint(): string | null {
|
|
78
93
|
try {
|
|
@@ -150,6 +165,110 @@ function findAuth(): SearXNGAuth | null {
|
|
|
150
165
|
return token ? { type: "bearer", value: token } : null;
|
|
151
166
|
}
|
|
152
167
|
|
|
168
|
+
/** Find configured engine names/shortcuts from settings. */
|
|
169
|
+
function findEngines(): string | null {
|
|
170
|
+
try {
|
|
171
|
+
const engines = settings.get("searxng.engines");
|
|
172
|
+
if (engines) return engines;
|
|
173
|
+
} catch {
|
|
174
|
+
// Settings not initialized yet
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Build request headers including authentication. */
|
|
180
|
+
function buildHeaders(auth: SearXNGAuth | null): Record<string, string> {
|
|
181
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
182
|
+
if (auth?.type === "basic") {
|
|
183
|
+
headers.Authorization = `Basic ${auth.value}`;
|
|
184
|
+
} else if (auth?.type === "bearer") {
|
|
185
|
+
headers.Authorization = `Bearer ${auth.value}`;
|
|
186
|
+
}
|
|
187
|
+
return headers;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Per-endpoint cache of shortcut/name → canonical engine name maps. */
|
|
191
|
+
const engineNameMapCache = new Map<string, Promise<Map<string, string> | null>>();
|
|
192
|
+
|
|
193
|
+
/** Fetch the instance's /config and build a lookup of lowercased engine names
|
|
194
|
+
* and shortcuts to canonical engine names. Returns null on any failure. */
|
|
195
|
+
async function fetchEngineNameMap(
|
|
196
|
+
base: string,
|
|
197
|
+
auth: SearXNGAuth | null,
|
|
198
|
+
fetchImpl: FetchImpl | undefined,
|
|
199
|
+
signal: AbortSignal | undefined,
|
|
200
|
+
): Promise<Map<string, string> | null> {
|
|
201
|
+
try {
|
|
202
|
+
const response = await (fetchImpl ?? fetch)(`${base}/config`, {
|
|
203
|
+
headers: buildHeaders(auth),
|
|
204
|
+
signal: withHardTimeout(signal),
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok) return null;
|
|
207
|
+
const config = (await response.json()) as SearXNGConfig;
|
|
208
|
+
const map = new Map<string, string>();
|
|
209
|
+
for (const engine of config.engines ?? []) {
|
|
210
|
+
if (!engine.name) continue;
|
|
211
|
+
map.set(engine.name.toLowerCase(), engine.name);
|
|
212
|
+
if (engine.shortcut) map.set(engine.shortcut.toLowerCase(), engine.name);
|
|
213
|
+
}
|
|
214
|
+
return map.size ? map : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Get the engine name map for an endpoint, cached for the process lifetime.
|
|
221
|
+
* Failures are not cached so a transient error retries on the next search. */
|
|
222
|
+
function getEngineNameMap(
|
|
223
|
+
endpoint: string,
|
|
224
|
+
auth: SearXNGAuth | null,
|
|
225
|
+
fetchImpl: FetchImpl | undefined,
|
|
226
|
+
signal: AbortSignal | undefined,
|
|
227
|
+
): Promise<Map<string, string> | null> {
|
|
228
|
+
const base = endpoint.replace(/\/+$/, "");
|
|
229
|
+
let cached = engineNameMapCache.get(base);
|
|
230
|
+
if (!cached) {
|
|
231
|
+
cached = fetchEngineNameMap(base, auth, fetchImpl, signal).then(map => {
|
|
232
|
+
if (!map) engineNameMapCache.delete(base);
|
|
233
|
+
return map;
|
|
234
|
+
});
|
|
235
|
+
engineNameMapCache.set(base, cached);
|
|
236
|
+
}
|
|
237
|
+
return cached;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Resolve configured engine entries (canonical names or shortcuts like `ddg`)
|
|
241
|
+
* to canonical names for SearXNG's `engines=` parameter, which accepts names
|
|
242
|
+
* only — shortcuts resolve exclusively through bang syntax. Unknown entries
|
|
243
|
+
* pass through verbatim; the server drops them and falls back to categories. */
|
|
244
|
+
async function resolveEngineNames(
|
|
245
|
+
raw: string,
|
|
246
|
+
endpoint: string,
|
|
247
|
+
auth: SearXNGAuth | null,
|
|
248
|
+
fetchImpl: FetchImpl | undefined,
|
|
249
|
+
signal: AbortSignal | undefined,
|
|
250
|
+
): Promise<string | undefined> {
|
|
251
|
+
const entries = raw
|
|
252
|
+
.split(",")
|
|
253
|
+
.map(entry => entry.trim())
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
if (!entries.length) return undefined;
|
|
256
|
+
const map = await getEngineNameMap(endpoint, auth, fetchImpl, signal);
|
|
257
|
+
if (!map) return entries.join(",");
|
|
258
|
+
return entries.map(entry => map.get(entry.toLowerCase()) ?? entry).join(",");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Strip external bang tokens (`!!g`, bare `!!`): SearXNG answers them with an
|
|
262
|
+
* HTTP redirect even for JSON requests, which breaks response parsing.
|
|
263
|
+
* Single-bang engine/category selectors (`!ddg`, `!images`) are kept — the
|
|
264
|
+
* instance resolves and removes them server-side. */
|
|
265
|
+
function stripExternalBangs(query: string): string {
|
|
266
|
+
return query
|
|
267
|
+
.split(/\s+/)
|
|
268
|
+
.filter(part => !part.startsWith("!!"))
|
|
269
|
+
.join(" ");
|
|
270
|
+
}
|
|
271
|
+
|
|
153
272
|
/** Build the search URL and headers for a SearXNG request */
|
|
154
273
|
function buildRequest(
|
|
155
274
|
endpoint: string,
|
|
@@ -158,6 +277,7 @@ function buildRequest(
|
|
|
158
277
|
num_results?: number;
|
|
159
278
|
recency?: "day" | "week" | "month" | "year";
|
|
160
279
|
categories?: string;
|
|
280
|
+
engines?: string;
|
|
161
281
|
language?: string;
|
|
162
282
|
signal?: AbortSignal;
|
|
163
283
|
},
|
|
@@ -181,19 +301,15 @@ function buildRequest(
|
|
|
181
301
|
url.searchParams.set("categories", params.categories);
|
|
182
302
|
}
|
|
183
303
|
|
|
304
|
+
if (params.engines) {
|
|
305
|
+
url.searchParams.set("engines", params.engines);
|
|
306
|
+
}
|
|
307
|
+
|
|
184
308
|
if (params.language) {
|
|
185
309
|
url.searchParams.set("language", params.language);
|
|
186
310
|
}
|
|
187
311
|
|
|
188
|
-
const headers
|
|
189
|
-
Accept: "application/json",
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
if (auth?.type === "basic") {
|
|
193
|
-
headers.Authorization = `Basic ${auth.value}`;
|
|
194
|
-
} else if (auth?.type === "bearer") {
|
|
195
|
-
headers.Authorization = `Bearer ${auth.value}`;
|
|
196
|
-
}
|
|
312
|
+
const headers = buildHeaders(auth);
|
|
197
313
|
|
|
198
314
|
return { url, headers };
|
|
199
315
|
}
|
|
@@ -205,6 +321,7 @@ async function callSearXNGSearch(
|
|
|
205
321
|
num_results?: number;
|
|
206
322
|
recency?: "day" | "week" | "month" | "year";
|
|
207
323
|
categories?: string;
|
|
324
|
+
engines?: string;
|
|
208
325
|
language?: string;
|
|
209
326
|
signal?: AbortSignal;
|
|
210
327
|
fetch?: FetchImpl;
|
|
@@ -231,6 +348,7 @@ async function callSearXNGSearch(
|
|
|
231
348
|
/** Execute SearXNG web search. */
|
|
232
349
|
export async function searchSearXNG(params: {
|
|
233
350
|
query: string;
|
|
351
|
+
parsedQuery?: StructuredQuery;
|
|
234
352
|
num_results?: number;
|
|
235
353
|
recency?: "day" | "week" | "month" | "year";
|
|
236
354
|
signal?: AbortSignal;
|
|
@@ -255,12 +373,29 @@ export async function searchSearXNG(params: {
|
|
|
255
373
|
} catch {
|
|
256
374
|
// Settings not initialized yet
|
|
257
375
|
}
|
|
376
|
+
const configuredEngines = findEngines();
|
|
377
|
+
|
|
378
|
+
// SearXNG forwards `q` to downstream engines, so build it with the shared
|
|
379
|
+
// scraper formatter: operators are canonicalized and scraper-hostile ones
|
|
380
|
+
// (path-carrying `site:`, `inurl:`) are structurally demoted to plain
|
|
381
|
+
// terms before formatting, so paren-grouped `site:` filters are covered
|
|
382
|
+
// too. `lang:` maps onto the native `language` param (overriding the
|
|
383
|
+
// configured default).
|
|
384
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
385
|
+
const query = formatScraperQuery(params.query, parsed);
|
|
386
|
+
if (parsed.lang) language = parsed.lang;
|
|
387
|
+
|
|
388
|
+
const engines = configuredEngines
|
|
389
|
+
? await resolveEngineNames(configuredEngines, endpoint, auth, params.fetch, params.signal)
|
|
390
|
+
: undefined;
|
|
258
391
|
|
|
259
392
|
const response = await callSearXNGSearch(
|
|
260
393
|
endpoint,
|
|
261
394
|
{
|
|
262
395
|
...params,
|
|
396
|
+
query: stripExternalBangs(query),
|
|
263
397
|
categories,
|
|
398
|
+
engines,
|
|
264
399
|
language,
|
|
265
400
|
fetch: params.fetch,
|
|
266
401
|
},
|
|
@@ -315,6 +450,7 @@ export class SearXNGProvider extends SearchProvider {
|
|
|
315
450
|
|
|
316
451
|
search(params: SearchParams): Promise<SearchResponse> {
|
|
317
452
|
return searchSearXNG({
|
|
453
|
+
parsedQuery: params.parsedQuery,
|
|
318
454
|
query: params.query,
|
|
319
455
|
num_results: params.numSearchResults ?? params.limit,
|
|
320
456
|
recency: params.recency,
|
|
@@ -2,6 +2,7 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
|
|
|
2
2
|
import { parseHTML } from "linkedom";
|
|
3
3
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
4
4
|
import { SearchProviderError } from "../../../web/search/types";
|
|
5
|
+
import { formatScraperQuery } from "../query";
|
|
5
6
|
import { clampNumResults } from "../utils";
|
|
6
7
|
import type { SearchParams } from "./base";
|
|
7
8
|
import { SearchProvider } from "./base";
|
|
@@ -136,12 +137,17 @@ async function callStartpageHtml(params: SearchParams): Promise<string> {
|
|
|
136
137
|
const fetchImpl = params.fetch ?? fetch;
|
|
137
138
|
const signal = withHardTimeout(params.signal);
|
|
138
139
|
const withDate = params.recency ? RECENCY_TO_STARTPAGE_WITH_DATE[params.recency] : undefined;
|
|
140
|
+
// Startpage proxies Google, so the operator set works inline; rebuild via
|
|
141
|
+
// the shared scraper formatter to canonicalize aliases (domain: → site:,
|
|
142
|
+
// since: → after:, …) and demote scraper-hostile operators. Directive-free
|
|
143
|
+
// queries pass through byte-identical.
|
|
144
|
+
const query = formatScraperQuery(params.query, params.parsedQuery);
|
|
139
145
|
|
|
140
146
|
const formInputs = await fetchFormInputs(fetchImpl, signal);
|
|
141
147
|
let page: LoadedHtmlPage;
|
|
142
148
|
if (formInputs) {
|
|
143
149
|
const form = new URLSearchParams(formInputs);
|
|
144
|
-
form.set("query",
|
|
150
|
+
form.set("query", query);
|
|
145
151
|
if (withDate) form.set("with_date", withDate);
|
|
146
152
|
page = await browserFetch(STARTPAGE_SEARCH_URL, {
|
|
147
153
|
fetch: fetchImpl,
|
|
@@ -152,7 +158,7 @@ async function callStartpageHtml(params: SearchParams): Promise<string> {
|
|
|
152
158
|
});
|
|
153
159
|
} else {
|
|
154
160
|
const url = new URL(STARTPAGE_SEARCH_URL);
|
|
155
|
-
url.searchParams.set("query",
|
|
161
|
+
url.searchParams.set("query", query);
|
|
156
162
|
if (withDate) url.searchParams.set("with_date", withDate);
|
|
157
163
|
page = await browserFetch(url.href, {
|
|
158
164
|
fetch: fetchImpl,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
|
9
9
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
10
10
|
import { SearchProviderError } from "../../../web/search/types";
|
|
11
|
+
import { formatQuery, parseSearchQuery } from "../query";
|
|
11
12
|
import type { SearchParams } from "./base";
|
|
12
13
|
import { SearchProvider } from "./base";
|
|
13
14
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
@@ -73,8 +74,13 @@ export async function searchSynthetic(params: SearchParamsWithFetch): Promise<Se
|
|
|
73
74
|
sessionId: params.sessionId,
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
78
|
+
const query = parsed.hasDirectives
|
|
79
|
+
? formatQuery(parsed, { phrases: true, negation: true, site: true })
|
|
80
|
+
: params.query;
|
|
81
|
+
|
|
76
82
|
const fetchImpl = params.fetch;
|
|
77
|
-
const data = await withAuth(keyOrResolver, key => callSyntheticSearch(key,
|
|
83
|
+
const data = await withAuth(keyOrResolver, key => callSyntheticSearch(key, query, params.signal, fetchImpl), {
|
|
78
84
|
signal: params.signal,
|
|
79
85
|
missingKeyMessage: "Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.",
|
|
80
86
|
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
9
9
|
import { SearchProviderError } from "../../../web/search/types";
|
|
10
|
+
import { formatQuery, parseSearchQuery } from "../query";
|
|
10
11
|
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
11
12
|
import type { SearchParams } from "./base";
|
|
12
13
|
import { SearchProvider } from "./base";
|
|
@@ -20,6 +21,14 @@ export interface TavilySearchParams {
|
|
|
20
21
|
query: string;
|
|
21
22
|
num_results?: number;
|
|
22
23
|
recency?: "day" | "week" | "month" | "year";
|
|
24
|
+
/** `site:` hosts mapped to Tavily's `include_domains`. */
|
|
25
|
+
include_domains?: string[];
|
|
26
|
+
/** `-site:` hosts mapped to Tavily's `exclude_domains`. */
|
|
27
|
+
exclude_domains?: string[];
|
|
28
|
+
/** `after:` inclusive lower bound, ISO `YYYY-MM-DD`, mapped to `start_date`. */
|
|
29
|
+
start_date?: string;
|
|
30
|
+
/** `before:` upper bound, ISO `YYYY-MM-DD`, mapped to `end_date`. */
|
|
31
|
+
end_date?: string;
|
|
23
32
|
signal?: AbortSignal;
|
|
24
33
|
fetch?: FetchImpl;
|
|
25
34
|
}
|
|
@@ -83,7 +92,21 @@ export function buildRequestBody(params: TavilySearchParams): Record<string, unk
|
|
|
83
92
|
include_answer: "advanced",
|
|
84
93
|
include_raw_content: false,
|
|
85
94
|
};
|
|
86
|
-
if (params.
|
|
95
|
+
if (params.include_domains?.length) {
|
|
96
|
+
body.include_domains = params.include_domains;
|
|
97
|
+
}
|
|
98
|
+
if (params.exclude_domains?.length) {
|
|
99
|
+
body.exclude_domains = params.exclude_domains;
|
|
100
|
+
}
|
|
101
|
+
if (params.start_date) {
|
|
102
|
+
body.start_date = params.start_date;
|
|
103
|
+
}
|
|
104
|
+
if (params.end_date) {
|
|
105
|
+
body.end_date = params.end_date;
|
|
106
|
+
}
|
|
107
|
+
// Explicit before:/after: bounds take precedence over the relative recency
|
|
108
|
+
// window; sending both would over-restrict.
|
|
109
|
+
if (params.recency && !params.start_date && !params.end_date) {
|
|
87
110
|
body.time_range = params.recency;
|
|
88
111
|
}
|
|
89
112
|
return body;
|
|
@@ -148,8 +171,19 @@ function hasRenderableResponse(response: SearchResponse): boolean {
|
|
|
148
171
|
return response.sources.length > 0;
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
/** Bare hosts from `site:` values (path parts are enforced by the central lenient filter). */
|
|
175
|
+
function siteHosts(sites: readonly string[]): string[] {
|
|
176
|
+
const hosts = new Set<string>();
|
|
177
|
+
for (const site of sites) {
|
|
178
|
+
const host = site.split("/", 1)[0];
|
|
179
|
+
if (host) hosts.add(host);
|
|
180
|
+
}
|
|
181
|
+
return [...hosts];
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/** Execute Tavily web search. */
|
|
152
185
|
export async function searchTavily(params: SearchParams): Promise<SearchResponse> {
|
|
186
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
153
187
|
const tavilyParams: TavilySearchParams = {
|
|
154
188
|
query: params.query,
|
|
155
189
|
num_results: params.numSearchResults ?? params.limit,
|
|
@@ -157,6 +191,16 @@ export async function searchTavily(params: SearchParams): Promise<SearchResponse
|
|
|
157
191
|
signal: params.signal,
|
|
158
192
|
fetch: params.fetch,
|
|
159
193
|
};
|
|
194
|
+
if (parsed.hasDirectives) {
|
|
195
|
+
// Tavily prefers clean natural text; re-emit only phrases and -exclusions.
|
|
196
|
+
tavilyParams.query = formatQuery(parsed, { phrases: true, negation: true });
|
|
197
|
+
const include = siteHosts(parsed.sites);
|
|
198
|
+
const exclude = siteHosts(parsed.excludedSites);
|
|
199
|
+
if (include.length > 0) tavilyParams.include_domains = include;
|
|
200
|
+
if (exclude.length > 0) tavilyParams.exclude_domains = exclude;
|
|
201
|
+
if (parsed.after) tavilyParams.start_date = parsed.after;
|
|
202
|
+
if (parsed.before) tavilyParams.end_date = parsed.before;
|
|
203
|
+
}
|
|
160
204
|
const keyOrResolver: ApiKey = params.authStorage.resolver("tavily", {
|
|
161
205
|
sessionId: params.sessionId,
|
|
162
206
|
});
|
|
@@ -171,11 +215,16 @@ export async function searchTavily(params: SearchParams): Promise<SearchResponse
|
|
|
171
215
|
withAuth(keyOrResolver, key => callTavilySearch(key, searchParams), authOptions);
|
|
172
216
|
|
|
173
217
|
const response = toSearchResponse(await callWithAuth(tavilyParams), numResults);
|
|
174
|
-
|
|
218
|
+
const hasTimeFilter = Boolean(tavilyParams.recency || tavilyParams.start_date || tavilyParams.end_date);
|
|
219
|
+
if (!hasTimeFilter || hasRenderableResponse(response)) {
|
|
175
220
|
return response;
|
|
176
221
|
}
|
|
177
222
|
|
|
178
|
-
|
|
223
|
+
// Time filters commonly zero out results; retry once without them.
|
|
224
|
+
return toSearchResponse(
|
|
225
|
+
await callWithAuth({ ...tavilyParams, recency: undefined, start_date: undefined, end_date: undefined }),
|
|
226
|
+
numResults,
|
|
227
|
+
);
|
|
179
228
|
}
|
|
180
229
|
|
|
181
230
|
/** Search provider for Tavily web search. */
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
9
9
|
import { SearchProviderError } from "../../../web/search/types";
|
|
10
|
+
import { formatQuery, parseSearchQuery, type QuerySyntax } from "../query";
|
|
10
11
|
import { clampNumResults } from "../utils";
|
|
11
12
|
import type { SearchParams } from "./base";
|
|
12
13
|
import { SearchProvider } from "./base";
|
|
@@ -17,6 +18,9 @@ const DEFAULT_NUM_RESULTS = 10;
|
|
|
17
18
|
const MAX_NUM_RESULTS = 20;
|
|
18
19
|
const MAX_PAGE = 10;
|
|
19
20
|
|
|
21
|
+
/** TinyFish is SERP-backed: common Google-style operators pass through. */
|
|
22
|
+
const TINYFISH_QUERY_SYNTAX: QuerySyntax = { phrases: true, negation: true, site: true, filetype: true };
|
|
23
|
+
|
|
20
24
|
const RECENCY_MINUTES: Record<NonNullable<SearchParams["recency"]>, number> = {
|
|
21
25
|
day: 1440,
|
|
22
26
|
week: 10080,
|
|
@@ -107,8 +111,9 @@ function appendTinyFishSources(sources: SearchSource[], results: readonly TinyFi
|
|
|
107
111
|
export async function searchTinyFish(params: SearchParams): Promise<SearchResponse> {
|
|
108
112
|
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
109
113
|
const pageSize = Math.min(numResults, DEFAULT_NUM_RESULTS);
|
|
114
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
110
115
|
const tinyFishParams: TinyFishSearchParams = {
|
|
111
|
-
query: params.query,
|
|
116
|
+
query: parsed.hasDirectives ? formatQuery(parsed, TINYFISH_QUERY_SYNTAX) : params.query,
|
|
112
117
|
num_results: pageSize,
|
|
113
118
|
recency: params.recency,
|
|
114
119
|
signal: params.signal,
|
|
@@ -3,6 +3,7 @@ import { $env } from "@oh-my-pi/pi-utils";
|
|
|
3
3
|
import { resolveXAIHttpTransport, type XAIHttpProvider, type XAIHttpTransport } from "../../../lib/xai-http";
|
|
4
4
|
import type { SearchCitation, SearchResponse, SearchSource, SearchUsage } from "../../../web/search/types";
|
|
5
5
|
import { SearchProviderError } from "../../../web/search/types";
|
|
6
|
+
import { formatQuery, parseSearchQuery, type QuerySyntax } from "../query";
|
|
6
7
|
import { clampNumResults } from "../utils";
|
|
7
8
|
import type { SearchParams } from "./base";
|
|
8
9
|
import { SearchProvider } from "./base";
|
|
@@ -57,14 +58,60 @@ interface XAIResponsesResponse {
|
|
|
57
58
|
usage?: XAIResponsesUsage | null;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Query syntax re-emitted for the Grok search agent. `site:`/`-site:` are
|
|
63
|
+
* stripped because hosts map natively onto the web_search domain filters;
|
|
64
|
+
* `before:`/`after:` stay in the query text — the Responses web_search tool
|
|
65
|
+
* has no date parameters (`from_date`/`to_date` exist only on `x_search` and
|
|
66
|
+
* the deprecated Live Search `search_parameters`, which now returns 410) and
|
|
67
|
+
* the agent honors the tokens as natural-language hints.
|
|
68
|
+
*/
|
|
69
|
+
const XAI_QUERY_SYNTAX: QuerySyntax = {
|
|
70
|
+
phrases: true,
|
|
71
|
+
negation: true,
|
|
72
|
+
or: true,
|
|
73
|
+
inUrl: true,
|
|
74
|
+
inTitle: true,
|
|
75
|
+
filetype: true,
|
|
76
|
+
dateRange: true,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** xAI web_search accepts at most 5 allowed or excluded domains per request. */
|
|
80
|
+
const MAX_DOMAIN_FILTERS = 5;
|
|
81
|
+
|
|
82
|
+
/** Bare hosts of `site:` values (`github.com/anthropics` → `github.com`), deduped, capped at 5; path parts are enforced by the central constraint filter. */
|
|
83
|
+
function domainFilterList(sites: readonly string[]): string[] {
|
|
84
|
+
const hosts = new Set<string>();
|
|
85
|
+
for (const site of sites) {
|
|
86
|
+
const slash = site.indexOf("/");
|
|
87
|
+
hosts.add(slash === -1 ? site : site.slice(0, slash));
|
|
88
|
+
if (hosts.size === MAX_DOMAIN_FILTERS) break;
|
|
89
|
+
}
|
|
90
|
+
return [...hosts];
|
|
91
|
+
}
|
|
92
|
+
|
|
60
93
|
function buildRequestBody(params: SearchParams): Record<string, unknown> {
|
|
94
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
95
|
+
const webSearchTool: Record<string, unknown> = { type: "web_search" };
|
|
96
|
+
let query = params.query;
|
|
97
|
+
if (parsed.hasDirectives) {
|
|
98
|
+
query = formatQuery(parsed, XAI_QUERY_SYNTAX);
|
|
99
|
+
// allowed_domains and excluded_domains are mutually exclusive per
|
|
100
|
+
// request; prefer the allow list, the central filter enforces exclusions.
|
|
101
|
+
if (parsed.sites.length > 0) {
|
|
102
|
+
webSearchTool.filters = { allowed_domains: domainFilterList(parsed.sites) };
|
|
103
|
+
} else if (parsed.excludedSites.length > 0) {
|
|
104
|
+
webSearchTool.filters = { excluded_domains: domainFilterList(parsed.excludedSites) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
const body: Record<string, unknown> = {
|
|
62
109
|
model: XAI_WEB_SEARCH_MODEL,
|
|
63
110
|
input: [
|
|
64
111
|
{ role: "system", content: params.systemPrompt },
|
|
65
|
-
{ role: "user", content:
|
|
112
|
+
{ role: "user", content: query },
|
|
66
113
|
],
|
|
67
|
-
tools: [
|
|
114
|
+
tools: [webSearchTool],
|
|
68
115
|
reasoning: { effort: XAI_WEB_SEARCH_REASONING_EFFORT },
|
|
69
116
|
};
|
|
70
117
|
|
|
@@ -8,6 +8,7 @@ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth }
|
|
|
8
8
|
import { isRecord } from "@oh-my-pi/pi-utils";
|
|
9
9
|
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
10
10
|
import { SearchProviderError } from "../../../web/search/types";
|
|
11
|
+
import { formatQuery, parseSearchQuery, type QuerySyntax } from "../query";
|
|
11
12
|
import { dateToAgeSeconds } from "../utils";
|
|
12
13
|
import type { SearchParams } from "./base";
|
|
13
14
|
import { SearchProvider } from "./base";
|
|
@@ -17,6 +18,22 @@ const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
|
|
|
17
18
|
const ZAI_TOOL_NAME = "web_search_prime";
|
|
18
19
|
const DEFAULT_NUM_RESULTS = 10;
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* webSearchPrime exposes no native filter args (the Web Search API's
|
|
23
|
+
* `search_domain_filter`/`search_recency_filter` are scoped to the
|
|
24
|
+
* `search_pro_jina` engine, not `search-prime`), but its Bing-flavored
|
|
25
|
+
* backend parses the common inline operators. Dates and language are left
|
|
26
|
+
* to the central lenient post-filter.
|
|
27
|
+
*/
|
|
28
|
+
const ZAI_QUERY_SYNTAX: QuerySyntax = {
|
|
29
|
+
phrases: true,
|
|
30
|
+
negation: true,
|
|
31
|
+
site: true,
|
|
32
|
+
inTitle: true,
|
|
33
|
+
inUrl: true,
|
|
34
|
+
filetype: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
20
37
|
export interface ZaiSearchParams {
|
|
21
38
|
query: string;
|
|
22
39
|
num_results?: number;
|
|
@@ -407,8 +424,9 @@ export class ZaiProvider extends SearchProvider {
|
|
|
407
424
|
|
|
408
425
|
search(params: SearchParams): Promise<SearchResponse> {
|
|
409
426
|
const { fetch: fetchOverride } = params as ZaiProviderSearchParams;
|
|
427
|
+
const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
|
|
410
428
|
return searchZai({
|
|
411
|
-
query: params.query,
|
|
429
|
+
query: parsed.hasDirectives ? formatQuery(parsed, ZAI_QUERY_SYNTAX) : params.query,
|
|
412
430
|
num_results: params.numSearchResults ?? params.limit,
|
|
413
431
|
signal: params.signal,
|
|
414
432
|
authStorage: params.authStorage,
|