@oh-my-pi/pi-coding-agent 17.1.1 → 17.1.2

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 (96) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +6216 -3984
  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/eval-format/index.d.ts +7 -0
  15. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  16. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  17. package/dist/types/tools/eval-format/python.d.ts +2 -0
  18. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  19. package/dist/types/tools/resolve.d.ts +7 -0
  20. package/dist/types/tools/todo.d.ts +4 -1
  21. package/dist/types/web/search/providers/base.d.ts +16 -0
  22. package/dist/types/web/search/providers/brave.d.ts +2 -0
  23. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  24. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  25. package/dist/types/web/search/providers/jina.d.ts +2 -0
  26. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  27. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  28. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  29. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  30. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  31. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  32. package/dist/types/web/search/query.d.ts +190 -0
  33. package/package.json +12 -12
  34. package/src/cli/bench-cli.ts +12 -1
  35. package/src/cli/web-search-cli.ts +7 -0
  36. package/src/config/model-resolver.ts +17 -6
  37. package/src/config/settings-schema.ts +5 -0
  38. package/src/debug/raw-sse-buffer.ts +157 -30
  39. package/src/export/share.ts +4 -3
  40. package/src/launch/broker-list-order.test.ts +89 -0
  41. package/src/launch/broker.ts +49 -8
  42. package/src/modes/controllers/input-controller.ts +8 -8
  43. package/src/modes/interactive-mode.ts +60 -5
  44. package/src/modes/types.ts +9 -4
  45. package/src/modes/utils/ui-helpers.ts +7 -8
  46. package/src/prompts/system/resolve-device-reminder.md +1 -1
  47. package/src/prompts/system/workflow-notice.md +1 -1
  48. package/src/prompts/tools/ast-edit.md +1 -0
  49. package/src/prompts/tools/bash.md +1 -0
  50. package/src/prompts/tools/eval.md +2 -2
  51. package/src/prompts/tools/task.md +5 -2
  52. package/src/prompts/tools/web-search.md +2 -0
  53. package/src/sdk.ts +8 -4
  54. package/src/session/agent-session.ts +14 -0
  55. package/src/session/queued-messages.ts +7 -1
  56. package/src/task/executor.ts +17 -9
  57. package/src/task/index.ts +14 -34
  58. package/src/task/structured-subagent.ts +4 -0
  59. package/src/task/types.ts +15 -13
  60. package/src/thinking.ts +27 -0
  61. package/src/tools/ast-edit.ts +4 -1
  62. package/src/tools/bash.ts +13 -0
  63. package/src/tools/browser/render.ts +2 -1
  64. package/src/tools/eval-format/index.ts +24 -0
  65. package/src/tools/eval-format/javascript.ts +952 -0
  66. package/src/tools/eval-format/julia.ts +446 -0
  67. package/src/tools/eval-format/python.ts +544 -0
  68. package/src/tools/eval-format/ruby.ts +380 -0
  69. package/src/tools/eval-render.ts +12 -6
  70. package/src/tools/resolve.ts +10 -1
  71. package/src/tools/todo.ts +58 -6
  72. package/src/web/search/index.ts +28 -5
  73. package/src/web/search/providers/anthropic.ts +62 -4
  74. package/src/web/search/providers/base.ts +16 -0
  75. package/src/web/search/providers/brave.ts +30 -3
  76. package/src/web/search/providers/codex.ts +14 -1
  77. package/src/web/search/providers/duckduckgo.ts +23 -1
  78. package/src/web/search/providers/ecosia.ts +5 -1
  79. package/src/web/search/providers/exa.ts +28 -1
  80. package/src/web/search/providers/firecrawl.ts +37 -3
  81. package/src/web/search/providers/gemini.ts +12 -2
  82. package/src/web/search/providers/google.ts +2 -1
  83. package/src/web/search/providers/jina.ts +31 -8
  84. package/src/web/search/providers/kagi.ts +10 -1
  85. package/src/web/search/providers/kimi.ts +16 -1
  86. package/src/web/search/providers/mojeek.ts +14 -1
  87. package/src/web/search/providers/parallel.ts +48 -2
  88. package/src/web/search/providers/perplexity.ts +94 -6
  89. package/src/web/search/providers/searxng.ts +145 -9
  90. package/src/web/search/providers/startpage.ts +8 -2
  91. package/src/web/search/providers/synthetic.ts +7 -1
  92. package/src/web/search/providers/tavily.ts +52 -3
  93. package/src/web/search/providers/tinyfish.ts +6 -1
  94. package/src/web/search/providers/xai.ts +49 -2
  95. package/src/web/search/providers/zai.ts +19 -1
  96. package/src/web/search/query.ts +850 -0
