@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -4,7 +4,7 @@
4
4
  * Calls Brave's web search REST API and maps results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
7
+ import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
9
  import { SearchProviderError } from "../../../web/search/types";
10
10
  import type { QuerySyntax, StructuredQuery } from "../query";
@@ -17,6 +17,9 @@ import { classifyProviderHttpError, withHardTimeout } from "./utils";
17
17
  const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
18
18
  const DEFAULT_NUM_RESULTS = 10;
19
19
  const MAX_NUM_RESULTS = 20;
20
+ const MAX_QUERY_CHARACTERS = 500;
21
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
22
+ const MAX_ERROR_BYTES = 8 * 1024;
20
23
 
21
24
  const RECENCY_MAP: Record<"day" | "week" | "month" | "year", "pd" | "pw" | "pm" | "py"> = {
22
25
  day: "pd",
@@ -51,62 +54,121 @@ export interface BraveSearchParams {
51
54
  num_results?: number;
52
55
  recency?: "day" | "week" | "month" | "year";
53
56
  parsedQuery?: StructuredQuery;
57
+ /** Two-letter market code, or `ALL`. */
58
+ country?: string;
59
+ /** Brave search language code, such as `en` or `zh-hans`. */
60
+ search_lang?: string;
61
+ safesearch?: "off" | "moderate" | "strict";
62
+ authStorage: AuthStorage;
63
+ sessionId?: string;
54
64
  signal?: AbortSignal;
55
65
  timeoutMs?: number;
56
66
  fetch?: FetchImpl;
57
67
  }
58
68
 
59
- interface BraveSearchResult {
60
- title?: string | null;
61
- url?: string | null;
62
- description?: string | null;
63
- age?: string | null;
64
- extra_snippets?: string[] | null;
69
+ interface BraveSearchResponse {
70
+ web?: unknown;
65
71
  }
66
72
 
67
- interface BraveSearchResponse {
68
- web?: {
69
- results?: BraveSearchResult[];
70
- };
73
+ function normalizeText(value: unknown, maxLength: number): string | undefined {
74
+ if (typeof value !== "string") return undefined;
75
+ const text = value
76
+ .replace(/<[^>]*>/g, " ")
77
+ .replace(/\s+/g, " ")
78
+ .trim();
79
+ if (!text) return undefined;
80
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
71
81
  }
72
82
 
73
- /** Find BRAVE_API_KEY from environment or .env files. */
74
- export function findApiKey(): string | null {
75
- return getEnvApiKey("brave") ?? null;
83
+ function normalizeUrl(value: unknown): string | undefined {
84
+ if (typeof value !== "string" || value.length > 2048) return undefined;
85
+ try {
86
+ const url = new URL(value);
87
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
88
+ return url.toString();
89
+ } catch {
90
+ return undefined;
91
+ }
76
92
  }
77
93
 
78
- function buildSnippet(result: BraveSearchResult): string | undefined {
79
- const snippets: string[] = [];
94
+ function webResults(response: BraveSearchResponse): readonly unknown[] {
95
+ if (typeof response.web !== "object" || response.web === null || !("results" in response.web)) return [];
96
+ return Array.isArray(response.web.results) ? response.web.results : [];
97
+ }
80
98
 
81
- if (result.description?.trim()) {
82
- snippets.push(result.description.trim());
99
+ async function readLimitedText(response: Response, maxBytes: number, truncate = false): Promise<string> {
100
+ if (!response.body) return "";
101
+ const reader = response.body.getReader();
102
+ let buffer = new Uint8Array(Math.min(maxBytes, 64 * 1024));
103
+ let bytes = 0;
104
+
105
+ try {
106
+ for (;;) {
107
+ const { done, value } = await reader.read();
108
+ if (done) break;
109
+ const accepted = Math.min(value.byteLength, maxBytes - bytes);
110
+ const nextBytes = bytes + accepted;
111
+ if (nextBytes > buffer.byteLength) {
112
+ const grown = new Uint8Array(Math.min(maxBytes, Math.max(nextBytes, buffer.byteLength * 2)));
113
+ grown.set(buffer.subarray(0, bytes));
114
+ buffer = grown;
115
+ }
116
+ buffer.set(value.subarray(0, accepted), bytes);
117
+ bytes = nextBytes;
118
+ if (accepted < value.byteLength) {
119
+ await reader.cancel().catch(() => undefined);
120
+ if (!truncate) throw new SearchProviderError("brave", "Brave API response exceeded 2 MiB", 500);
121
+ break;
122
+ }
123
+ }
124
+ } finally {
125
+ reader.releaseLock();
83
126
  }
84
127
 
85
- if (Array.isArray(result.extra_snippets)) {
86
- for (const snippet of result.extra_snippets) {
87
- if (!snippet?.trim()) continue;
88
- if (snippets.includes(snippet.trim())) continue;
89
- snippets.push(snippet.trim());
128
+ return new TextDecoder().decode(buffer.subarray(0, bytes));
129
+ }
130
+
131
+ function buildSnippet(result: object): string | undefined {
132
+ const snippets = new Set<string>();
133
+ const description = normalizeText("description" in result ? result.description : undefined, 8_000);
134
+ if (description) snippets.add(description);
135
+
136
+ const extras = "extra_snippets" in result ? result.extra_snippets : undefined;
137
+ if (Array.isArray(extras)) {
138
+ for (const value of extras) {
139
+ const snippet = normalizeText(value, 8_000);
140
+ if (snippet) snippets.add(snippet);
90
141
  }
91
142
  }
92
143
 
93
- return snippets.length > 0 ? snippets.join("\n") : undefined;
144
+ const combined = [...snippets].join("\n");
145
+ return combined ? (combined.length <= 8_000 ? combined : `${combined.slice(0, 7_999)}…`) : undefined;
94
146
  }
95
147
 
96
148
  async function callBraveSearch(
97
149
  apiKey: string,
98
150
  params: BraveSearchParams,
99
151
  ): Promise<{ response: BraveSearchResponse; requestId?: string }> {
100
- const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
152
+ const numResults = Math.floor(clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS));
101
153
  const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
154
+ const query = parsed.hasDirectives ? formatQuery(parsed, BRAVE_QUERY_SYNTAX) : params.query;
155
+ if (query.length > MAX_QUERY_CHARACTERS) {
156
+ throw new SearchProviderError(
157
+ "brave",
158
+ `Brave search queries cannot exceed ${MAX_QUERY_CHARACTERS} characters`,
159
+ 400,
160
+ );
161
+ }
102
162
  const url = new URL(BRAVE_SEARCH_URL);
103
- url.searchParams.set("q", parsed.hasDirectives ? formatQuery(parsed, BRAVE_QUERY_SYNTAX) : params.query);
163
+ url.searchParams.set("q", query);
104
164
  url.searchParams.set("count", String(numResults));
105
165
  url.searchParams.set("extra_snippets", "true");
166
+ url.searchParams.set("text_decorations", "false");
167
+ url.searchParams.set("safesearch", params.safesearch ?? "moderate");
168
+ if (params.country) url.searchParams.set("country", params.country.toUpperCase());
169
+ if (params.search_lang) url.searchParams.set("search_lang", params.search_lang);
106
170
  const freshness = braveFreshness(parsed, params.recency);
107
- if (freshness) {
108
- url.searchParams.set("freshness", freshness);
109
- }
171
+ if (freshness) url.searchParams.set("freshness", freshness);
110
172
 
111
173
  const fetchImpl = params.fetch ?? fetch;
112
174
  const response = await fetchImpl(url, {
@@ -118,36 +180,46 @@ async function callBraveSearch(
118
180
  });
119
181
 
120
182
  if (!response.ok) {
121
- const errorText = await response.text();
183
+ const errorText = await readLimitedText(response, MAX_ERROR_BYTES, true);
122
184
  const classified = classifyProviderHttpError("brave", response.status, errorText);
123
185
  if (classified) throw classified;
124
186
  throw new SearchProviderError("brave", `Brave API error (${response.status}): ${errorText}`, response.status);
125
187
  }
126
188
 
127
- const data = (await response.json()) as BraveSearchResponse;
189
+ const raw = await readLimitedText(response, MAX_RESPONSE_BYTES);
190
+ let data: BraveSearchResponse;
191
+ try {
192
+ data = JSON.parse(raw) as BraveSearchResponse;
193
+ } catch {
194
+ throw new SearchProviderError("brave", "Brave API returned invalid JSON", 500);
195
+ }
128
196
  const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined;
129
197
  return { response: data, requestId };
130
198
  }
131
199
 
132
200
  /** Execute Brave web search. */
133
201
  export async function searchBrave(params: BraveSearchParams): Promise<SearchResponse> {
134
- const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
135
- const apiKey = findApiKey();
136
- if (!apiKey) {
137
- throw new Error("BRAVE_API_KEY not found. Set it in environment or .env file.");
138
- }
139
-
140
- const { response, requestId } = await callBraveSearch(apiKey, params);
202
+ const numResults = Math.floor(clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS));
203
+ const keyOrResolver: ApiKey = params.authStorage.resolver("brave", {
204
+ sessionId: params.sessionId,
205
+ });
206
+ const { response, requestId } = await withAuth(keyOrResolver, key => callBraveSearch(key, params), {
207
+ signal: params.signal,
208
+ missingKeyMessage: 'Brave credentials not found. Set BRAVE_API_KEY or configure an API key for provider "brave".',
209
+ });
141
210
  const sources: SearchSource[] = [];
142
211
 
143
- for (const result of response.web?.results ?? []) {
144
- if (!result.url) continue;
212
+ for (const result of webResults(response)) {
213
+ if (typeof result !== "object" || result === null) continue;
214
+ const url = normalizeUrl("url" in result ? result.url : undefined);
215
+ if (!url) continue;
216
+ const publishedDate = normalizeText("age" in result ? result.age : undefined, 100);
145
217
  sources.push({
146
- title: result.title ?? result.url,
147
- url: result.url,
218
+ title: normalizeText("title" in result ? result.title : undefined, 300) ?? url,
219
+ url,
148
220
  snippet: buildSnippet(result),
149
- publishedDate: result.age ?? undefined,
150
- ageSeconds: dateToAgeSeconds(result.age),
221
+ publishedDate,
222
+ ageSeconds: dateToAgeSeconds(publishedDate),
151
223
  });
152
224
  }
153
225
 
@@ -155,6 +227,7 @@ export async function searchBrave(params: BraveSearchParams): Promise<SearchResp
155
227
  provider: "brave",
156
228
  sources: sources.slice(0, numResults),
157
229
  requestId,
230
+ authMode: "api_key",
158
231
  };
159
232
  }
160
233
 
@@ -163,8 +236,8 @@ export class BraveProvider extends SearchProvider {
163
236
  readonly id = "brave";
164
237
  readonly label = "Brave";
165
238
 
166
- isAvailable(_authStorage: AuthStorage): boolean {
167
- return !!findApiKey();
239
+ isAvailable(authStorage: AuthStorage): boolean {
240
+ return authStorage.hasAuth("brave") || !!getEnvApiKey("brave");
168
241
  }
169
242
 
170
243
  search(params: SearchParams): Promise<SearchResponse> {
@@ -173,6 +246,8 @@ export class BraveProvider extends SearchProvider {
173
246
  num_results: params.numSearchResults ?? params.limit,
174
247
  recency: params.recency,
175
248
  parsedQuery: params.parsedQuery,
249
+ authStorage: params.authStorage,
250
+ sessionId: params.sessionId,
176
251
  signal: params.signal,
177
252
  timeoutMs: params.timeoutMs,
178
253
  fetch: params.fetch,
@@ -4,7 +4,6 @@
4
4
  * Uses the configured Codex Responses transport for proxy/API-key setups and
5
5
  * the official ChatGPT backend for OAuth logins.
6
6
  */
7
- import * as os from "node:os";
8
7
  import {
9
8
  type AuthStorage,
10
9
  type FetchImpl,
@@ -22,8 +21,7 @@ import {
22
21
  OPENAI_HEADER_VALUES,
23
22
  OPENAI_HEADERS,
24
23
  } from "@oh-my-pi/pi-catalog/wire/codex";
25
- import { $env, readSseJson } from "@oh-my-pi/pi-utils";
26
- import packageJson from "../../../../package.json" with { type: "json" };
24
+ import { $env, readSseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
27
25
  import type { ModelRegistry } from "../../../config/model-registry";
28
26
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
29
27
  import { SearchProviderError } from "../../../web/search/types";
@@ -151,6 +149,13 @@ export interface CodexSearchParams {
151
149
  }
152
150
 
153
151
  /** Codex API response structure */
152
+ interface CodexWebSearchSource {
153
+ url?: string;
154
+ source_website_url?: string;
155
+ title?: string;
156
+ caption?: string;
157
+ }
158
+
154
159
  interface CodexResponseItem {
155
160
  type: string;
156
161
  id?: string;
@@ -161,6 +166,9 @@ interface CodexResponseItem {
161
166
  arguments?: string;
162
167
  content?: CodexContentPart[];
163
168
  summary?: Array<{ type: string; text: string }>;
169
+ action?: { sources?: CodexWebSearchSource[] };
170
+ sources?: CodexWebSearchSource[];
171
+ results?: CodexWebSearchSource[];
164
172
  }
165
173
 
166
174
  interface CodexContentPart {
@@ -221,12 +229,45 @@ function isImagePlaceholderAnswer(text: string): boolean {
221
229
  return IMAGE_PLACEHOLDER_ANSWERS.has(normalized);
222
230
  }
223
231
 
232
+ function cleanSourceUrl(rawUrl: string): string {
233
+ try {
234
+ const url = new URL(rawUrl);
235
+ if (url.searchParams.get("utm_source") === "openai") {
236
+ url.searchParams.delete("utm_source");
237
+ }
238
+ return url.toString();
239
+ } catch {
240
+ return rawUrl.replace(/[?&]utm_source=openai$/u, "");
241
+ }
242
+ }
243
+
224
244
  function addSource(sources: SearchSource[], source: SearchSource): void {
225
- if (!sources.some(existing => existing.url === source.url)) {
226
- sources.push(source);
245
+ const normalizedSource = { ...source, url: cleanSourceUrl(source.url) };
246
+ const existing = sources.find(candidate => candidate.url === normalizedSource.url);
247
+ if (!existing) {
248
+ sources.push(normalizedSource);
249
+ return;
250
+ }
251
+ if (existing.title === existing.url && normalizedSource.title !== normalizedSource.url) {
252
+ existing.title = normalizedSource.title;
253
+ }
254
+ if (!existing.snippet && normalizedSource.snippet) {
255
+ existing.snippet = normalizedSource.snippet;
227
256
  }
228
257
  }
229
258
 
259
+ function extractCitationSnippet(text: string, start: number | undefined, end: number | undefined): string | undefined {
260
+ if (start === undefined || end === undefined || !text) return undefined;
261
+ const before = Math.max(0, start - 100);
262
+ const after = Math.min(text.length, end + 100);
263
+ const snippet = text
264
+ .slice(before, after)
265
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
266
+ .trim();
267
+ if (!snippet) return undefined;
268
+ return snippet.length > 300 ? `${snippet.slice(0, 297)}...` : snippet;
269
+ }
270
+
230
271
  function countCharacter(text: string, target: string): number {
231
272
  let count = 0;
232
273
  for (const char of text) {
@@ -398,7 +439,7 @@ function buildCodexHeaders(
398
439
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
399
440
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
400
441
  headers.set(OPENAI_HEADERS.VERSION, CODEX_CLIENT_VERSION);
401
- headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
442
+ headers.set("User-Agent", USER_AGENT);
402
443
  headers.set("Accept", "text/event-stream");
403
444
  headers.set("Content-Type", "application/json");
404
445
  return headers;
@@ -428,6 +469,15 @@ function extractCodexSseError(rawEvent: Record<string, unknown>): { code: string
428
469
  return { code, message };
429
470
  }
430
471
 
472
+ function classifyCodexSseErrorStatus(code: string, message: string): number {
473
+ const detail = `${code} ${message}`.toLowerCase();
474
+ if (/rate[- ]?limit|too many requests|quota|\b429\b/u.test(detail)) return 429;
475
+ if (/unauthori[sz]ed|\b401\b/u.test(detail)) return 401;
476
+ if (/forbidden|\b403\b/u.test(detail)) return 403;
477
+ if (/timeout|timed out/u.test(detail)) return 504;
478
+ return 500;
479
+ }
480
+
431
481
  /**
432
482
  * Calls the Codex Responses API with web search tool enabled.
433
483
  * The caller provides the exact model id to send; retry / fallback policy
@@ -455,6 +505,8 @@ async function callCodexSearch(
455
505
  model: requestedModel,
456
506
  stream: true,
457
507
  store: false,
508
+ include: ["web_search_call.action.sources"],
509
+ parallel_tool_calls: true,
458
510
  input: [
459
511
  {
460
512
  type: "message",
@@ -510,7 +562,11 @@ async function callCodexSearch(
510
562
  webSearchInvoked = true;
511
563
  }
512
564
 
513
- if (eventType === "response.output_text.delta") {
565
+ if (eventType === "response.created") {
566
+ const resp = (rawEvent as { response?: CodexResponse }).response;
567
+ if (resp?.id) requestId = resp.id;
568
+ if (resp?.model) model = resp.model;
569
+ } else if (eventType === "response.output_text.delta") {
514
570
  const delta = typeof rawEvent.delta === "string" ? rawEvent.delta : "";
515
571
  if (delta) {
516
572
  streamedAnswerParts.push(delta);
@@ -518,7 +574,20 @@ async function callCodexSearch(
518
574
  } else if (eventType === "response.output_item.done") {
519
575
  const item = rawEvent.item as CodexResponseItem | undefined;
520
576
  if (!item) continue;
521
- if (item.type === "web_search_call") webSearchInvoked = true;
577
+ if (item.type === "web_search_call") {
578
+ webSearchInvoked = true;
579
+ const sourceGroups = [item.action?.sources, item.sources, item.results];
580
+ for (const group of sourceGroups) {
581
+ for (const source of group ?? []) {
582
+ const url = source.url ?? source.source_website_url;
583
+ if (!url) continue;
584
+ addSource(sources, {
585
+ title: source.title ?? source.caption ?? url,
586
+ url,
587
+ });
588
+ }
589
+ }
590
+ }
522
591
 
523
592
  // Handle text message content and extract sources from annotations
524
593
  if (item.type === "message" && item.content) {
@@ -530,8 +599,11 @@ async function callCodexSearch(
530
599
  if (part.annotations) {
531
600
  for (const annotation of part.annotations) {
532
601
  if (annotation.type === "url_citation" && annotation.url) {
533
- // Deduplicate by URL
534
- addSource(sources, { title: annotation.title ?? annotation.url, url: annotation.url });
602
+ addSource(sources, {
603
+ title: annotation.title ?? annotation.url,
604
+ url: annotation.url,
605
+ snippet: extractCitationSnippet(part.text, annotation.start_index, annotation.end_index),
606
+ });
535
607
  }
536
608
  }
537
609
  }
@@ -563,13 +635,17 @@ async function callCodexSearch(
563
635
  }
564
636
  } else if (eventType === "error") {
565
637
  const { code, message } = extractCodexSseError(rawEvent);
566
- throw new SearchProviderError("codex", `Codex error (${code}): ${message || "Unknown error"}`, 500);
638
+ throw new SearchProviderError(
639
+ "codex",
640
+ `Codex error (${code}): ${message || "Unknown error"}`,
641
+ classifyCodexSseErrorStatus(code, message),
642
+ );
567
643
  } else if (eventType === "response.failed") {
568
644
  const { code, message } = extractCodexSseError(rawEvent);
569
645
  const detail = code
570
646
  ? `Codex request failed (${code}): ${message || "Request failed"}`
571
647
  : `Codex request failed: ${message || "Request failed"}`;
572
- throw new SearchProviderError("codex", detail, 500);
648
+ throw new SearchProviderError("codex", detail, classifyCodexSseErrorStatus(code, message));
573
649
  }
574
650
  }
575
651
 
@@ -19,6 +19,9 @@ import { SearchProvider } from "./base";
19
19
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
20
20
 
21
21
  const EXA_API_URL = "https://api.exa.ai/search";
22
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
23
+ const EXA_MCP_SOURCE = "oh-my-pi";
24
+ const MAX_EXA_SNIPPET_CHARS = 500;
22
25
  const DEFAULT_EXA_SEARCH_DELAY_MS = getDefault("exa.searchDelayMs");
23
26
 
24
27
  let nextExaSearchRequestAt = 0;
@@ -329,13 +332,20 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
329
332
  return response.json() as Promise<ExaSearchResponse>;
330
333
  }
331
334
  function buildExaMcpArgs(params: ExaSearchParams): Record<string, unknown> {
332
- const args: Record<string, unknown> = { query: params.query };
333
- if (params.num_results !== undefined) args.num_results = params.num_results;
334
- if (params.type !== undefined) args.type = params.type;
335
- if (params.include_domains !== undefined) args.include_domains = params.include_domains;
336
- if (params.exclude_domains !== undefined) args.exclude_domains = params.exclude_domains;
337
- if (params.start_published_date !== undefined) args.start_published_date = params.start_published_date;
338
- if (params.end_published_date !== undefined) args.end_published_date = params.end_published_date;
335
+ const queryParts = [params.query];
336
+ for (const domain of params.include_domains ?? []) {
337
+ const trimmed = domain.trim();
338
+ if (trimmed) queryParts.push(`site:${trimmed}`);
339
+ }
340
+ for (const domain of params.exclude_domains ?? []) {
341
+ const trimmed = domain.trim();
342
+ if (trimmed) queryParts.push(`-site:${trimmed}`);
343
+ }
344
+ if (params.start_published_date) queryParts.push(`after:${params.start_published_date}`);
345
+ if (params.end_published_date) queryParts.push(`before:${params.end_published_date}`);
346
+
347
+ const args: Record<string, unknown> = { query: queryParts.join(" ") };
348
+ if (params.num_results !== undefined) args.numResults = params.num_results;
339
349
  return args;
340
350
  }
341
351
 
@@ -346,11 +356,12 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
346
356
  query.set("tools", "web_search_exa");
347
357
  const fetchImpl = params.fetch ?? fetch;
348
358
  await waitForExaSearchSlot(params.signal);
349
- const response = await fetchImpl(`https://mcp.exa.ai/mcp?${query.toString()}`, {
359
+ const response = await fetchImpl(`${EXA_MCP_URL}?${query.toString()}`, {
350
360
  method: "POST",
351
361
  headers: {
352
362
  "Content-Type": "application/json",
353
363
  Accept: "application/json, text/event-stream",
364
+ "x-exa-source": EXA_MCP_SOURCE,
354
365
  },
355
366
  body: JSON.stringify({
356
367
  jsonrpc: "2.0",
@@ -364,11 +375,26 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
364
375
  signal: withHardTimeout(params.signal, params.timeoutMs),
365
376
  });
366
377
  if (!response.ok) {
367
- throw new Error(`MCP request failed: ${response.status} ${response.statusText}`);
378
+ const errorText = await response.text();
379
+ const classified = classifyProviderHttpError("exa", response.status, errorText);
380
+ if (classified) throw classified;
381
+ if (response.status === 429) {
382
+ throw new SearchProviderError(
383
+ "exa",
384
+ "exa: MCP rate limit reached (429); configure an Exa API key for higher limits",
385
+ response.status,
386
+ );
387
+ }
388
+ throw new SearchProviderError(
389
+ "exa",
390
+ `Exa MCP request failed (${response.status}): ${errorText}`,
391
+ response.status,
392
+ );
368
393
  }
369
394
  const mcpResponse = parseSSE(await response.text()) as {
370
395
  result?: {
371
396
  content?: Array<{ type: string; text?: string }>;
397
+ isError?: boolean;
372
398
  };
373
399
  error?: {
374
400
  code: number;
@@ -381,6 +407,12 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
381
407
  if (mcpResponse.error) {
382
408
  throw new Error(`MCP error: ${mcpResponse.error.message}`);
383
409
  }
410
+ if (mcpResponse.result?.isError) {
411
+ const message = mcpResponse.result.content
412
+ ?.find(item => item.type === "text" && typeof item.text === "string")
413
+ ?.text?.trim();
414
+ throw new SearchProviderError("exa", message || "Exa MCP returned an error");
415
+ }
384
416
  const responsePayload = normalizeExaMcpPayload(mcpResponse.result);
385
417
  if (isSearchResponse(responsePayload)) {
386
418
  return responsePayload as ExaSearchResponse;
@@ -419,7 +451,10 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
419
451
  sources.push({
420
452
  title: result.title ?? result.url,
421
453
  url: result.url,
422
- snippet: result.summary || result.text || result.highlights?.join(" ") || undefined,
454
+ snippet: (result.summary || result.text || result.highlights?.join(" ") || undefined)?.slice(
455
+ 0,
456
+ MAX_EXA_SNIPPET_CHARS,
457
+ ),
423
458
  publishedDate: result.publishedDate ?? undefined,
424
459
  ageSeconds: dateToAgeSeconds(result.publishedDate ?? undefined),
425
460
  author: result.author ?? undefined,
@@ -20,7 +20,7 @@ import type { SearchParams } from "./base";
20
20
  import { SearchProvider } from "./base";
21
21
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
22
22
 
23
- const FIRECRAWL_SEARCH_URL = "https://api.firecrawl.dev/v2/search";
23
+ const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev/v2";
24
24
  const DEFAULT_NUM_RESULTS = 10;
25
25
  const MAX_NUM_RESULTS = 100;
26
26
 
@@ -30,6 +30,28 @@ const RECENCY_TBS: Record<NonNullable<SearchParams["recency"]>, string> = {
30
30
  month: "qdr:m",
31
31
  year: "qdr:y",
32
32
  };
33
+ function resolveSearchUrl(): string {
34
+ const configured = process.env.FIRECRAWL_BASE_URL ?? process.env.FIRECRAWL_API_URL;
35
+ if (!configured?.trim()) return `${FIRECRAWL_DEFAULT_BASE_URL}/search`;
36
+ let url: URL;
37
+ try {
38
+ url = new URL(configured.trim());
39
+ } catch {
40
+ throw new Error("Invalid Firecrawl base URL: expected an HTTP or HTTPS URL");
41
+ }
42
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
43
+ throw new Error("Invalid Firecrawl base URL: expected an HTTP or HTTPS URL");
44
+ }
45
+ if (url.username || url.password) {
46
+ throw new Error("Invalid Firecrawl base URL: URL credentials are not allowed");
47
+ }
48
+ url.search = "";
49
+ url.hash = "";
50
+ url.pathname = url.pathname.replace(/\/+$/, "");
51
+ if (!/\/v[12]$/i.test(url.pathname)) url.pathname += "/v2";
52
+ url.pathname += "/search";
53
+ return url.toString();
54
+ }
33
55
 
34
56
  export interface FirecrawlSearchParams {
35
57
  query: string;
@@ -46,14 +68,23 @@ interface FirecrawlWebResult {
46
68
  title?: string | null;
47
69
  url?: string | null;
48
70
  description?: string | null;
71
+ snippet?: string | null;
49
72
  markdown?: string | null;
50
73
  }
51
74
 
52
75
  interface FirecrawlSearchResponse {
76
+ success?: boolean;
77
+ error?: string | null;
53
78
  id?: string | null;
54
- data?: {
55
- web?: FirecrawlWebResult[] | null;
56
- } | null;
79
+ data?:
80
+ | FirecrawlWebResult[]
81
+ | {
82
+ web?: FirecrawlWebResult[] | null;
83
+ news?: FirecrawlWebResult[] | null;
84
+ images?: FirecrawlWebResult[] | null;
85
+ }
86
+ | null;
87
+ results?: FirecrawlWebResult[] | null;
57
88
  }
58
89
 
59
90
  /** Resolve Firecrawl API key through the shared auth storage pipeline. */
@@ -88,7 +119,7 @@ async function callFirecrawlSearch(
88
119
  if (apiKey) {
89
120
  headers.Authorization = `Bearer ${apiKey}`;
90
121
  }
91
- const response = await (params.fetch ?? fetch)(FIRECRAWL_SEARCH_URL, {
122
+ const response = await (params.fetch ?? fetch)(resolveSearchUrl(), {
92
123
  method: "POST",
93
124
  headers,
94
125
  body: JSON.stringify(buildRequestBody(params)),
@@ -106,7 +137,11 @@ async function callFirecrawlSearch(
106
137
  );
107
138
  }
108
139
 
109
- return (await response.json()) as FirecrawlSearchResponse;
140
+ const data = (await response.json()) as FirecrawlSearchResponse;
141
+ if (data.success === false) {
142
+ throw new SearchProviderError("firecrawl", data.error?.trim() || "Firecrawl request failed");
143
+ }
144
+ return data;
110
145
  }
111
146
 
112
147
  /** ISO `YYYY-MM-DD` to Google `MM/DD/YYYY` for `tbs=cdr` custom date ranges. */
@@ -128,6 +163,11 @@ function buildDateTbs(parsed: StructuredQuery): string | undefined {
128
163
  return parts.join(",");
129
164
  }
130
165
 
166
+ function getWebResults(data: FirecrawlSearchResponse): FirecrawlWebResult[] {
167
+ if (Array.isArray(data.data)) return data.data;
168
+ if (data.data && Array.isArray(data.data.web)) return data.data.web;
169
+ return data.results ?? [];
170
+ }
131
171
  /** Execute Firecrawl web search. */
132
172
  export async function searchFirecrawl(params: SearchParams): Promise<SearchResponse> {
133
173
  const parsed = params.parsedQuery ?? parseSearchQuery(params.query);
@@ -169,12 +209,12 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
169
209
 
170
210
  const sources: SearchSource[] = [];
171
211
 
172
- for (const result of data.data?.web ?? []) {
212
+ for (const result of getWebResults(data)) {
173
213
  if (!result.url) continue;
174
214
  sources.push({
175
215
  title: result.title ?? result.url,
176
216
  url: result.url,
177
- snippet: result.description ?? result.markdown ?? undefined,
217
+ snippet: result.description ?? result.snippet ?? result.markdown ?? undefined,
178
218
  });
179
219
  }
180
220
 
@@ -192,11 +232,13 @@ export class FirecrawlProvider extends SearchProvider {
192
232
  readonly label = "Firecrawl";
193
233
 
194
234
  /**
195
- * Auto-chain admission: requires a credential so an unconfigured Firecrawl
196
- * doesn't displace other providers that the user has set up with API keys.
235
+ * Auto-chain admission requires either a credential or an explicitly
236
+ * configured self-hosted endpoint. Hosted keyless mode remains explicit-only
237
+ * so it does not displace providers the user configured.
197
238
  */
198
239
  isAvailable(authStorage: AuthStorage): boolean {
199
- return authStorage.hasAuth("firecrawl") || !!getEnvApiKey("firecrawl");
240
+ const configuredBaseUrl = process.env.FIRECRAWL_BASE_URL ?? process.env.FIRECRAWL_API_URL;
241
+ return !!configuredBaseUrl?.trim() || authStorage.hasAuth("firecrawl") || !!getEnvApiKey("firecrawl");
200
242
  }
201
243
 
202
244
  /**