diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 20eeacc3c..ee7fad936 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -7,15 +7,20 @@
- Added in-process moreutils-style shell builtins to the bash tool's embedded shell: `ts` (timestamp lines; `-i`/`-s` elapsed modes, `-m` monotonic clock, `-r` relative rewriting of RFC3339/syslog timestamps, `%.S`/`%.s`/`%.T` subsecond extensions), `sponge` (soak stdin fully before atomically writing the target, so `foo file | ... | sponge file` works; `-a` appends), `ifne` (run a command only when stdin is non-empty; `-n` inverts and passes non-empty stdin through), `isutf8` (streaming UTF-8 validation with line/char/byte diagnostics; `-q`, `-l`, `-i`), `combine` (boolean `and`/`not`/`or`/`xor` on the lines of two files, `-` for stdin), and `errno` (errno name/number/description lookup with `-l` list and `-s` search; unix only). Like the uutils-backed builtins, they run in-process against the command's own stdio, resolve paths against the shell working directory, honor cancellation, and are disabled by `PI_DISABLE_UUTILS_BUILTINS`.
- The bash tool prompt now lists the available shell builtins (`mkdir` through `jq`, `rm`/`mv`/`ln`, and the moreutils set) so the model relies on them without existence checks; the line is dropped when `PI_DISABLE_UUTILS_BUILTINS` disables the builtins and omits unix-only `errno` on Windows.
- Sessions using a broker-backed auth store now report each completed request's token usage and cost to the auth broker (batched, 10s cadence) so the broker can track actual token burn per client install
+- Added a per-spawn `effort` parameter to the `task` tool (`"lo"` | `"med"` | `"hi"`): each selector maps onto the resolved model's supported thinking range (lowest, middle, and highest level — whatever the model tops out at, e.g. `high`, `xhigh`, or `max`) and overrides the agent's default selector, including `auto`. Omitting `effort` keeps the existing automatic per-prompt thinking classification.
+- Added `searxng.engines` setting for the SearXNG web search provider: a comma-separated list of engine names or bang shortcuts (e.g. `ddg, br, startpage`) sent as the API's `engines=` parameter. Shortcuts are resolved to canonical engine names via the instance's `/config` endpoint (cached per endpoint; entries pass through verbatim if `/config` is unreachable). Bang syntax in queries (`!ddg foo`) continues to pass through to the instance, and external bangs (`!!g`) are now stripped client-side since SearXNG answers them with an HTTP redirect even for JSON requests.
+- Web search queries now understand Google-style directives on every provider: `site:`/`-site:` (plus `domain:`/`host:` aliases), `after:`/`before:`/`since:`/`until:` date bounds, `inurl:`/`intitle:`/`intext:`/`allin*:`, `filetype:`/`ext:`, `lang:`, quoted phrases (including smart quotes), `+term`, `-`/`NOT` exclusions, and `OR`/`|` groups. A shared parser (`web/search/query.ts`) structures the query once per request; each provider maps constraints onto native API filters where the upstream supports them (Perplexity domain/date/language filters on both the API-key and ask paths, Tavily `include_domains`/`exclude_domains` + `start_date`/`end_date`, Exa domain lists + published-date bounds on API and MCP paths, Anthropic `allowed_domains`/`blocked_domains`, xAI `filters.allowed_domains`/`excluded_domains`, Parallel `source_policy`, Brave absolute `freshness` ranges, Firecrawl `tbs=cdr` date ranges, Jina `X-Site`, SearXNG `language`) and otherwise re-emits only the operator syntax its engine parses (full Google syntax for Gemini grounding, OpenAI, Kagi, and the credential-free scrapers — with scraper-hostile path-`site:`/`inurl:` operators demoted to plain keywords; conservative subsets for DuckDuckGo, Mojeek, Kimi, Z.AI, TinyFish, and Synthetic). The pipeline then applies a lenient post-filter to every response: constraints the engine ignored are enforced on the returned sources, and any constraint dimension that would eliminate every result is relaxed and reported to the model (`Note: no results matched \`site:...\`; the constraint was relaxed`) instead of returning nothing. Directive-free queries are passed through byte-identical everywhere.
### Changed
- Large pastes saved via the large-paste menu now insert `local://paste-N.md` references (previously `local://attachment-N`), so the saved paste carries a markdown extension and a clearer name.
- Raw SSE debug capture now trims over-budget events smartly instead of chopping off the tail: tool definitions inside `data:` payloads are compacted first (name kept, schema/description elided — often enough to keep the whole payload as valid JSON), and anything still over the 64k cap keeps its head and tail with a `: omp-debug-elided chars=N` comment marking the removed middle, so trailing fields like `usage` stay visible.
+- The `web_search` tool prompt now tells the model to never search for content that is programmatically accessible or has a known URL (GitHub, known arXiv papers, Wikipedia pages, official docs) and to `read` the URL directly instead.
### Fixed
- Fixed `todo` calls that omit `op` hard-failing validation ("op must be operation to apply (was missing)"): the tool now validates leniently and infers the op for unambiguous payloads (`list` → `init`, `phase`+`items` → `append`, bare `items` on an empty list → `init`); `op` stays required in the schema, and ambiguous op-less calls surface the schema error as a retryable tool error.
+- Fixed credential-free web search engines (SearXNG, DuckDuckGo, Google, Startpage, Ecosia, Mojeek, and the Public Web fan-out) returning zero results for queries with `site:` paths (e.g. `site:github.com/owner/repo`) or `inurl:` operators: scraper engines only match `site:` against a bare domain and DuckDuckGo ignores `inurl:` entirely, so such queries silently emptied the result set and fell through to the next provider in the chain. A shared `formatScraperQuery` formatter now structurally demotes path-carrying `site:` and all `inurl:` values to plain search terms (covering OR-grouped and quoted directives) while preserving bare-domain `site:` filters, negated operators, and each engine's supported syntax; the pipeline post-filter still enforces the demoted constraints on returned sources.
- Fixed `ast_edit` previews reading like applied edits to the model: the `⟨proposed⟩` badge was TUI-only, so the model-visible result (hashline header + `-`/`+` rows, identical to applied edit output) carried no staged-proposal signal. The preview result now leads with a "Staged as a proposal — files NOT modified yet" notice naming `xd://resolve`/`xd://reject`, the injected resolve reminder names the source tool, and the `ast_edit` tool prompt documents the two-phase flow.
- Fixed the `hub` launch `ps`/`list` response burying the active process behind every exited one and growing without bound in long-lived projects: the broker now lists non-terminal daemons first (oldest to newest) and caps exited/failed history at the 10 most recently exited, so the active launch is immediately visible and the response stays bounded. Broker recovery also preserves each already-terminal daemon's real exit time instead of overwriting it with the restart timestamp, so the history cap keeps the genuinely most-recently-exited processes after an idle-broker restart ([#6517](https://github.com/can1357/oh-my-pi/issues/6517)).
diff --git a/packages/coding-agent/src/cli/web-search-cli.ts b/packages/coding-agent/src/cli/web-search-cli.ts
index b642cda67..cd7f12564 100644
--- a/packages/coding-agent/src/cli/web-search-cli.ts
+++ b/packages/coding-agent/src/cli/web-search-cli.ts
@@ -130,8 +130,15 @@ ${chalk.bold("Options:")}
--compact Render condensed output
-h, --help Show this help
+${chalk.bold("Query directives:")}
+ site:/-site: after:/before: (YYYY-MM-DD) inurl: intitle: filetype:
+ "exact phrase" -term OR
+ Mapped to native provider filters where available, otherwise applied as a
+ lenient post-filter (a constraint matching nothing is relaxed, not fatal).
+
${chalk.bold("Examples:")}
${APP_NAME} q --provider=exa "what's the color of the sky"
${APP_NAME} q --provider=brave --recency=week "latest TypeScript 5.7 changes"
+ ${APP_NAME} q 'transformer scaling site:arxiv.org after:2024 -site:reddit.com'
`);
}
diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts
index b1204abe4..8d808ad55 100644
--- a/packages/coding-agent/src/config/settings-schema.ts
+++ b/packages/coding-agent/src/config/settings-schema.ts
@@ -5266,6 +5266,11 @@ export const SETTINGS_SCHEMA = {
default: undefined,
},
+ "searxng.engines": {
+ type: "string",
+ default: undefined,
+ },
+
"searxng.language": {
type: "string",
default: undefined,
diff --git a/packages/coding-agent/src/prompts/tools/web-search.md b/packages/coding-agent/src/prompts/tools/web-search.md
index a5dcc4004..a48555a07 100644
--- a/packages/coding-agent/src/prompts/tools/web-search.md
+++ b/packages/coding-agent/src/prompts/tools/web-search.md
@@ -3,4 +3,6 @@ Searches the web for up-to-date information beyond knowledge cutoff.
- You SHOULD prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
- You MUST include links for cited sources in the final response
+- NEVER use for content that is programmatically accessible or whose URL you already know (GitHub repos/issues, a known arXiv paper, a Wikipedia page, official docs) — `read` the URL directly instead
+- `query` supports Google-style directives on every provider: `site:`/`-site:`, `after:`/`before:` (`YYYY-MM-DD`), `inurl:`, `intitle:`, `filetype:`, `"exact phrase"`, `-term`, `OR`. Constraints map to native provider filters where available; otherwise results are filtered leniently — a constraint matching nothing is relaxed and reported instead of returning zero results.
diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts
index 83cf2c5dc..41c1413a0 100644
--- a/packages/coding-agent/src/web/search/index.ts
+++ b/packages/coding-agent/src/web/search/index.ts
@@ -27,6 +27,7 @@ import {
type SearchProvider,
type SearchProviderCandidate,
} from "./provider";
+import { applyQueryConstraints, parseSearchQuery } from "./query";
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
import type { SearchProviderId, SearchResponse } from "./types";
import { SearchProviderError } from "./types";
@@ -57,9 +58,12 @@ function formatCount(label: string, count: number): string {
return `${count} ${label}${count === 1 ? "" : "s"}`;
}
-/** Format response for LLM consumption */
-function formatForLLM(response: SearchResponse): string {
+/** Format response for LLM consumption. `notes` lead the output (e.g. relaxed-constraint warnings). */
+function formatForLLM(response: SearchResponse, notes: readonly string[] = []): string {
const parts: string[] = [];
+ for (const note of notes) {
+ parts.push(`Note: ${note}`);
+ }
if (response.answer) {
parts.push(response.answer);
@@ -141,6 +145,8 @@ async function executeSearch(
candidates = resolveProviderCandidates();
}
+ const parsedQuery = parseSearchQuery(params.query);
+
// Invariant across providers; read once and tolerate an uninitialized
// Settings singleton (e.g. `omp q ...` CLI path, unit tests) so the
// provider-fallback loop never aborts before any provider runs.
@@ -182,6 +188,7 @@ async function executeSearch(
const response = await provider.search({
query: params.query,
+ parsedQuery,
limit: params.limit,
recency: params.recency,
systemPrompt: webSearchSystemPrompt,
@@ -196,15 +203,31 @@ async function executeSearch(
geminiModel,
});
- if (!hasRenderableSearchContent(response)) {
+ // Lenient constraint pass over whatever the provider returned: enforce
+ // site:/inurl:/intitle:/filetype:/date directives the provider could
+ // not (or only partially) honor natively, relaxing any dimension that
+ // would wipe out every result. Citations/answer text stay untouched.
+ let finalResponse = response;
+ const constraintNotes: string[] = [];
+ if (parsedQuery.hasConstraints && response.sources.length > 0) {
+ const filtered = applyQueryConstraints(response.sources, parsedQuery);
+ if (filtered.sources.length !== response.sources.length) {
+ finalResponse = { ...response, sources: filtered.sources };
+ }
+ for (const label of filtered.dropped) {
+ constraintNotes.push(`no results matched \`${label}\`; the constraint was relaxed`);
+ }
+ }
+
+ if (!hasRenderableSearchContent(finalResponse)) {
throw new SearchProviderError(provider.id, `${provider.label} returned no renderable search content.`, 204);
}
- const text = formatForLLM(response);
+ const text = formatForLLM(finalResponse, constraintNotes);
return {
content: [{ type: "text" as const, text }],
- details: { response },
+ details: { response: finalResponse },
};
} catch (error) {
// Surface user-initiated cancellation immediately so the session sees
diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts
index 7f25f7b80..d34661d85 100644
--- a/packages/coding-agent/src/web/search/providers/anthropic.ts
+++ b/packages/coding-agent/src/web/search/providers/anthropic.ts
@@ -28,6 +28,7 @@ import type {
SearchSource,
} from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, parseSearchQuery, type QuerySyntax, type StructuredQuery } from "../query";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -36,6 +37,59 @@ const DEFAULT_MODEL = "claude-haiku-4-5";
const DEFAULT_MAX_TOKENS = 4096;
const WEB_SEARCH_TOOL_NAME = "web_search";
const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
+
+/**
+ * Claude's search backend understands common Google-style operators, so most
+ * directives are re-emitted as query text. `site:` is intentionally absent:
+ * site includes/excludes map onto the web_search tool's native
+ * `allowed_domains`/`blocked_domains` parameters instead.
+ */
+const ANTHROPIC_QUERY_SYNTAX: QuerySyntax = {
+ phrases: true,
+ negation: true,
+ or: true,
+ inUrl: true,
+ inTitle: true,
+ filetype: true,
+ dateRange: true,
+};
+
+/** Upstream request shape derived from the parsed query. */
+interface AnthropicQueryPlan {
+ query: string;
+ allowedDomains?: string[];
+ blockedDomains?: string[];
+}
+
+/**
+ * Map parsed directives onto the request: `site:` includes become
+ * `allowed_domains`, `-site:` exclusions become `blocked_domains` (the two are
+ * mutually exclusive on the API, so exclusions are only sent when there are no
+ * includes), and remaining directives are re-emitted as query syntax.
+ * Directive-free queries pass through byte-identical. Anthropic domain
+ * filters take bare hosts (subdomains included automatically); any path part
+ * of a `site:` value is enforced by the central constraint filter.
+ */
+function planQuery(rawQuery: string, parsed: StructuredQuery): AnthropicQueryPlan {
+ if (!parsed.hasDirectives) return { query: rawQuery };
+ const hosts = (sites: readonly string[]) => {
+ const unique = new Set();
+ for (const site of sites) {
+ const slash = site.indexOf("/");
+ const host = slash === -1 ? site : site.slice(0, slash);
+ if (host.length > 0) unique.add(host);
+ }
+ return [...unique];
+ };
+ const allowed = hosts(parsed.sites);
+ const blocked = allowed.length === 0 ? hosts(parsed.excludedSites) : [];
+ return {
+ query: formatQuery(parsed, ANTHROPIC_QUERY_SYNTAX),
+ allowedDomains: allowed.length > 0 ? allowed : undefined,
+ blockedDomains: blocked.length > 0 ? blocked : undefined,
+ };
+}
+
export interface AnthropicSearchParams {
query: string;
system_prompt?: string;
@@ -82,7 +136,7 @@ function buildSystemBlocks(
* Calls the Anthropic API with web search tool enabled.
* @param auth - Authentication configuration (API key or OAuth)
* @param model - Model identifier to use
- * @param query - Search query from the user
+ * @param plan - Query text plus native domain filters derived from parsed directives
* @param metadataUserId - Optional Anthropic Messages metadata.user_id (already shaped for OAuth)
* @param systemPrompt - Optional system prompt for guiding response style
* @returns Raw API response from Anthropic
@@ -91,7 +145,7 @@ function buildSystemBlocks(
async function callSearch(
auth: AnthropicAuthConfig,
model: string,
- query: string,
+ plan: AnthropicQueryPlan,
metadataUserId?: string,
systemPrompt?: string,
maxTokens?: number,
@@ -107,11 +161,13 @@ async function callSearch(
const body: Record = {
model,
max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
- messages: [{ role: "user", content: query }],
+ messages: [{ role: "user", content: plan.query }],
tools: [
{
type: WEB_SEARCH_TOOL_TYPE,
name: WEB_SEARCH_TOOL_NAME,
+ ...(plan.allowedDomains ? { allowed_domains: plan.allowedDomains } : {}),
+ ...(plan.blockedDomains ? { blocked_domains: plan.blockedDomains } : {}),
},
],
};
@@ -283,6 +339,8 @@ export async function searchAnthropic(
const callerSessionId = "authStorage" in params ? params.sessionId : undefined;
const accountId =
"authStorage" in params ? params.authStorage.getOAuthAccountId("anthropic", params.sessionId) : undefined;
+ const parsed = ("parsedQuery" in params ? params.parsedQuery : undefined) ?? parseSearchQuery(params.query);
+ const plan = planQuery(params.query, parsed);
const response = await withAuth(
keyOrResolver,
key => {
@@ -302,7 +360,7 @@ export async function searchAnthropic(
return callSearch(
auth,
model,
- params.query,
+ plan,
metadataUserId,
systemPrompt,
maxTokens,
diff --git a/packages/coding-agent/src/web/search/providers/base.ts b/packages/coding-agent/src/web/search/providers/base.ts
index 6841cf253..a123331b4 100644
--- a/packages/coding-agent/src/web/search/providers/base.ts
+++ b/packages/coding-agent/src/web/search/providers/base.ts
@@ -1,5 +1,6 @@
import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import type { ModelRegistry } from "../../../config/model-registry";
+import type { StructuredQuery } from "../query";
import type { SearchProviderId, SearchResponse } from "../types";
/**
@@ -14,6 +15,21 @@ import type { SearchProviderId, SearchResponse } from "../types";
*/
export interface SearchParams {
query: string;
+ /**
+ * Structured view of `query`, parsed once by the search pipeline:
+ * Google-style directives (`site:`, `before:`/`after:`, `inurl:`,
+ * `intitle:`, `filetype:`, quoted phrases, `OR` groups, `-exclusions`)
+ * extracted into fields.
+ *
+ * Providers SHOULD map constraints onto native API parameters
+ * (domain/date filters) or engine query syntax (`formatQuery`) where the
+ * upstream supports them, and lean lenient otherwise: the pipeline
+ * post-filters every response with `applyQueryConstraints`, which
+ * relaxes any constraint that would eliminate all results — so a
+ * best-effort search always beats an empty one. When absent (direct
+ * provider calls), parse with `parseSearchQuery(params.query)`.
+ */
+ parsedQuery?: StructuredQuery;
limit?: number;
/**
* Temporal filter narrowing results to the specified time window.
diff --git a/packages/coding-agent/src/web/search/providers/brave.ts b/packages/coding-agent/src/web/search/providers/brave.ts
index 5fdbc2c02..57ac1f480 100644
--- a/packages/coding-agent/src/web/search/providers/brave.ts
+++ b/packages/coding-agent/src/web/search/providers/brave.ts
@@ -7,6 +7,8 @@
import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import type { QuerySyntax, StructuredQuery } from "../query";
+import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
import { clampNumResults, dateToAgeSeconds } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -23,10 +25,32 @@ const RECENCY_MAP: Record<"day" | "week" | "month" | "year", "pd" | "pw" | "pm"
year: "py",
};
+/**
+ * Brave parses the classic operator set inline (site:, quotes, -, OR…) but
+ * date bounds map onto the native `freshness` param, so `before:`/`after:`
+ * tokens are stripped from the rebuilt query string.
+ */
+const BRAVE_QUERY_SYNTAX: QuerySyntax = { ...GOOGLE_QUERY_SYNTAX, dateRange: false };
+
+/**
+ * Freshness param: explicit `after:`/`before:` bounds win over the
+ * recency-derived period, rendered as Brave's absolute range
+ * `YYYY-MM-DDtoYYYY-MM-DD` with sensible open ends.
+ */
+function braveFreshness(parsed: StructuredQuery, recency?: keyof typeof RECENCY_MAP): string | undefined {
+ if (parsed.after || parsed.before) {
+ const start = parsed.after ?? "1970-01-01";
+ const end = parsed.before ?? new Date().toISOString().slice(0, 10);
+ return `${start}to${end}`;
+ }
+ return recency ? RECENCY_MAP[recency] : undefined;
+}
+
export interface BraveSearchParams {
query: string;
num_results?: number;
recency?: "day" | "week" | "month" | "year";
+ parsedQuery?: StructuredQuery;
signal?: AbortSignal;
fetch?: FetchImpl;
}
@@ -73,12 +97,14 @@ async function callBraveSearch(
params: BraveSearchParams,
): Promise<{ response: BraveSearchResponse; requestId?: string }> {
const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
const url = new URL(BRAVE_SEARCH_URL);
- url.searchParams.set("q", params.query);
+ url.searchParams.set("q", parsed.hasDirectives ? formatQuery(parsed, BRAVE_QUERY_SYNTAX) : params.query);
url.searchParams.set("count", String(numResults));
url.searchParams.set("extra_snippets", "true");
- if (params.recency) {
- url.searchParams.set("freshness", RECENCY_MAP[params.recency]);
+ const freshness = braveFreshness(parsed, params.recency);
+ if (freshness) {
+ url.searchParams.set("freshness", freshness);
}
const fetchImpl = params.fetch ?? fetch;
@@ -145,6 +171,7 @@ export class BraveProvider extends SearchProvider {
query: params.query,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
+ parsedQuery: params.parsedQuery,
signal: params.signal,
fetch: params.fetch,
});
diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts
index 9b543f0f7..4599b3e5e 100644
--- a/packages/coding-agent/src/web/search/providers/codex.ts
+++ b/packages/coding-agent/src/web/search/providers/codex.ts
@@ -31,6 +31,7 @@ import packageJson from "../../../../package.json" with { type: "json" };
import type { ModelRegistry } from "../../../config/model-registry";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -572,6 +573,7 @@ async function callCodexSearch(
async function runCodexSearchCandidates(options: {
auth: { accessToken: string; accountId?: string };
params: SearchParams;
+ query: string;
modelCandidates: CodexModelCandidate[];
modelWasConfigured: boolean;
transport: CodexSearchTransport;
@@ -582,7 +584,7 @@ async function runCodexSearchCandidates(options: {
if (!candidate) continue;
try {
- return await callCodexSearch(options.auth, options.params.query, {
+ return await callCodexSearch(options.auth, options.query, {
signal: options.params.signal,
systemPrompt: options.params.systemPrompt,
searchContextSize: "high",
@@ -622,6 +624,15 @@ export async function searchCodex(params: SearchParams): Promise
throw new SearchProviderError("codex", "No Codex web search model is configured.");
}
const transport = resolveCodexSearchTransport(params.modelRegistry, firstCandidate.modelId);
+ // The ChatGPT-backend Codex endpoint speaks the undocumented codex-rs
+ // request shape (responses-lite moves tools into an `additional_tools`
+ // developer item), so the documented `web_search.filters.allowed_domains`
+ // parameter cannot be assumed to survive it. Instead, re-emit directive
+ // queries with the full Google-style operator syntax — the backing index
+ // parses the classic operator set — and leave directive-free queries
+ // byte-identical.
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ const query = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
let result: CodexSearchResult;
if (transport.customEndpoint) {
@@ -652,6 +663,7 @@ export async function searchCodex(params: SearchParams): Promise
runCodexSearchCandidates({
auth: { accessToken },
params,
+ query,
modelCandidates,
modelWasConfigured: configuredModel !== undefined,
transport,
@@ -682,6 +694,7 @@ export async function searchCodex(params: SearchParams): Promise
return runCodexSearchCandidates({
auth: { accessToken: access.accessToken, accountId },
params,
+ query,
modelCandidates,
modelWasConfigured: configuredModel !== undefined,
transport,
diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts
index c77972375..f8343d7fb 100644
--- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts
+++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts
@@ -1,6 +1,8 @@
import type { AuthStorage } from "@oh-my-pi/pi-ai";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import type { QuerySyntax } from "../query";
+import { formatScraperQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -118,8 +120,28 @@ function isAnomalyResponse(html: string): boolean {
return html.includes("anomaly-modal") || html.includes("anomaly.js");
}
+/**
+ * Query syntax the DDG HTML frontend parses: quotes, `-`, OR, site:,
+ * filetype:, intitle:, inurl:, intext:. Date bounds (`before:`/`after:`) are
+ * deliberately off — DDG does not parse them, so they are stripped from the
+ * query and enforced by the pipeline's lenient post-filter instead.
+ */
+const DDG_QUERY_SYNTAX: QuerySyntax = {
+ phrases: true,
+ negation: true,
+ or: true,
+ site: true,
+ inUrl: true,
+ inTitle: true,
+ inText: true,
+ filetype: true,
+};
+
async function callDuckDuckGoHtml(params: SearchParams): Promise {
- const form = new URLSearchParams({ q: params.query, kl: "us-en" });
+ const form = new URLSearchParams({
+ q: formatScraperQuery(params.query, params.parsedQuery, DDG_QUERY_SYNTAX),
+ kl: "us-en",
+ });
const df = params.recency ? RECENCY_TO_DDG_DF[params.recency] : undefined;
if (df) form.set("df", df);
// Add b: "" parameter as specified in the browser fetch template to match real browser form submission
diff --git a/packages/coding-agent/src/web/search/providers/ecosia.ts b/packages/coding-agent/src/web/search/providers/ecosia.ts
index a41cca27a..fcbb37163 100644
--- a/packages/coding-agent/src/web/search/providers/ecosia.ts
+++ b/packages/coding-agent/src/web/search/providers/ecosia.ts
@@ -2,6 +2,7 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai";
import { parseHTML } from "linkedom";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatScraperQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -100,7 +101,10 @@ function isBlockedPage(page: LoadedHtmlPage): boolean {
async function callEcosiaHtml(params: SearchParams): Promise {
const signal = withHardTimeout(params.signal);
const url = new URL(ECOSIA_SEARCH_URL);
- url.searchParams.set("q", params.query);
+ // Ecosia serves Google-backed results, so classic operators pass through
+ // inline; canonicalize aliases (domain: -> site:, since: -> after:) and
+ // demote scraper-hostile operators via the shared scraper formatter.
+ url.searchParams.set("q", formatScraperQuery(params.query, params.parsedQuery));
let page: LoadedHtmlPage;
try {
diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts
index a26143d67..1e71ee610 100644
--- a/packages/coding-agent/src/web/search/providers/exa.ts
+++ b/packages/coding-agent/src/web/search/providers/exa.ts
@@ -12,6 +12,7 @@ import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
import { parseSSE } from "../../../mcp/json-rpc";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, parseSearchQuery, type StructuredQuery } from "../query";
import { dateToAgeSeconds } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -472,8 +473,9 @@ export class ExaProvider extends SearchProvider {
}
search(params: SearchParams): Promise {
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
return searchExa({
- query: params.query,
+ ...directiveParams(parsed),
num_results: params.numSearchResults ?? params.limit,
signal: params.signal,
authStorage: params.authStorage,
@@ -482,3 +484,28 @@ export class ExaProvider extends SearchProvider {
});
}
}
+
+/**
+ * Map parsed query directives onto Exa's native request parameters:
+ * `site:` → includeDomains, `-site:` → excludeDomains (bare hosts; path parts
+ * are enforced by the central constraint filter), `after:`/`before:` →
+ * start/endPublishedDate (ISO 8601). Exa's neural search prefers natural
+ * language, so the query itself is re-emitted with quoted phrases only.
+ * Directive-free queries pass through byte-identical.
+ */
+function directiveParams(
+ parsed: StructuredQuery,
+): Pick<
+ ExaSearchParams,
+ "query" | "include_domains" | "exclude_domains" | "start_published_date" | "end_published_date"
+> {
+ if (!parsed.hasDirectives) return { query: parsed.raw };
+ const hosts = (sites: readonly string[]) => [...new Set(sites.map(site => site.split("/", 1)[0]))];
+ return {
+ query: formatQuery(parsed, { phrases: true }),
+ include_domains: parsed.sites.length ? hosts(parsed.sites) : undefined,
+ exclude_domains: parsed.excludedSites.length ? hosts(parsed.excludedSites) : undefined,
+ start_published_date: parsed.after,
+ end_published_date: parsed.before,
+ };
+}
diff --git a/packages/coding-agent/src/web/search/providers/firecrawl.ts b/packages/coding-agent/src/web/search/providers/firecrawl.ts
index cd8ad00e0..ff87a465b 100644
--- a/packages/coding-agent/src/web/search/providers/firecrawl.ts
+++ b/packages/coding-agent/src/web/search/providers/firecrawl.ts
@@ -14,6 +14,7 @@ import {
} from "@oh-my-pi/pi-ai";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery, type StructuredQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -34,6 +35,8 @@ export interface FirecrawlSearchParams {
query: string;
num_results?: number;
recency?: SearchParams["recency"];
+ /** Explicit `tbs` (custom date range); takes precedence over `recency`. */
+ tbs?: string;
signal?: AbortSignal;
fetch?: FetchImpl;
}
@@ -67,8 +70,9 @@ function buildRequestBody(params: FirecrawlSearchParams): Record {
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ let query = params.query;
+ let tbs: string | undefined;
+ if (parsed.hasDirectives) {
+ // Firecrawl search is SERP-backed: the query supports Google operators
+ // (site:, inurl:, intitle:, quotes, -, OR). Absolute date bounds move to
+ // the native tbs param and are stripped from the query string.
+ tbs = buildDateTbs(parsed);
+ query = formatQuery(parsed, tbs ? { ...GOOGLE_QUERY_SYNTAX, dateRange: false } : GOOGLE_QUERY_SYNTAX);
+ }
const firecrawlParams: FirecrawlSearchParams = {
- query: params.query,
+ query,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
+ tbs,
signal: params.signal,
fetch: params.fetch,
};
diff --git a/packages/coding-agent/src/web/search/providers/gemini.ts b/packages/coding-agent/src/web/search/providers/gemini.ts
index 27612ae77..ccd36fd8a 100644
--- a/packages/coding-agent/src/web/search/providers/gemini.ts
+++ b/packages/coding-agent/src/web/search/providers/gemini.ts
@@ -18,6 +18,7 @@ import { fetchWithRetry } from "@oh-my-pi/pi-utils";
import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery, type StructuredQuery } from "../query";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -51,6 +52,8 @@ interface GeminiToolParams {
export interface GeminiSearchParams extends GeminiToolParams {
query: string;
+ /** Pre-parsed structured query; falls back to parsing `query` when omitted. */
+ parsedQuery?: StructuredQuery;
system_prompt?: string;
num_results?: number;
/** Maximum output tokens. */
@@ -508,6 +511,12 @@ async function callGeminiDeveloperSearch(
*/
export async function searchGemini(params: GeminiSearchParams): Promise {
const selectedModel = resolveGeminiSearchModel(params.geminiModel);
+ // Gemini's googleSearch grounding forwards the query to Google Search, which
+ // understands the classic operator set natively. Normalize directive aliases
+ // (domain: → site:, since: → after:, …) to canonical Google forms; leave
+ // directive-free queries byte-identical.
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ const searchQuery = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
let result: GeminiSearchResult;
@@ -528,7 +537,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise {
return searchGemini({
query: params.query,
+ parsedQuery: params.parsedQuery,
system_prompt: params.systemPrompt,
num_results: params.numSearchResults ?? params.limit,
max_output_tokens: params.maxOutputTokens,
diff --git a/packages/coding-agent/src/web/search/providers/google.ts b/packages/coding-agent/src/web/search/providers/google.ts
index 574f2dcaf..3cad6b51c 100644
--- a/packages/coding-agent/src/web/search/providers/google.ts
+++ b/packages/coding-agent/src/web/search/providers/google.ts
@@ -2,6 +2,7 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai";
import { parseHTML } from "linkedom";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatScraperQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -91,7 +92,7 @@ function parseHtmlResults(html: string): ParsedResult[] {
function buildSearchUrl(params: SearchParams, numResults: number): string {
const url = new URL(GOOGLE_SEARCH_URL);
- url.searchParams.set("q", params.query);
+ url.searchParams.set("q", formatScraperQuery(params.query, params.parsedQuery));
url.searchParams.set("num", String(numResults));
url.searchParams.set("hl", "en");
url.searchParams.set("gl", "us");
diff --git a/packages/coding-agent/src/web/search/providers/jina.ts b/packages/coding-agent/src/web/search/providers/jina.ts
index 5b31e0630..a46ea5834 100644
--- a/packages/coding-agent/src/web/search/providers/jina.ts
+++ b/packages/coding-agent/src/web/search/providers/jina.ts
@@ -8,6 +8,7 @@
import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, parseSearchQuery } from "../query";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -18,6 +19,8 @@ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
export interface JinaSearchParams {
query: string;
num_results?: number;
+ /** Single bare host for Jina's `X-Site` in-site search header. */
+ site?: string;
signal?: AbortSignal;
fetch?: FetchImpl;
}
@@ -39,15 +42,18 @@ export function findApiKey(): string | null {
async function callJinaSearch(
apiKey: string,
query: string,
+ site?: string,
signal?: AbortSignal,
fetchImpl: FetchImpl = fetch,
): Promise {
const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
+ const headers: Record = {
+ Accept: "application/json",
+ Authorization: `Bearer ${apiKey}`,
+ };
+ if (site) headers["X-Site"] = site;
const response = await fetchImpl(requestUrl, {
- headers: {
- Accept: "application/json",
- Authorization: `Bearer ${apiKey}`,
- },
+ headers,
signal: withHardTimeout(signal),
});
@@ -69,7 +75,7 @@ export async function searchJina(params: JinaSearchParams): Promise {
- const fetchImpl = params.fetch;
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ let query = params.query;
+ let site: string | undefined;
+ if (parsed.hasDirectives) {
+ // Jina's X-Site header takes a single domain; with exactly one
+ // include site, send its host there and strip site: tokens from
+ // the query. Multiple sites stay inline (Bing-backed, parses them).
+ if (parsed.sites.length === 1) site = parsed.sites[0]!.split("/")[0];
+ query = formatQuery(parsed, {
+ phrases: true,
+ negation: true,
+ site: !site,
+ inTitle: true,
+ inUrl: true,
+ filetype: true,
+ });
+ }
return searchJina({
- query: params.query,
+ query,
num_results: params.numSearchResults ?? params.limit,
+ site,
signal: params.signal,
- fetch: fetchImpl,
+ fetch: params.fetch,
});
}
}
diff --git a/packages/coding-agent/src/web/search/providers/kagi.ts b/packages/coding-agent/src/web/search/providers/kagi.ts
index 081022304..8de2ee349 100644
--- a/packages/coding-agent/src/web/search/providers/kagi.ts
+++ b/packages/coding-agent/src/web/search/providers/kagi.ts
@@ -7,6 +7,8 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import type { SearchResponse } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
import { KagiApiError, searchWithKagi } from "../../kagi";
+import type { StructuredQuery } from "../query";
+import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -22,16 +24,22 @@ export async function searchKagi(params: {
query: string;
num_results?: number;
recency?: SearchParams["recency"];
+ parsedQuery?: StructuredQuery;
signal?: AbortSignal;
authStorage: AuthStorage;
sessionId?: string;
fetch?: FetchImpl;
}): Promise {
const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
+ // Kagi's index understands the classic Google operator set: canonicalize
+ // directives (domain: -> site:, until: -> before:YYYY-MM-DD, ...) and pass
+ // them through in the query string. Directive-free queries stay untouched.
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ const query = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
try {
const result = await searchWithKagi(
- params.query,
+ query,
{
limit: numResults,
recency: params.recency,
@@ -75,6 +83,7 @@ export class KagiProvider extends SearchProvider {
return searchKagi({
query: params.query,
+ parsedQuery: params.parsedQuery,
num_results: params.numSearchResults ?? params.limit,
recency: params.recency,
signal: params.signal,
diff --git a/packages/coding-agent/src/web/search/providers/kimi.ts b/packages/coding-agent/src/web/search/providers/kimi.ts
index c265747a1..969960005 100644
--- a/packages/coding-agent/src/web/search/providers/kimi.ts
+++ b/packages/coding-agent/src/web/search/providers/kimi.ts
@@ -12,6 +12,7 @@ import { $env } from "@oh-my-pi/pi-utils";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, parseSearchQuery, type QuerySyntax, type StructuredQuery } from "../query";
import { clampNumResults, dateToAgeSeconds } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -25,8 +26,19 @@ const DEFAULT_NUM_RESULTS = 10;
const MAX_NUM_RESULTS = 20;
const DEFAULT_TIMEOUT_SECONDS = 30;
+/** Kimi Code search is Bing-flavored: re-emit the operators Bing parses; dates/lang stay with the central filter. */
+const KIMI_QUERY_SYNTAX: QuerySyntax = {
+ phrases: true,
+ negation: true,
+ site: true,
+ inTitle: true,
+ inUrl: true,
+ filetype: true,
+};
+
export interface KimiSearchParams {
query: string;
+ parsedQuery?: StructuredQuery;
num_results?: number;
include_content?: boolean;
signal?: AbortSignal;
@@ -138,12 +150,14 @@ export async function searchKimi(params: KimiSearchParams): Promise
callKimiSearch(key, {
- query: params.query,
+ query,
limit,
includeContent: params.include_content ?? false,
signal: params.signal,
@@ -192,6 +206,7 @@ export class KimiProvider extends SearchProvider {
return searchKimi({
query: params.query,
+ parsedQuery: params.parsedQuery,
num_results: params.numSearchResults ?? params.limit,
signal: params.signal,
authStorage: params.authStorage,
diff --git a/packages/coding-agent/src/web/search/providers/mojeek.ts b/packages/coding-agent/src/web/search/providers/mojeek.ts
index 4951803bc..ed06c2fa3 100644
--- a/packages/coding-agent/src/web/search/providers/mojeek.ts
+++ b/packages/coding-agent/src/web/search/providers/mojeek.ts
@@ -4,6 +4,7 @@ import { parseHTML } from "linkedom";
import type { Page } from "puppeteer-core";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatScraperQuery, type QuerySyntax } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -81,9 +82,21 @@ function parseHtmlResults(html: string): ParsedResult[] {
return results;
}
+/**
+ * Syntax re-emitted to Mojeek for directive-carrying queries. Mojeek's
+ * support page (mojeek.com/support/search-operators.html) confirms `site:`,
+ * and the community docs confirm quoted phrases and `-` exclusions. Mojeek
+ * also parses `in*:` operators and its own date syntax (`since:`/`before:`
+ * with YYYYMMDD), but the latter differs from Google's `after:`/`before:`
+ * ISO form and `since` is already claimed by `recency`, so date bounds and
+ * `in*` constraints are conservatively left to the pipeline's lenient
+ * post-filter instead.
+ */
+const MOJEEK_QUERY_SYNTAX: QuerySyntax = { phrases: true, negation: true, site: true };
+
function buildSearchUrl(params: SearchParams, numResults: number): string {
const url = new URL(MOJEEK_SEARCH_URL);
- url.searchParams.set("q", params.query);
+ url.searchParams.set("q", formatScraperQuery(params.query, params.parsedQuery, MOJEEK_QUERY_SYNTAX));
url.searchParams.set("t", String(numResults));
url.searchParams.set("arc", "none");
url.searchParams.set("lang", "en");
diff --git a/packages/coding-agent/src/web/search/providers/parallel.ts b/packages/coding-agent/src/web/search/providers/parallel.ts
index 5c22d03c3..957efde5f 100644
--- a/packages/coding-agent/src/web/search/providers/parallel.ts
+++ b/packages/coding-agent/src/web/search/providers/parallel.ts
@@ -9,6 +9,7 @@ import {
parseParallelErrorResponse,
parseParallelSearchPayload,
} from "../../parallel";
+import { formatQuery, parseSearchQuery, type StructuredQuery } from "../query";
import { clampNumResults } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -17,6 +18,42 @@ import { classifyProviderHttpError, toSearchSources, withHardTimeout } from "./u
const DEFAULT_NUM_RESULTS = 10;
const MAX_NUM_RESULTS = 40;
+/** Query-string caps for Parallel: natural-language objective, no field operators. */
+const PARALLEL_QUERY_SYNTAX = { phrases: true, negation: true, or: true } as const;
+
+/** Parallel `source_policy` (beta Search API): bare-host allow/deny lists + freshness floor. */
+interface ParallelSourcePolicy {
+ include_domains?: string[];
+ exclude_domains?: string[];
+ after_date?: string;
+}
+
+/** Site values may carry paths (`github.com/anthropics`); Parallel takes bare hosts. */
+function toHosts(sites: readonly string[]): string[] {
+ const hosts = new Set();
+ for (const site of sites) {
+ const host = site.split("/", 1)[0];
+ if (host) hosts.add(host);
+ }
+ return [...hosts];
+}
+
+/**
+ * Map parsed `site:`/`-site:`/`after:` directives onto Parallel's
+ * `source_policy`. Per Parallel docs, `exclude_domains` is ignored when
+ * `include_domains` is set, so exclusions are only sent without an allow
+ * list (the central lenient filter enforces them regardless).
+ */
+function toSourcePolicy(parsed: StructuredQuery): ParallelSourcePolicy | undefined {
+ const policy: ParallelSourcePolicy = {};
+ const include = toHosts(parsed.sites);
+ const exclude = toHosts(parsed.excludedSites);
+ if (include.length) policy.include_domains = include;
+ else if (exclude.length) policy.exclude_domains = exclude;
+ if (parsed.after) policy.after_date = parsed.after;
+ return Object.keys(policy).length ? policy : undefined;
+}
+
async function searchWithAuthStorage(
objective: string,
queries: string[],
@@ -26,6 +63,7 @@ async function searchWithAuthStorage(
},
authStorage: AuthStorage,
sessionId?: string,
+ sourcePolicy?: ParallelSourcePolicy,
): Promise {
const apiKey = await authStorage.getApiKey("parallel", sessionId, { signal: params.signal });
if (!apiKey) {
@@ -57,6 +95,7 @@ async function searchWithAuthStorage(
excerpts: {
max_chars_per_result: 10_000,
},
+ ...(sourcePolicy && { source_policy: sourcePolicy }),
}),
signal: withHardTimeout(params.signal),
});
@@ -78,22 +117,28 @@ export async function searchParallel(
num_results?: number;
signal?: AbortSignal;
fetch?: FetchImpl;
+ parsedQuery?: StructuredQuery;
},
authStorage: AuthStorage,
sessionId?: string,
): Promise {
const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ // Back-compat: without directives the upstream request is byte-identical.
+ const query = parsed.hasDirectives ? formatQuery(parsed, PARALLEL_QUERY_SYNTAX) : params.query;
+ const sourcePolicy = parsed.hasDirectives ? toSourcePolicy(parsed) : undefined;
try {
const result = await searchWithAuthStorage(
- params.query,
- [params.query],
+ query,
+ [query],
{
signal: params.signal,
fetch: params.fetch,
},
authStorage,
sessionId,
+ sourcePolicy,
);
return {
@@ -128,6 +173,7 @@ export class ParallelProvider extends SearchProvider {
num_results: params.numSearchResults ?? params.limit,
signal: params.signal,
fetch: params.fetch,
+ parsedQuery: params.parsedQuery,
},
params.authStorage,
params.sessionId,
diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts
index 548c8dbc8..c9c40c0a6 100644
--- a/packages/coding-agent/src/web/search/providers/perplexity.ts
+++ b/packages/coding-agent/src/web/search/providers/perplexity.ts
@@ -30,6 +30,7 @@ import type {
SearchSource,
} from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import { formatQuery, parseSearchQuery, type QuerySyntax, type StructuredQuery } from "../query";
import { dateToAgeSeconds } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -46,6 +47,71 @@ const OAUTH_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
const ANONYMOUS_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+/**
+ * Query-string operators Perplexity's search backend tolerates as text signal.
+ * `site:`/date/`lang:` directives are excluded: they map onto native request
+ * fields (`search_domain_filter`, `search_*_date_filter`,
+ * `search_language_filter`) and must be stripped from the query so the engine
+ * is not double-constrained.
+ */
+const PERPLEXITY_QUERY_SYNTAX: QuerySyntax = {
+ phrases: true,
+ negation: true,
+ or: true,
+ inUrl: true,
+ inTitle: true,
+ filetype: true,
+};
+
+/** Native Perplexity search filters derived from parsed query directives. */
+interface PerplexityNativeFilters {
+ /** Query rebuilt without natively-mapped directives. */
+ query: string;
+ /** `search_domain_filter`: allow entries as bare hosts, deny entries as `-host`. */
+ domainFilter?: string[];
+ /** `search_after_date_filter`, `%m/%d/%Y`. */
+ afterDate?: string;
+ /** `search_before_date_filter`, `%m/%d/%Y`. */
+ beforeDate?: string;
+ /** `search_language_filter`: ISO 639-1 two-letter codes. */
+ languageFilter?: string[];
+}
+
+/**
+ * Bare host of a `site:` value (`github.com/anthropics` → `github.com`);
+ * Perplexity's domain filter takes hosts only, the path part is enforced by
+ * the central lenient post-filter.
+ */
+function siteHost(site: string): string {
+ const slash = site.indexOf("/");
+ return slash === -1 ? site : site.slice(0, slash);
+}
+
+/** ISO `YYYY-MM-DD` → Perplexity's documented `%m/%d/%Y` date-filter format (e.g. `3/1/2025`). */
+function toPerplexityDate(iso: string): string {
+ const [year, month, day] = iso.split("-");
+ return `${Number(month)}/${Number(day)}/${year}`;
+}
+
+/** Map parsed query directives onto native Perplexity search filters. */
+function buildNativeFilters(parsed: StructuredQuery, rawQuery: string): PerplexityNativeFilters {
+ if (!parsed.hasDirectives) return { query: rawQuery };
+ // Allow + deny share one array; the API caps it at 20 entries.
+ const domains = [
+ ...new Set([...parsed.sites.map(siteHost), ...parsed.excludedSites.map(site => `-${siteHost(site)}`)]),
+ ].slice(0, 20);
+ // search_language_filter takes ISO 639-1 two-letter codes; pass `en-us` as
+ // `en`, and leave anything else to the central post-filter.
+ const langCode = parsed.lang ? /^([a-z]{2})(?:[-_]|$)/.exec(parsed.lang)?.[1] : undefined;
+ return {
+ query: formatQuery(parsed, PERPLEXITY_QUERY_SYNTAX),
+ domainFilter: domains.length > 0 ? domains : undefined,
+ afterDate: parsed.after ? toPerplexityDate(parsed.after) : undefined,
+ beforeDate: parsed.before ? toPerplexityDate(parsed.before) : undefined,
+ languageFilter: langCode ? [langCode] : undefined,
+ };
+}
+
interface PerplexityOAuthStreamMarkdownBlock {
answer?: string;
chunks?: string[];
@@ -258,6 +324,8 @@ export interface PerplexitySearchParams {
signal?: AbortSignal;
query: string;
system_prompt?: string;
+ /** Pre-parsed view of `query` from the search pipeline; parsed locally when absent. */
+ parsedQuery?: StructuredQuery;
search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
num_results?: number;
/** Maximum output tokens. Defaults to 8192. */
@@ -352,6 +420,10 @@ function buildPerplexityExtraBody(request: PerplexityRequest): Record {
const requestId = crypto.randomUUID();
// The consumer `perplexity_ask` endpoint is itself a research assistant and
@@ -539,7 +612,7 @@ async function callPerplexityAsk(
// "I don't have access to web-search tools in this turn", so ask-endpoint
// searches send the bare query. (The API-key path still uses system_prompt
// as a proper `system` message.)
- const effectiveQuery = params.query;
+ const effectiveQuery = filters.query;
const headers: Record = {
"Content-Type": "application/json",
@@ -578,7 +651,9 @@ async function callPerplexityAsk(
version: OAUTH_API_VERSION,
language: "en-US",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- search_recency_filter: params.search_recency_filter ?? null,
+ // Recency cannot be combined with absolute date filters; explicit
+ // before:/after: bounds take precedence.
+ search_recency_filter: filters.afterDate || filters.beforeDate ? null : (params.search_recency_filter ?? null),
is_incognito: true,
use_schematized_api: true,
// `true` (the native app's default) lets the backend classifier skip
@@ -603,6 +678,10 @@ async function callPerplexityAsk(
if (auth.type === "anonymous") {
requestParams.send_back_text_in_streaming_api = true;
}
+ if (filters.domainFilter) requestParams.search_domain_filter = filters.domainFilter;
+ if (filters.afterDate) requestParams.search_after_date_filter = filters.afterDate;
+ if (filters.beforeDate) requestParams.search_before_date_filter = filters.beforeDate;
+ if (filters.languageFilter) requestParams.search_language_filter = filters.languageFilter;
const requestInit = {
method: "POST",
@@ -781,12 +860,14 @@ function applySourceLimit(result: SearchResponse, limit?: number): SearchRespons
/** Execute Perplexity web search */
export async function searchPerplexity(params: PerplexitySearchParams): Promise {
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
+ const filters = buildNativeFilters(parsed, params.query);
const systemPrompt = params.system_prompt;
const messages: PerplexityRequest["messages"] = [];
if (systemPrompt) {
messages.push({ role: "system", content: systemPrompt });
}
- messages.push({ role: "user", content: params.query });
+ messages.push({ role: "user", content: filters.query });
const request: PerplexityRequest = {
model: "sonar-pro",
@@ -805,7 +886,13 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
return_related_questions: true,
};
- if (params.search_recency_filter) {
+ if (filters.domainFilter) request.search_domain_filter = filters.domainFilter;
+ if (filters.afterDate) request.search_after_date_filter = filters.afterDate;
+ if (filters.beforeDate) request.search_before_date_filter = filters.beforeDate;
+ if (filters.languageFilter) request.search_language_filter = filters.languageFilter;
+ // The API rejects search_recency_filter combined with absolute date
+ // filters; explicit before:/after: bounds take precedence.
+ if (params.search_recency_filter && !filters.afterDate && !filters.beforeDate) {
request.search_recency_filter = params.search_recency_filter;
}
@@ -830,10 +917,10 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
? await withOAuthAccess(
params.authStorage,
"perplexity",
- access => callPerplexityAsk({ type: "oauth", token: access.accessToken }, params),
+ access => callPerplexityAsk({ type: "oauth", token: access.accessToken }, params, filters),
{ sessionId: params.sessionId, signal: params.signal, seed: auth.access },
)
- : await callPerplexityAsk(auth, params);
+ : await callPerplexityAsk(auth, params, filters);
return applySourceLimit(
{
provider: "perplexity",
@@ -892,6 +979,7 @@ export class PerplexityProvider extends SearchProvider {
return searchPerplexity({
signal: params.signal,
query: params.query,
+ parsedQuery: params.parsedQuery,
temperature: params.temperature,
max_tokens: params.maxOutputTokens,
num_search_results: params.numSearchResults,
diff --git a/packages/coding-agent/src/web/search/providers/searxng.ts b/packages/coding-agent/src/web/search/providers/searxng.ts
index 9decfe9c7..b5e4119e5 100644
--- a/packages/coding-agent/src/web/search/providers/searxng.ts
+++ b/packages/coding-agent/src/web/search/providers/searxng.ts
@@ -14,6 +14,9 @@
* searxng.basicUsername - Optional RFC 7617 Basic auth username
* searxng.basicPassword - Optional RFC 7617 Basic auth password
* searxng.categories - Optional comma-separated categories filter
+ * searxng.engines - Optional comma-separated engine names or shortcuts
+ * (e.g. "duckduckgo, br, sp"); shortcuts resolve via
+ * the instance's /config endpoint
* searxng.language - Optional language code (e.g. en, zh-CN)
*
* Environment variable fallbacks:
@@ -22,6 +25,11 @@
* SEARXNG_BASIC_USERNAME - Optional RFC 7617 Basic auth username
* SEARXNG_BASIC_PASSWORD - Optional RFC 7617 Basic auth password
*
+ * Bang syntax in queries is passed through: `!ddg foo` selects an engine or
+ * category server-side and the bang token is stripped from the upstream query.
+ * External bangs (`!!g`) are removed client-side because SearXNG answers them
+ * with an HTTP redirect even for JSON requests.
+ *
* Reference: https://docs.searxng.org/dev/search_api.html
*/
@@ -30,6 +38,8 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import { settings } from "../../../config/settings";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
+import type { StructuredQuery } from "../query";
+import { formatScraperQuery, parseSearchQuery } from "../query";
import { clampNumResults, dateToAgeSeconds } from "../utils";
import type { SearchParams } from "./base";
import { SearchProvider } from "./base";
@@ -73,6 +83,11 @@ interface SearXNGAuth {
value: string;
}
+/** Subset of the SearXNG /config payload used for engine shortcut resolution. */
+interface SearXNGConfig {
+ engines?: Array<{ name?: string; shortcut?: string }>;
+}
+
/** Find SearXNG endpoint from settings or environment. */
function findEndpoint(): string | null {
try {
@@ -150,6 +165,110 @@ function findAuth(): SearXNGAuth | null {
return token ? { type: "bearer", value: token } : null;
}
+/** Find configured engine names/shortcuts from settings. */
+function findEngines(): string | null {
+ try {
+ const engines = settings.get("searxng.engines");
+ if (engines) return engines;
+ } catch {
+ // Settings not initialized yet
+ }
+ return null;
+}
+
+/** Build request headers including authentication. */
+function buildHeaders(auth: SearXNGAuth | null): Record {
+ const headers: Record = { Accept: "application/json" };
+ if (auth?.type === "basic") {
+ headers.Authorization = `Basic ${auth.value}`;
+ } else if (auth?.type === "bearer") {
+ headers.Authorization = `Bearer ${auth.value}`;
+ }
+ return headers;
+}
+
+/** Per-endpoint cache of shortcut/name → canonical engine name maps. */
+const engineNameMapCache = new Map | null>>();
+
+/** Fetch the instance's /config and build a lookup of lowercased engine names
+ * and shortcuts to canonical engine names. Returns null on any failure. */
+async function fetchEngineNameMap(
+ base: string,
+ auth: SearXNGAuth | null,
+ fetchImpl: FetchImpl | undefined,
+ signal: AbortSignal | undefined,
+): Promise