@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.
- package/CHANGELOG.md +57 -0
- package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
- package/dist/cli.js +3069 -3047
- package/dist/types/advisor/delta-split.d.ts +24 -0
- package/dist/types/advisor/runtime.d.ts +2 -2
- package/dist/types/async/job-manager.d.ts +8 -1
- package/dist/types/cli/update-cli.d.ts +53 -1
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-resolver.d.ts +15 -2
- package/dist/types/config/settings-schema.d.ts +4 -0
- package/dist/types/discovery/agents-md.d.ts +10 -1
- package/dist/types/eval/runner-cache.d.ts +12 -0
- package/dist/types/extensibility/extensions/runner.d.ts +12 -3
- package/dist/types/extensibility/extensions/types.d.ts +15 -4
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
- package/dist/types/lib/xai-http.d.ts +0 -1
- package/dist/types/mcp/tool-bridge.d.ts +8 -5
- package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
- package/dist/types/modes/components/status-line/types.d.ts +4 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
- package/dist/types/modes/interactive-mode.d.ts +17 -8
- package/dist/types/modes/types.d.ts +7 -10
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +22 -3
- package/dist/types/session/messages.d.ts +20 -0
- package/dist/types/session/retry-fallback-chains.d.ts +13 -0
- package/dist/types/session/session-advisors.d.ts +1 -1
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-maintenance.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +24 -5
- package/dist/types/session/turn-recovery.d.ts +34 -5
- package/dist/types/slash-commands/types.d.ts +5 -1
- package/dist/types/task/executor.d.ts +1 -1
- package/dist/types/tools/approval.d.ts +7 -0
- package/dist/types/tools/todo.d.ts +14 -15
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/utils/local-date.d.ts +2 -0
- package/dist/types/vibe/runtime.d.ts +1 -1
- package/dist/types/web/parallel.d.ts +1 -0
- package/dist/types/web/search/providers/brave.d.ts +8 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
- package/dist/types/web/search/providers/jina.d.ts +3 -3
- package/dist/types/web/search/providers/parallel.d.ts +1 -0
- package/dist/types/web/search/providers/perplexity.d.ts +4 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
- package/package.json +13 -13
- package/src/advisor/delta-split.ts +98 -0
- package/src/advisor/runtime.ts +321 -69
- package/src/async/job-manager.ts +14 -3
- package/src/cli/plugin-cli.ts +30 -2
- package/src/cli/update-cli.ts +259 -24
- package/src/config/keybindings.ts +52 -9
- package/src/config/model-resolver.ts +19 -3
- package/src/config/settings-schema.ts +5 -0
- package/src/cursor.ts +10 -5
- package/src/discovery/agents-md.ts +61 -23
- package/src/eval/jl/kernel.ts +2 -20
- package/src/eval/py/kernel.ts +2 -20
- package/src/eval/rb/kernel.ts +2 -20
- package/src/eval/runner-cache.ts +41 -0
- package/src/exec/non-interactive-env.ts +14 -3
- package/src/extensibility/extensions/loader.ts +5 -2
- package/src/extensibility/extensions/runner.ts +184 -66
- package/src/extensibility/extensions/types.ts +26 -2
- package/src/extensibility/extensions/wrapper.ts +13 -7
- package/src/extensibility/plugins/marketplace/manager.ts +6 -2
- package/src/hindsight/client.ts +1 -1
- package/src/lib/xai-http.ts +0 -4
- package/src/lsp/client.ts +2 -0
- package/src/lsp/servers.ts +1 -1
- package/src/mcp/tool-bridge.ts +15 -6
- package/src/modes/components/agent-hub-renderer.ts +9 -3
- package/src/modes/components/agent-hub.ts +2 -1
- package/src/modes/components/status-line/component.ts +58 -6
- package/src/modes/components/status-line/segments.ts +12 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/user-message.ts +20 -5
- package/src/modes/controllers/event-controller.ts +48 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -7
- package/src/modes/controllers/input-controller.ts +25 -6
- package/src/modes/interactive-mode.ts +315 -129
- package/src/modes/rpc/rpc-frame.ts +13 -5
- package/src/modes/theme/tui-adapters.ts +4 -5
- package/src/modes/types.ts +13 -7
- package/src/modes/utils/hotkeys-markdown.ts +10 -6
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/registry/persisted-agents.ts +43 -8
- package/src/sdk.ts +139 -8
- package/src/session/agent-session-types.ts +2 -0
- package/src/session/agent-session.ts +66 -10
- package/src/session/messages.ts +98 -28
- package/src/session/retry-fallback-chains.ts +14 -0
- package/src/session/session-advisors.ts +32 -15
- package/src/session/session-history-format.ts +15 -1
- package/src/session/session-maintenance.ts +8 -8
- package/src/session/session-manager.ts +6 -2
- package/src/session/session-tools.ts +321 -184
- package/src/session/turn-recovery.ts +225 -47
- package/src/slash-commands/builtin-modes.ts +41 -12
- package/src/slash-commands/types.ts +5 -1
- package/src/task/executor.ts +92 -45
- package/src/task/structured-subagent.ts +5 -5
- package/src/tools/approval.ts +44 -10
- package/src/tools/fetch.ts +21 -2
- package/src/tools/image-gen.ts +6 -8
- package/src/tools/todo.ts +70 -26
- package/src/tools/tts.ts +3 -2
- package/src/tools/write.ts +7 -3
- package/src/utils/local-date.ts +13 -0
- package/src/utils/tools-manager.ts +2 -2
- package/src/vibe/runtime.ts +22 -14
- package/src/web/kagi.ts +91 -34
- package/src/web/parallel.ts +11 -2
- package/src/web/scrapers/crates-io.ts +2 -2
- package/src/web/scrapers/discogs.ts +2 -2
- package/src/web/scrapers/docs-rs.ts +2 -2
- package/src/web/scrapers/github.ts +2 -2
- package/src/web/scrapers/musicbrainz.ts +1 -2
- package/src/web/scrapers/pubmed.ts +2 -2
- package/src/web/scrapers/sec-edgar.ts +2 -2
- package/src/web/search/providers/brave.ts +121 -46
- package/src/web/search/providers/codex.ts +88 -12
- package/src/web/search/providers/exa.ts +45 -10
- package/src/web/search/providers/firecrawl.ts +53 -11
- package/src/web/search/providers/gemini.ts +139 -27
- package/src/web/search/providers/jina.ts +48 -25
- package/src/web/search/providers/parallel.ts +23 -9
- package/src/web/search/providers/perplexity.ts +24 -7
- package/src/web/search/providers/searxng.ts +77 -1
- package/src/web/search/providers/tavily.ts +23 -22
- package/src/web/search/providers/tinyfish.ts +44 -10
- 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
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
144
|
-
if (
|
|
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 ??
|
|
147
|
-
url
|
|
218
|
+
title: normalizeText("title" in result ? result.title : undefined, 300) ?? url,
|
|
219
|
+
url,
|
|
148
220
|
snippet: buildSnippet(result),
|
|
149
|
-
publishedDate
|
|
150
|
-
ageSeconds: dateToAgeSeconds(
|
|
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(
|
|
167
|
-
return !!
|
|
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
|
-
|
|
226
|
-
|
|
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",
|
|
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.
|
|
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")
|
|
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
|
-
|
|
534
|
-
|
|
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(
|
|
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,
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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)(
|
|
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
|
-
|
|
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
|
|
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
|
|
196
|
-
*
|
|
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
|
-
|
|
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
|
/**
|