@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli.js +6317 -4085
  3. package/dist/types/cli/bench-cli.d.ts +1 -0
  4. package/dist/types/config/model-resolver.d.ts +6 -3
  5. package/dist/types/config/settings-schema.d.ts +4 -0
  6. package/dist/types/launch/broker-list-order.test.d.ts +1 -0
  7. package/dist/types/modes/interactive-mode.d.ts +2 -6
  8. package/dist/types/modes/types.d.ts +8 -5
  9. package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
  10. package/dist/types/task/executor.d.ts +3 -1
  11. package/dist/types/task/structured-subagent.d.ts +3 -0
  12. package/dist/types/task/types.d.ts +11 -11
  13. package/dist/types/thinking.d.ts +13 -0
  14. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  15. package/dist/types/tools/eval-format/index.d.ts +7 -0
  16. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  17. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  18. package/dist/types/tools/eval-format/python.d.ts +2 -0
  19. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  20. package/dist/types/tools/memory-render.d.ts +1 -4
  21. package/dist/types/tools/resolve.d.ts +7 -0
  22. package/dist/types/tools/todo.d.ts +4 -1
  23. package/dist/types/web/search/providers/base.d.ts +16 -0
  24. package/dist/types/web/search/providers/brave.d.ts +2 -0
  25. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  26. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  27. package/dist/types/web/search/providers/jina.d.ts +2 -0
  28. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  29. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  30. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  31. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  32. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  33. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  34. package/dist/types/web/search/query.d.ts +190 -0
  35. package/package.json +12 -12
  36. package/src/cli/bench-cli.ts +12 -1
  37. package/src/cli/web-search-cli.ts +7 -0
  38. package/src/config/model-resolver.ts +17 -6
  39. package/src/config/settings-schema.ts +5 -0
  40. package/src/debug/raw-sse-buffer.ts +157 -30
  41. package/src/edit/hashline/filesystem.ts +1 -1
  42. package/src/edit/modes/patch.ts +1 -1
  43. package/src/export/share.ts +4 -3
  44. package/src/launch/broker-list-order.test.ts +89 -0
  45. package/src/launch/broker.ts +49 -8
  46. package/src/modes/controllers/input-controller.ts +8 -8
  47. package/src/modes/interactive-mode.ts +60 -5
  48. package/src/modes/types.ts +9 -4
  49. package/src/modes/utils/ui-helpers.ts +7 -8
  50. package/src/prompts/system/resolve-device-reminder.md +1 -1
  51. package/src/prompts/system/workflow-notice.md +1 -1
  52. package/src/prompts/tools/ast-edit.md +2 -1
  53. package/src/prompts/tools/bash.md +1 -0
  54. package/src/prompts/tools/eval.md +2 -2
  55. package/src/prompts/tools/task.md +5 -2
  56. package/src/prompts/tools/web-search.md +2 -0
  57. package/src/sdk.ts +8 -4
  58. package/src/session/agent-session.ts +14 -0
  59. package/src/session/queued-messages.ts +7 -1
  60. package/src/session/stream-guards.ts +1 -1
  61. package/src/task/executor.ts +17 -9
  62. package/src/task/index.ts +14 -34
  63. package/src/task/structured-subagent.ts +4 -0
  64. package/src/task/types.ts +15 -13
  65. package/src/thinking.ts +27 -0
  66. package/src/tools/ast-edit.ts +4 -1
  67. package/src/tools/auto-generated-guard.ts +18 -5
  68. package/src/tools/bash.ts +13 -0
  69. package/src/tools/browser/render.ts +2 -1
  70. package/src/tools/eval-format/index.ts +24 -0
  71. package/src/tools/eval-format/javascript.ts +952 -0
  72. package/src/tools/eval-format/julia.ts +446 -0
  73. package/src/tools/eval-format/python.ts +544 -0
  74. package/src/tools/eval-format/ruby.ts +380 -0
  75. package/src/tools/eval-render.ts +12 -6
  76. package/src/tools/memory-render.ts +11 -2
  77. package/src/tools/render-utils.ts +8 -3
  78. package/src/tools/resolve.ts +10 -1
  79. package/src/tools/todo.ts +58 -6
  80. package/src/tools/write.ts +1 -1
  81. package/src/web/search/index.ts +28 -5
  82. package/src/web/search/providers/anthropic.ts +62 -4
  83. package/src/web/search/providers/base.ts +16 -0
  84. package/src/web/search/providers/brave.ts +30 -3
  85. package/src/web/search/providers/codex.ts +14 -1
  86. package/src/web/search/providers/duckduckgo.ts +23 -1
  87. package/src/web/search/providers/ecosia.ts +5 -1
  88. package/src/web/search/providers/exa.ts +28 -1
  89. package/src/web/search/providers/firecrawl.ts +37 -3
  90. package/src/web/search/providers/gemini.ts +12 -2
  91. package/src/web/search/providers/google.ts +2 -1
  92. package/src/web/search/providers/jina.ts +31 -8
  93. package/src/web/search/providers/kagi.ts +10 -1
  94. package/src/web/search/providers/kimi.ts +16 -1
  95. package/src/web/search/providers/mojeek.ts +14 -1
  96. package/src/web/search/providers/parallel.ts +48 -2
  97. package/src/web/search/providers/perplexity.ts +94 -6
  98. package/src/web/search/providers/searxng.ts +145 -9
  99. package/src/web/search/providers/startpage.ts +8 -2
  100. package/src/web/search/providers/synthetic.ts +7 -1
  101. package/src/web/search/providers/tavily.ts +52 -3
  102. package/src/web/search/providers/tinyfish.ts +6 -1
  103. package/src/web/search/providers/xai.ts +49 -2
  104. package/src/web/search/providers/zai.ts +19 -1
  105. 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: Record<string, string> = {
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", params.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", params.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, params.query, params.signal, fetchImpl), {
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.recency) {
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
- if (!tavilyParams.recency || hasRenderableResponse(response)) {
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
- return toSearchResponse(await callWithAuth({ ...tavilyParams, recency: undefined }), numResults);
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: params.query },
112
+ { role: "user", content: query },
66
113
  ],
67
- tools: [{ type: "web_search" }],
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,