@oh-my-pi/pi-coding-agent 17.1.0 → 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 (196) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/cli.js +6325 -4420
  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 +62 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  7. package/dist/types/launch/broker-list-order.test.d.ts +1 -0
  8. package/dist/types/live/attestation.d.ts +2 -0
  9. package/dist/types/live/controller.d.ts +10 -2
  10. package/dist/types/live/protocol.d.ts +1 -1
  11. package/dist/types/live/transport.d.ts +6 -19
  12. package/dist/types/live/visualizer.d.ts +8 -11
  13. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  14. package/dist/types/modes/components/custom-message.d.ts +1 -1
  15. package/dist/types/modes/components/message-frame.d.ts +8 -4
  16. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  18. package/dist/types/modes/interactive-mode.d.ts +3 -6
  19. package/dist/types/modes/types.d.ts +9 -5
  20. package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
  21. package/dist/types/session/agent-session-types.d.ts +8 -1
  22. package/dist/types/session/agent-session.d.ts +20 -1
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/eval-runner.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +2 -0
  26. package/dist/types/session/session-tools.d.ts +15 -0
  27. package/dist/types/session/streaming-output.d.ts +8 -0
  28. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  29. package/dist/types/stt/index.d.ts +0 -2
  30. package/dist/types/stt/stt-controller.d.ts +7 -0
  31. package/dist/types/task/executor.d.ts +3 -1
  32. package/dist/types/task/structured-subagent.d.ts +3 -0
  33. package/dist/types/task/types.d.ts +11 -11
  34. package/dist/types/thinking.d.ts +13 -0
  35. package/dist/types/tiny/title-client.d.ts +10 -0
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/computer/protocol.d.ts +43 -0
  38. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  39. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  40. package/dist/types/tools/computer/worker.d.ts +15 -0
  41. package/dist/types/tools/computer-renderer.d.ts +22 -0
  42. package/dist/types/tools/computer.d.ts +71 -0
  43. package/dist/types/tools/context.d.ts +2 -0
  44. package/dist/types/tools/default-renderer.d.ts +21 -0
  45. package/dist/types/tools/eval-format/index.d.ts +7 -0
  46. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  47. package/dist/types/tools/eval-format/julia.d.ts +2 -0
  48. package/dist/types/tools/eval-format/python.d.ts +2 -0
  49. package/dist/types/tools/eval-format/ruby.d.ts +2 -0
  50. package/dist/types/tools/index.d.ts +2 -0
  51. package/dist/types/tools/resolve.d.ts +7 -0
  52. package/dist/types/tools/todo.d.ts +4 -1
  53. package/dist/types/tts/streaming-player.d.ts +10 -43
  54. package/dist/types/utils/tools-manager.d.ts +1 -2
  55. package/dist/types/web/search/providers/base.d.ts +16 -0
  56. package/dist/types/web/search/providers/brave.d.ts +2 -0
  57. package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
  58. package/dist/types/web/search/providers/gemini.d.ts +3 -0
  59. package/dist/types/web/search/providers/jina.d.ts +2 -0
  60. package/dist/types/web/search/providers/kagi.d.ts +2 -0
  61. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  62. package/dist/types/web/search/providers/parallel.d.ts +2 -0
  63. package/dist/types/web/search/providers/perplexity.d.ts +3 -0
  64. package/dist/types/web/search/providers/searxng.d.ts +10 -0
  65. package/dist/types/web/search/providers/tavily.d.ts +8 -0
  66. package/dist/types/web/search/query.d.ts +190 -0
  67. package/package.json +12 -12
  68. package/src/cli/args.ts +1 -0
  69. package/src/cli/bench-cli.ts +12 -1
  70. package/src/cli/setup-cli.ts +2 -14
  71. package/src/cli/web-search-cli.ts +7 -0
  72. package/src/cli.ts +8 -0
  73. package/src/config/model-registry.ts +6 -0
  74. package/src/config/model-resolver.ts +17 -6
  75. package/src/config/settings-schema.ts +66 -0
  76. package/src/debug/raw-sse-buffer.ts +157 -30
  77. package/src/eval/executor-base.ts +1 -0
  78. package/src/eval/js/executor.ts +2 -0
  79. package/src/exec/bash-executor.ts +1 -0
  80. package/src/export/share.ts +4 -3
  81. package/src/extensibility/extensions/wrapper.ts +68 -12
  82. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  83. package/src/launch/broker-list-order.test.ts +89 -0
  84. package/src/launch/broker.ts +49 -8
  85. package/src/live/attestation.ts +91 -0
  86. package/src/live/controller.ts +76 -23
  87. package/src/live/protocol.test.ts +3 -3
  88. package/src/live/protocol.ts +1 -1
  89. package/src/live/transport.ts +72 -140
  90. package/src/live/visualizer.ts +114 -134
  91. package/src/modes/components/assistant-message.ts +7 -2
  92. package/src/modes/components/custom-message.ts +4 -1
  93. package/src/modes/components/message-frame.ts +14 -8
  94. package/src/modes/components/session-account-selector.ts +62 -0
  95. package/src/modes/components/tool-execution.ts +17 -110
  96. package/src/modes/controllers/input-controller.ts +55 -47
  97. package/src/modes/controllers/live-command-controller.ts +82 -5
  98. package/src/modes/controllers/selector-controller.ts +62 -0
  99. package/src/modes/interactive-mode.ts +79 -5
  100. package/src/modes/types.ts +10 -4
  101. package/src/modes/utils/ui-helpers.ts +7 -8
  102. package/src/prompts/system/computer-safety.md +14 -0
  103. package/src/prompts/system/resolve-device-reminder.md +1 -1
  104. package/src/prompts/system/workflow-notice.md +1 -1
  105. package/src/prompts/tools/ast-edit.md +1 -0
  106. package/src/prompts/tools/bash.md +1 -0
  107. package/src/prompts/tools/computer.md +26 -0
  108. package/src/prompts/tools/eval.md +2 -2
  109. package/src/prompts/tools/task.md +5 -2
  110. package/src/prompts/tools/web-search.md +2 -0
  111. package/src/sdk.ts +13 -4
  112. package/src/session/agent-session-types.ts +9 -0
  113. package/src/session/agent-session.ts +70 -0
  114. package/src/session/auth-storage.ts +1 -0
  115. package/src/session/eval-runner.ts +5 -0
  116. package/src/session/messages.ts +3 -0
  117. package/src/session/queued-messages.ts +7 -1
  118. package/src/session/session-tools.ts +37 -0
  119. package/src/session/streaming-output.ts +52 -5
  120. package/src/slash-commands/builtin-registry.ts +165 -9
  121. package/src/slash-commands/helpers/session-pin.ts +44 -0
  122. package/src/stt/downloader.ts +0 -2
  123. package/src/stt/index.ts +0 -2
  124. package/src/stt/stt-controller.ts +57 -146
  125. package/src/system-prompt.ts +4 -0
  126. package/src/task/executor.ts +17 -9
  127. package/src/task/index.ts +14 -34
  128. package/src/task/structured-subagent.ts +4 -0
  129. package/src/task/types.ts +15 -13
  130. package/src/thinking.ts +27 -0
  131. package/src/tiny/title-client.ts +22 -0
  132. package/src/tools/ast-edit.ts +4 -1
  133. package/src/tools/bash-interactive.ts +90 -86
  134. package/src/tools/bash.ts +13 -0
  135. package/src/tools/browser/render.ts +2 -1
  136. package/src/tools/builtin-names.ts +1 -0
  137. package/src/tools/computer/protocol.ts +28 -0
  138. package/src/tools/computer/supervisor.ts +258 -0
  139. package/src/tools/computer/worker-entry.ts +25 -0
  140. package/src/tools/computer/worker.ts +135 -0
  141. package/src/tools/computer-renderer.ts +108 -0
  142. package/src/tools/computer.ts +433 -0
  143. package/src/tools/context.ts +2 -0
  144. package/src/tools/default-renderer.ts +139 -0
  145. package/src/tools/essential-tools.ts +1 -0
  146. package/src/tools/eval-format/index.ts +24 -0
  147. package/src/tools/eval-format/javascript.ts +952 -0
  148. package/src/tools/eval-format/julia.ts +446 -0
  149. package/src/tools/eval-format/python.ts +544 -0
  150. package/src/tools/eval-format/ruby.ts +380 -0
  151. package/src/tools/eval-render.ts +12 -6
  152. package/src/tools/index.ts +5 -0
  153. package/src/tools/renderers.ts +2 -0
  154. package/src/tools/resolve.ts +10 -1
  155. package/src/tools/todo.ts +58 -6
  156. package/src/tools/xdev.ts +54 -26
  157. package/src/tts/streaming-player.ts +81 -340
  158. package/src/utils/clipboard.ts +1 -30
  159. package/src/utils/mac-file-urls.applescript +37 -0
  160. package/src/utils/tool-choice.ts +14 -0
  161. package/src/utils/tools-manager.ts +1 -19
  162. package/src/web/search/index.ts +28 -5
  163. package/src/web/search/providers/anthropic.ts +62 -4
  164. package/src/web/search/providers/base.ts +16 -0
  165. package/src/web/search/providers/brave.ts +30 -3
  166. package/src/web/search/providers/codex.ts +14 -1
  167. package/src/web/search/providers/duckduckgo.ts +23 -1
  168. package/src/web/search/providers/ecosia.ts +5 -1
  169. package/src/web/search/providers/exa.ts +28 -1
  170. package/src/web/search/providers/firecrawl.ts +37 -3
  171. package/src/web/search/providers/gemini.ts +12 -2
  172. package/src/web/search/providers/google.ts +2 -1
  173. package/src/web/search/providers/jina.ts +31 -8
  174. package/src/web/search/providers/kagi.ts +10 -1
  175. package/src/web/search/providers/kimi.ts +16 -1
  176. package/src/web/search/providers/mojeek.ts +14 -1
  177. package/src/web/search/providers/parallel.ts +48 -2
  178. package/src/web/search/providers/perplexity.ts +94 -6
  179. package/src/web/search/providers/searxng.ts +145 -9
  180. package/src/web/search/providers/startpage.ts +8 -2
  181. package/src/web/search/providers/synthetic.ts +7 -1
  182. package/src/web/search/providers/tavily.ts +52 -3
  183. package/src/web/search/providers/tinyfish.ts +6 -1
  184. package/src/web/search/providers/xai.ts +49 -2
  185. package/src/web/search/providers/zai.ts +19 -1
  186. package/src/web/search/query.ts +850 -0
  187. package/dist/types/stt/recorder.d.ts +0 -30
  188. package/dist/types/stt/transcriber.d.ts +0 -14
  189. package/dist/types/stt/wav.d.ts +0 -29
  190. package/dist/types/tts/player.d.ts +0 -32
  191. package/src/live/audio-worklet.txt +0 -59
  192. package/src/live/browser-runtime.txt +0 -221
  193. package/src/stt/recorder.ts +0 -551
  194. package/src/stt/transcriber.ts +0 -60
  195. package/src/stt/wav.ts +0 -173
  196. package/src/tts/player.ts +0 -137