@@ -28,6 +28,7 @@ import type {
28
28
  SearchSource,
29
29
  } from "../../../web/search/types";
30
30
  import { SearchProviderError } from "../../../web/search/types";
31
+ import { formatQuery, parseSearchQuery, type QuerySyntax, type StructuredQuery } from "../query";
31
32
  import type { SearchParams } from "./base";
32
33
  import { SearchProvider } from "./base";
33
34
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -36,6 +37,59 @@ const DEFAULT_MODEL = "claude-haiku-4-5";
36
37
  const DEFAULT_MAX_TOKENS = 4096;
37
38
  const WEB_SEARCH_TOOL_NAME = "web_search";
38
39
  const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
40
+
41
+ /**
42
+ * Claude's search backend understands common Google-style operators, so most
43
+ * directives are re-emitted as query text. `site:` is intentionally absent:
44
+ * site includes/excludes map onto the web_search tool's native
45
+ * `allowed_domains`/`blocked_domains` parameters instead.
46
+ */
47
+ const ANTHROPIC_QUERY_SYNTAX: QuerySyntax = {
48
+ phrases: true,
49
+ negation: true,
50
+ or: true,
51
+ inUrl: true,
52
+ inTitle: true,
53
+ filetype: true,
54
+ dateRange: true,
55
+ };
56
+
57
+ /** Upstream request shape derived from the parsed query. */
58
+ interface AnthropicQueryPlan {
59
+ query: string;
60
+ allowedDomains?: string[];
61
+ blockedDomains?: string[];
62
+ }
63
+
64
+ /**
65
+ * Map parsed directives onto the request: `site:` includes become
66
+ * `allowed_domains`, `-site:` exclusions become `blocked_domains` (the two are
67
+ * mutually exclusive on the API, so exclusions are only sent when there are no
68
+ * includes), and remaining directives are re-emitted as query syntax.
69
+ * Directive-free queries pass through byte-identical. Anthropic domain
70
+ * filters take bare hosts (subdomains included automatically); any path part
71
+ * of a `site:` value is enforced by the central constraint filter.
72
+ */
73
+ function planQuery(rawQuery: string, parsed: StructuredQuery): AnthropicQueryPlan {
74
+ if (!parsed.hasDirectives) return { query: rawQuery };
75
+ const hosts = (sites: readonly string[]) => {
76
+ const unique = new Set<string>();
77
+ for (const site of sites) {
78
+ const slash = site.indexOf("/");
79
+ const host = slash === -1 ? site : site.slice(0, slash);
80
+ if (host.length > 0) unique.add(host);
81
+ }
82
+ return [...unique];
83
+ };
84
+ const allowed = hosts(parsed.sites);
85
+ const blocked = allowed.length === 0 ? hosts(parsed.excludedSites) : [];
86
+ return {
87
+ query: formatQuery(parsed, ANTHROPIC_QUERY_SYNTAX),
88
+ allowedDomains: allowed.length > 0 ? allowed : undefined,
89
+ blockedDomains: blocked.length > 0 ? blocked : undefined,
90
+ };
91
+ }
92
+
39
93
  export interface AnthropicSearchParams {
40
94
  query: string;
41
95
  system_prompt?: string;
@@ -82,7 +136,7 @@ function buildSystemBlocks(
82
136
  * Calls the Anthropic API with web search tool enabled.
83
137
  * @param auth - Authentication configuration (API key or OAuth)
84
138
  * @param model - Model identifier to use
85
- * @param query - Search query from the user
139
+ * @param plan - Query text plus native domain filters derived from parsed directives
86
140
  * @param metadataUserId - Optional Anthropic Messages metadata.user_id (already shaped for OAuth)
87
141
  * @param systemPrompt - Optional system prompt for guiding response style
88
142
  * @returns Raw API response from Anthropic
@@ -91,7 +145,7 @@ function buildSystemBlocks(
91
145
  async function callSearch(
92
146
  auth: AnthropicAuthConfig,
93
147
  model: string,
94
- query: string,
148
+ plan: AnthropicQueryPlan,
95
149
  metadataUserId?: string,
96
150
  systemPrompt?: string,
97
151
  maxTokens?: number,
@@ -107,11 +161,13 @@ async function callSearch(
107
161
  const body: Record<string, unknown> = {
108
162
  model,
109
163
  max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
110
- messages: [{ role: "user", content: query }],
164
+ messages: [{ role: "user", content: plan.query }],
111
165
  tools: [
112
166
  {
113
167
  type: WEB_SEARCH_TOOL_TYPE,
114
168
  name: WEB_SEARCH_TOOL_NAME,
169
+ ...(plan.allowedDomains ? { allowed_domains: plan.allowedDomains } : {}),
170
+ ...(plan.blockedDomains ? { blocked_domains: plan.blockedDomains } : {}),
115
171
  },
116
172
  ],
117
173
  };
@@ -283,6 +339,8 @@ export async function searchAnthropic(
283
339
  const callerSessionId = "authStorage" in params ? params.sessionId : undefined;
284
340
  const accountId =
285
341
  "authStorage" in params ? params.authStorage.getOAuthAccountId("anthropic", params.sessionId) : undefined;
342
+ const parsed = ("parsedQuery" in params ? params.parsedQuery : undefined) ?? parseSearchQuery(params.query);
343
+ const plan = planQuery(params.query, parsed);
286
344
  const response = await withAuth(
287
345
  keyOrResolver,
288
346
  key => {
@@ -302,7 +360,7 @@ export async function searchAnthropic(
302
360
  return callSearch(
303
361
  auth,
304
362
  model,
305
- params.query,
363
+ plan,
306
364
  metadataUserId,
307
365
  systemPrompt,
308
366
  maxTokens,
@@ -1,5 +1,6 @@
1
1
  import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
2
2
  import type { ModelRegistry } from "../../../config/model-registry";
3
+ import type { StructuredQuery } from "../query";
3
4
  import type { SearchProviderId, SearchResponse } from "../types";
4
5
 
5
6
  /**
@@ -14,6 +15,21 @@ import type { SearchProviderId, SearchResponse } from "../types";
14
15
  */
15
16
  export interface SearchParams {
16
17
  query: string;
18
+ /**
19
+ * Structured view of `query`, parsed once by the search pipeline:
20
+ * Google-style directives (`site:`, `before:`/`after:`, `inurl:`,
21
+ * `intitle:`, `filetype:`, quoted phrases, `OR` groups, `-exclusions`)
22
+ * extracted into fields.
23
+ *
24
+ * Providers SHOULD map constraints onto native API parameters
25
+ * (domain/date filters) or engine query syntax (`formatQuery`) where the
26
+ * upstream supports them, and lean lenient otherwise: the pipeline
27
+ * post-filters every response with `applyQueryConstraints`, which
28
+ * relaxes any constraint that would eliminate all results — so a
29
+ * best-effort search always beats an empty one. When absent (direct
30
+ * provider calls), parse with `parseSearchQuery(params.query)`.
31
+ */
32
+ parsedQuery?: StructuredQuery;
17
33
  limit?: number;
18
34
  /**
19
35
  * Temporal filter narrowing results to the specified time window.
@@ -7,6 +7,8 @@
7
7
  import { type AuthStorage, type FetchImpl, getEnvApiKey } 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 type { QuerySyntax, StructuredQuery } from "../query";
11
+ import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
10
12
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
13
  import type { SearchParams } from "./base";
12
14
  import { SearchProvider } from "./base";
@@ -23,10 +25,32 @@ const RECENCY_MAP: Record<"day" | "week" | "month" | "year", "pd" | "pw" | "pm"
23
25
  year: "py",
24
26
  };
25
27
 
28
+ /**
29
+ * Brave parses the classic operator set inline (site:, quotes, -, OR…) but
30
+ * date bounds map onto the native `freshness` param, so `before:`/`after:`
31
+ * tokens are stripped from the rebuilt query string.
32
+ */
33
+ const BRAVE_QUERY_SYNTAX: QuerySyntax = { ...GOOGLE_QUERY_SYNTAX, dateRange: false };
34
+
35
+ /**
36
+ * Freshness param: explicit `after:`/`before:` bounds win over the
37
+ * recency-derived period, rendered as Brave's absolute range
38
+ * `YYYY-MM-DDtoYYYY-MM-DD` with sensible open ends.
39
+ */
40
+ function braveFreshness(parsed: StructuredQuery, recency?: keyof typeof RECENCY_MAP): string | undefined {
41
+ if (parsed.after || parsed.before) {
42
+ const start = parsed.after ?? "1970-01-01";
43
+ const end = parsed.before ?? new Date().toISOString().slice(0, 10);
44
+ return `${start}to${end}`;
45
+ }
46
+ return recency ? RECENCY_MAP[recency] : undefined;
47
+ }
48
+
26
49
  export interface BraveSearchParams {
27
50
  query: string;
28
51
  num_results?: number;
29
52
  recency?: "day" | "week" | "month" | "year";
53
+ parsedQuery?: StructuredQuery;
30
54
  signal?: AbortSignal;
31
55
  fetch?: FetchImpl;
32
56
  }
@@ -73,12 +97,14 @@ async function callBraveSearch(
73
97
  params: BraveSearchParams,
74
98
  ): Promise<{ response: BraveSearchResponse; requestId?: string }> {
75
99
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
100
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
76
101
  const url = new URL(BRAVE_SEARCH_URL);
77
- url.searchParams.set("q", params.query);
102
+ url.searchParams.set("q", parsed.hasDirectives ? formatQuery(parsed, BRAVE_QUERY_SYNTAX) : params.query);
78
103
  url.searchParams.set("count", String(numResults));
79
104
  url.searchParams.set("extra_snippets", "true");
80
- if (params.recency) {
81
- url.searchParams.set("freshness", RECENCY_MAP[params.recency]);
105
+ const freshness = braveFreshness(parsed, params.recency);
106
+ if (freshness) {
107
+ url.searchParams.set("freshness", freshness);
82
108
  }
83
109
 
84
110
  const fetchImpl = params.fetch ?? fetch;
@@ -145,6 +171,7 @@ export class BraveProvider extends SearchProvider {
145
171
  query: params.query,
146
172
  num_results: params.numSearchResults ?? params.limit,
147
173
  recency: params.recency,
174
+ parsedQuery: params.parsedQuery,
148
175
  signal: params.signal,
149
176
  fetch: params.fetch,
150
177
  });
@@ -31,6 +31,7 @@ import packageJson from "../../../../package.json" with { type: "json" };
31
31
  import type { ModelRegistry } from "../../../config/model-registry";
32
32
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
33
33
  import { SearchProviderError } from "../../../web/search/types";
34
+ import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
34
35
  import type { SearchParams } from "./base";
35
36
  import { SearchProvider } from "./base";
36
37
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -572,6 +573,7 @@ async function callCodexSearch(
572
573
  async function runCodexSearchCandidates(options: {
573
574
  auth: { accessToken: string; accountId?: string };
574
575
  params: SearchParams;
576
+ query: string;
575
577
  modelCandidates: CodexModelCandidate[];
576
578
  modelWasConfigured: boolean;
577
579
  transport: CodexSearchTransport;
@@ -582,7 +584,7 @@ async function runCodexSearchCandidates(options: {
582
584
  if (!candidate) continue;
583
585
 
584
586
  try {
585
- return await callCodexSearch(options.auth, options.params.query, {
587
+ return await callCodexSearch(options.auth, options.query, {
586
588
  signal: options.params.signal,
587
589
  systemPrompt: options.params.systemPrompt,
588
590
  searchContextSize: "high",
@@ -622,6 +624,15 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
622
624
  throw new SearchProviderError("codex", "No Codex web search model is configured.");
623
625
  }
624
626
  const transport = resolveCodexSearchTransport(params.modelRegistry, firstCandidate.modelId);
627
+ // The ChatGPT-backend Codex endpoint speaks the undocumented codex-rs
628
+ // request shape (responses-lite moves tools into an `additional_tools`
629
+ // developer item), so the documented `web_search.filters.allowed_domains`
630
+ // parameter cannot be assumed to survive it. Instead, re-emit directive
631
+ // queries with the full Google-style operator syntax — the backing index
632
+ // parses the classic operator set — and leave directive-free queries
633
+ // byte-identical.
634
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
635
+ const query = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
625
636
 
626
637
  let result: CodexSearchResult;
627
638
  if (transport.customEndpoint) {
@@ -652,6 +663,7 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
652
663
  runCodexSearchCandidates({
653
664
  auth: { accessToken },
654
665
  params,
666
+ query,
655
667
  modelCandidates,
656
668
  modelWasConfigured: configuredModel !== undefined,
657
669
  transport,
@@ -682,6 +694,7 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
682
694
  return runCodexSearchCandidates({
683
695
  auth: { accessToken: access.accessToken, accountId },
684
696
  params,
697
+ query,
685
698
  modelCandidates,
686
699
  modelWasConfigured: configuredModel !== undefined,
687
700
  transport,
@@ -1,6 +1,8 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
3
3
  import { SearchProviderError } from "../../../web/search/types";
4
+ import type { QuerySyntax } from "../query";
5
+ import { formatScraperQuery } from "../query";
4
6
  import { clampNumResults } from "../utils";
5
7
  import type { SearchParams } from "./base";
6
8
  import { SearchProvider } from "./base";
@@ -118,8 +120,28 @@ function isAnomalyResponse(html: string): boolean {
118
120
  return html.includes("anomaly-modal") || html.includes("anomaly.js");
119
121
  }
120
122
 
123
+ /**
124
+ * Query syntax the DDG HTML frontend parses: quotes, `-`, OR, site:,
125
+ * filetype:, intitle:, inurl:, intext:. Date bounds (`before:`/`after:`) are
126
+ * deliberately off — DDG does not parse them, so they are stripped from the
127
+ * query and enforced by the pipeline's lenient post-filter instead.
128
+ */
129
+ const DDG_QUERY_SYNTAX: QuerySyntax = {
130
+ phrases: true,
131
+ negation: true,
132
+ or: true,
133
+ site: true,
134
+ inUrl: true,
135
+ inTitle: true,
136
+ inText: true,
137
+ filetype: true,
138
+ };
139
+
121
140
  async function callDuckDuckGoHtml(params: SearchParams): Promise<string> {
122
- const form = new URLSearchParams({ q: params.query, kl: "us-en" });
141
+ const form = new URLSearchParams({
142
+ q: formatScraperQuery(params.query, params.parsedQuery, DDG_QUERY_SYNTAX),
143
+ kl: "us-en",
144
+ });
123
145
  const df = params.recency ? RECENCY_TO_DDG_DF[params.recency] : undefined;
124
146
  if (df) form.set("df", df);
125
147
  // Add b: "" parameter as specified in the browser fetch template to match real browser form submission
@@ -2,6 +2,7 @@ import type { AuthStorage } 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";
@@ -100,7 +101,10 @@ function isBlockedPage(page: LoadedHtmlPage): boolean {
100
101
  async function callEcosiaHtml(params: SearchParams): Promise<string> {
101
102
  const signal = withHardTimeout(params.signal);
102
103
  const url = new URL(ECOSIA_SEARCH_URL);
103
- url.searchParams.set("q", params.query);
104
+ // Ecosia serves Google-backed results, so classic operators pass through
105
+ // inline; canonicalize aliases (domain: -> site:, since: -> after:) and
106
+ // demote scraper-hostile operators via the shared scraper formatter.
107
+ url.searchParams.set("q", formatScraperQuery(params.query, params.parsedQuery));
104
108
 
105
109
  let page: LoadedHtmlPage;
106
110
  try {
@@ -12,6 +12,7 @@ import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
12
  import { parseSSE } from "../../../mcp/json-rpc";
13
13
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
14
14
  import { SearchProviderError } from "../../../web/search/types";
15
+ import { formatQuery, parseSearchQuery, type StructuredQuery } from "../query";
15
16
  import { dateToAgeSeconds } from "../utils";
16
17
  import type { SearchParams } from "./base";
17
18
  import { SearchProvider } from "./base";
@@ -472,8 +473,9 @@ export class ExaProvider extends SearchProvider {
472
473
  }
473
474
 
474
475
  search(params: SearchParams): Promise<SearchResponse> {
476
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
475
477
  return searchExa({
476
- query: params.query,
478
+ ...directiveParams(parsed),
477
479
  num_results: params.numSearchResults ?? params.limit,
478
480
  signal: params.signal,
479
481
  authStorage: params.authStorage,
@@ -482,3 +484,28 @@ export class ExaProvider extends SearchProvider {
482
484
  });
483
485
  }
484
486
  }
487
+
488
+ /**
489
+ * Map parsed query directives onto Exa's native request parameters:
490
+ * `site:` → includeDomains, `-site:` → excludeDomains (bare hosts; path parts
491
+ * are enforced by the central constraint filter), `after:`/`before:` →
492
+ * start/endPublishedDate (ISO 8601). Exa's neural search prefers natural
493
+ * language, so the query itself is re-emitted with quoted phrases only.
494
+ * Directive-free queries pass through byte-identical.
495
+ */
496
+ function directiveParams(
497
+ parsed: StructuredQuery,
498
+ ): Pick<
499
+ ExaSearchParams,
500
+ "query" | "include_domains" | "exclude_domains" | "start_published_date" | "end_published_date"
501
+ > {
502
+ if (!parsed.hasDirectives) return { query: parsed.raw };
503
+ const hosts = (sites: readonly string[]) => [...new Set(sites.map(site => site.split("/", 1)[0]))];
504
+ return {
505
+ query: formatQuery(parsed, { phrases: true }),
506
+ include_domains: parsed.sites.length ? hosts(parsed.sites) : undefined,
507
+ exclude_domains: parsed.excludedSites.length ? hosts(parsed.excludedSites) : undefined,
508
+ start_published_date: parsed.after,
509
+ end_published_date: parsed.before,
510
+ };
511
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from "@oh-my-pi/pi-ai";
15
15
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
16
16
  import { SearchProviderError } from "../../../web/search/types";
17
+ import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery, type StructuredQuery } from "../query";
17
18
  import { clampNumResults } from "../utils";
18
19
  import type { SearchParams } from "./base";
19
20
  import { SearchProvider } from "./base";
@@ -34,6 +35,8 @@ export interface FirecrawlSearchParams {
34
35
  query: string;
35
36
  num_results?: number;
36
37
  recency?: SearchParams["recency"];
38
+ /** Explicit `tbs` (custom date range); takes precedence over `recency`. */
39
+ tbs?: string;
37
40
  signal?: AbortSignal;
38
41
  fetch?: FetchImpl;
39
42
  }
@@ -67,8 +70,9 @@ function buildRequestBody(params: FirecrawlSearchParams): Record<string, unknown
67
70
  limit: clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS),
68
71
  sources: [{ type: "web" }],
69
72
  };
70
- if (params.recency) {
71
- body.tbs = RECENCY_TBS[params.recency];
73
+ const tbs = params.tbs ?? (params.recency ? RECENCY_TBS[params.recency] : undefined);
74
+ if (tbs) {
75
+ body.tbs = tbs;
72
76
  }
73
77
  return body;
74
78
  }
@@ -104,12 +108,42 @@ async function callFirecrawlSearch(
104
108
  return (await response.json()) as FirecrawlSearchResponse;
105
109
  }
106
110
 
111
+ /** ISO `YYYY-MM-DD` to Google `MM/DD/YYYY` for `tbs=cdr` custom date ranges. */
112
+ function toGoogleDate(iso: string): string {
113
+ const [year, month, day] = iso.split("-");
114
+ return `${month}/${day}/${year}`;
115
+ }
116
+
117
+ /**
118
+ * Map explicit `before:`/`after:` bounds to a Firecrawl `tbs` custom date
119
+ * range (`cdr:1,cd_min:MM/DD/YYYY,cd_max:MM/DD/YYYY`), or undefined when the
120
+ * query carries no absolute date bounds.
121
+ */
122
+ function buildDateTbs(parsed: StructuredQuery): string | undefined {
123
+ if (!parsed.after && !parsed.before) return undefined;
124
+ const parts = ["cdr:1"];
125
+ if (parsed.after) parts.push(`cd_min:${toGoogleDate(parsed.after)}`);
126
+ if (parsed.before) parts.push(`cd_max:${toGoogleDate(parsed.before)}`);
127
+ return parts.join(",");
128
+ }
129
+
107
130
  /** Execute Firecrawl web search. */
108
131
  export async function searchFirecrawl(params: SearchParams): Promise<SearchResponse> {
132
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
133
+ let query = params.query;
134
+ let tbs: string | undefined;
135
+ if (parsed.hasDirectives) {
136
+ // Firecrawl search is SERP-backed: the query supports Google operators
137
+ // (site:, inurl:, intitle:, quotes, -, OR). Absolute date bounds move to
138
+ // the native tbs param and are stripped from the query string.
139
+ tbs = buildDateTbs(parsed);
140
+ query = formatQuery(parsed, tbs ? { ...GOOGLE_QUERY_SYNTAX, dateRange: false } : GOOGLE_QUERY_SYNTAX);
141
+ }
109
142
  const firecrawlParams: FirecrawlSearchParams = {
110
- query: params.query,
143
+ query,
111
144
  num_results: params.numSearchResults ?? params.limit,
112
145
  recency: params.recency,
146
+ tbs,
113
147
  signal: params.signal,
114
148
  fetch: params.fetch,
115
149
  };
@@ -18,6 +18,7 @@ import { fetchWithRetry } from "@oh-my-pi/pi-utils";
18
18
 
19
19
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
20
20
  import { SearchProviderError } from "../../../web/search/types";
21
+ import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery, type StructuredQuery } from "../query";
21
22
  import type { SearchParams } from "./base";
22
23
  import { SearchProvider } from "./base";
23
24
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
@@ -51,6 +52,8 @@ interface GeminiToolParams {
51
52
 
52
53
  export interface GeminiSearchParams extends GeminiToolParams {
53
54
  query: string;
55
+ /** Pre-parsed structured query; falls back to parsing `query` when omitted. */
56
+ parsedQuery?: StructuredQuery;
54
57
  system_prompt?: string;
55
58
  num_results?: number;
56
59
  /** Maximum output tokens. */
@@ -508,6 +511,12 @@ async function callGeminiDeveloperSearch(
508
511
  */
509
512
  export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
510
513
  const selectedModel = resolveGeminiSearchModel(params.geminiModel);
514
+ // Gemini's googleSearch grounding forwards the query to Google Search, which
515
+ // understands the classic operator set natively. Normalize directive aliases
516
+ // (domain: → site:, since: → after:, …) to canonical Google forms; leave
517
+ // directive-free queries byte-identical.
518
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
519
+ const searchQuery = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
511
520
  const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
512
521
  let result: GeminiSearchResult;
513
522
 
@@ -528,7 +537,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
528
537
  isAntigravity,
529
538
  },
530
539
  selectedModel,
531
- params.query,
540
+ searchQuery,
532
541
  params.system_prompt,
533
542
  params.max_output_tokens,
534
543
  params.temperature,
@@ -555,7 +564,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
555
564
  result = await callGeminiDeveloperSearch(
556
565
  apiKey,
557
566
  selectedModel,
558
- params.query,
567
+ searchQuery,
559
568
  params.system_prompt,
560
569
  params.max_output_tokens,
561
570
  params.temperature,
@@ -601,6 +610,7 @@ export class GeminiProvider extends SearchProvider {
601
610
  search(params: SearchParams): Promise<SearchResponse> {
602
611
  return searchGemini({
603
612
  query: params.query,
613
+ parsedQuery: params.parsedQuery,
604
614
  system_prompt: params.systemPrompt,
605
615
  num_results: params.numSearchResults ?? params.limit,
606
616
  max_output_tokens: params.maxOutputTokens,
@@ -2,6 +2,7 @@ import type { AuthStorage } 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";
@@ -91,7 +92,7 @@ function parseHtmlResults(html: string): ParsedResult[] {
91
92
 
92
93
  function buildSearchUrl(params: SearchParams, numResults: number): string {
93
94
  const url = new URL(GOOGLE_SEARCH_URL);
94
- url.searchParams.set("q", params.query);
95
+ url.searchParams.set("q", formatScraperQuery(params.query, params.parsedQuery));
95
96
  url.searchParams.set("num", String(numResults));
96
97
  url.searchParams.set("hl", "en");
97
98
  url.searchParams.set("gl", "us");
@@ -8,6 +8,7 @@
8
8
  import { type AuthStorage, type FetchImpl, getEnvApiKey } 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";
@@ -18,6 +19,8 @@ type SearchParamsWithFetch = SearchParams & { fetch?: FetchImpl };
18
19
  export interface JinaSearchParams {
19
20
  query: string;
20
21
  num_results?: number;
22
+ /** Single bare host for Jina's `X-Site` in-site search header. */
23
+ site?: string;
21
24
  signal?: AbortSignal;
22
25
  fetch?: FetchImpl;
23
26
  }
@@ -39,15 +42,18 @@ export function findApiKey(): string | null {
39
42
  async function callJinaSearch(
40
43
  apiKey: string,
41
44
  query: string,
45
+ site?: string,
42
46
  signal?: AbortSignal,
43
47
  fetchImpl: FetchImpl = fetch,
44
48
  ): Promise<JinaSearchResponse> {
45
49
  const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
50
+ const headers: Record<string, string> = {
51
+ Accept: "application/json",
52
+ Authorization: `Bearer ${apiKey}`,
53
+ };
54
+ if (site) headers["X-Site"] = site;
46
55
  const response = await fetchImpl(requestUrl, {
47
- headers: {
48
- Accept: "application/json",
49
- Authorization: `Bearer ${apiKey}`,
50
- },
56
+ headers,
51
57
  signal: withHardTimeout(signal),
52
58
  });
53
59
 
@@ -69,7 +75,7 @@ export async function searchJina(params: JinaSearchParams): Promise<SearchRespon
69
75
  throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
70
76
  }
71
77
 
72
- const response = await callJinaSearch(apiKey, params.query, params.signal, params.fetch);
78
+ const response = await callJinaSearch(apiKey, params.query, params.site, params.signal, params.fetch);
73
79
  const sources: SearchSource[] = [];
74
80
 
75
81
  for (const result of response) {
@@ -99,13 +105,30 @@ export class JinaProvider extends SearchProvider {
99
105
  }
100
106
 
101
107
  search(params: SearchParamsWithFetch): Promise<SearchResponse> {
102
- const fetchImpl = params.fetch;
108
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
109
+ let query = params.query;
110
+ let site: string | undefined;
111
+ if (parsed.hasDirectives) {
112
+ // Jina's X-Site header takes a single domain; with exactly one
113
+ // include site, send its host there and strip site: tokens from
114
+ // the query. Multiple sites stay inline (Bing-backed, parses them).
115
+ if (parsed.sites.length === 1) site = parsed.sites[0]!.split("/")[0];
116
+ query = formatQuery(parsed, {
117
+ phrases: true,
118
+ negation: true,
119
+ site: !site,
120
+ inTitle: true,
121
+ inUrl: true,
122
+ filetype: true,
123
+ });
124
+ }
103
125
 
104
126
  return searchJina({
105
- query: params.query,
127
+ query,
106
128
  num_results: params.numSearchResults ?? params.limit,
129
+ site,
107
130
  signal: params.signal,
108
- fetch: fetchImpl,
131
+ fetch: params.fetch,
109
132
  });
110
133
  }
111
134
  }
@@ -7,6 +7,8 @@ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
7
7
  import type { SearchResponse } from "../../../web/search/types";
8
8
  import { SearchProviderError } from "../../../web/search/types";
9
9
  import { KagiApiError, searchWithKagi } from "../../kagi";
10
+ import type { StructuredQuery } from "../query";
11
+ import { formatQuery, GOOGLE_QUERY_SYNTAX, parseSearchQuery } from "../query";
10
12
  import { clampNumResults } from "../utils";
11
13
  import type { SearchParams } from "./base";
12
14
  import { SearchProvider } from "./base";
@@ -22,16 +24,22 @@ export async function searchKagi(params: {
22
24
  query: string;
23
25
  num_results?: number;
24
26
  recency?: SearchParams["recency"];
27
+ parsedQuery?: StructuredQuery;
25
28
  signal?: AbortSignal;
26
29
  authStorage: AuthStorage;
27
30
  sessionId?: string;
28
31
  fetch?: FetchImpl;
29
32
  }): Promise<SearchResponse> {
30
33
  const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
34
+ // Kagi's index understands the classic Google operator set: canonicalize
35
+ // directives (domain: -> site:, until: -> before:YYYY-MM-DD, ...) and pass
36
+ // them through in the query string. Directive-free queries stay untouched.
37
+ const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
38
+ const query = parsed.hasDirectives ? formatQuery(parsed, GOOGLE_QUERY_SYNTAX) : params.query;
31
39
 
32
40
  try {
33
41
  const result = await searchWithKagi(
34
- params.query,
42
+ query,
35
43
  {
36
44
  limit: numResults,
37
45
  recency: params.recency,
@@ -75,6 +83,7 @@ export class KagiProvider extends SearchProvider {
75
83
 
76
84
  return searchKagi({
77
85
  query: params.query,
86
+ parsedQuery: params.parsedQuery,
78
87
  num_results: params.numSearchResults ?? params.limit,
79
88
  recency: params.recency,
80
89
  signal: params.signal,