@@ -12,6 +12,19 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
12
12
  return { type: "tool", name: toolName };
13
13
  }
14
14
 
15
+ if (toolName === "computer" && model.supportsComputerUse === true) {
16
+ if (
17
+ model.api === "openai-codex-responses" ||
18
+ model.api === "openai-responses" ||
19
+ model.api === "azure-openai-responses"
20
+ ) {
21
+ return { type: "computer" };
22
+ }
23
+ return undefined;
24
+ }
25
+ // Models without native computer support receive the tool as a regular
26
+ // function tool, so the generic named-function forcing below applies.
27
+
15
28
  if (
16
29
  model.api === "openai-codex-responses" ||
17
30
  model.api === "openai-responses" ||
@@ -39,6 +52,7 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
39
52
  */
40
53
  export function isToolChoiceActive(toolChoice: ToolChoice | undefined, tools: readonly { name: string }[]): boolean {
41
54
  if (!toolChoice || typeof toolChoice === "string") return true;
55
+ if (toolChoice.type === "computer") return tools.some(tool => tool.name === "computer");
42
56
  const name =
43
57
  toolChoice.type === "tool"
44
58
  ? toolChoice.name
@@ -73,16 +73,6 @@ interface ToolConfig {
73
73
  getAssetName: (version: string, plat: string, architecture: string) => string | null;
74
74
  }
75
75
 
76
- // ffmpeg static-binary asset names (eugeneware/ffmpeg-static direct binaries).
77
- // Maps node arch (arm64|x64) only; everything else is unsupported.
78
- export function ffmpegAssetName(_version: string, plat: string, architecture: string): string | null {
79
- if (architecture !== "arm64" && architecture !== "x64") return null;
80
- if (plat === "darwin") return `ffmpeg-darwin-${architecture}`;
81
- if (plat === "linux") return `ffmpeg-linux-${architecture}`;
82
- if (plat === "win32") return architecture === "x64" ? "ffmpeg-win32-x64" : null;
83
- return null;
84
- }
85
-
86
76
  const TOOLS: Record<string, ToolConfig> = {
87
77
  sd: {
88
78
  name: "sd",
@@ -139,14 +129,6 @@ const TOOLS: Record<string, ToolConfig> = {
139
129
  return null;
140
130
  },
141
131
  },
142
- ffmpeg: {
143
- name: "ffmpeg",
144
- repo: "eugeneware/ffmpeg-static",
145
- binaryName: "ffmpeg",
146
- tagPrefix: "",
147
- isDirectBinary: true,
148
- getAssetName: ffmpegAssetName,
149
- },
150
132
  };
151
133
 
152
134
  // CLI packages installed via uv/pip
@@ -164,7 +146,7 @@ const PYTHON_TOOLS: Record<string, PythonPackageToolConfig> = {
164
146
  },
165
147
  };
166
148
 
167
- export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura" | "ffmpeg";
149
+ export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura";
168
150
 
169
151
  // Get the path to a tool (system-wide or in our tools dir)
170
152
  export function getToolPath(tool: ToolName): string | null {
@@ -27,6 +27,7 @@ import {
27
27
  type SearchProvider,
28
28
  type SearchProviderCandidate,
29
29
  } from "./provider";
30
+ import { applyQueryConstraints, parseSearchQuery } from "./query";
30
31
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
31
32
  import type { SearchProviderId, SearchResponse } from "./types";
32
33
  import { SearchProviderError } from "./types";
@@ -57,9 +58,12 @@ function formatCount(label: string, count: number): string {
57
58
  return `${count} ${label}${count === 1 ? "" : "s"}`;
58
59
  }
59
60
 
60
- /** Format response for LLM consumption */
61
- function formatForLLM(response: SearchResponse): string {
61
+ /** Format response for LLM consumption. `notes` lead the output (e.g. relaxed-constraint warnings). */
62
+ function formatForLLM(response: SearchResponse, notes: readonly string[] = []): string {
62
63
  const parts: string[] = [];
64
+ for (const note of notes) {
65
+ parts.push(`Note: ${note}`);
66
+ }
63
67
 
64
68
  if (response.answer) {
65
69
  parts.push(response.answer);
@@ -141,6 +145,8 @@ async function executeSearch(
141
145
  candidates = resolveProviderCandidates();
142
146
  }
143
147
 
148
+ const parsedQuery = parseSearchQuery(params.query);
149
+
144
150
  // Invariant across providers; read once and tolerate an uninitialized
145
151
  // Settings singleton (e.g. `omp q ...` CLI path, unit tests) so the
146
152
  // provider-fallback loop never aborts before any provider runs.
@@ -182,6 +188,7 @@ async function executeSearch(
182
188
 
183
189
  const response = await provider.search({
184
190
  query: params.query,
191
+ parsedQuery,
185
192
  limit: params.limit,
186
193
  recency: params.recency,
187
194
  systemPrompt: webSearchSystemPrompt,
@@ -196,15 +203,31 @@ async function executeSearch(
196
203
  geminiModel,
197
204
  });
198
205
 
199
- if (!hasRenderableSearchContent(response)) {
206
+ // Lenient constraint pass over whatever the provider returned: enforce
207
+ // site:/inurl:/intitle:/filetype:/date directives the provider could
208
+ // not (or only partially) honor natively, relaxing any dimension that
209
+ // would wipe out every result. Citations/answer text stay untouched.
210
+ let finalResponse = response;
211
+ const constraintNotes: string[] = [];
212
+ if (parsedQuery.hasConstraints && response.sources.length > 0) {
213
+ const filtered = applyQueryConstraints(response.sources, parsedQuery);
214
+ if (filtered.sources.length !== response.sources.length) {
215
+ finalResponse = { ...response, sources: filtered.sources };
216
+ }
217
+ for (const label of filtered.dropped) {
218
+ constraintNotes.push(`no results matched \`${label}\`; the constraint was relaxed`);
219
+ }
220
+ }
221
+
222
+ if (!hasRenderableSearchContent(finalResponse)) {
200
223
  throw new SearchProviderError(provider.id, `${provider.label} returned no renderable search content.`, 204);
201
224
  }
202
225
 
203
- const text = formatForLLM(response);
226
+ const text = formatForLLM(finalResponse, constraintNotes);
204
227
 
205
228
  return {
206
229
  content: [{ type: "text" as const, text }],
207
- details: { response },
230
+ details: { response: finalResponse },
208
231
  };
209
232
  } catch (error) {
210
233
  // Surface user-initiated cancellation immediately so the session sees
@@ -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");