@nijaru/pi-search 0.0.7 → 0.0.9
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/README.md +112 -8
- package/package.json +4 -4
- package/src/brave-answers.ts +1 -1
- package/src/brave.ts +1 -1
- package/src/cloudflare.ts +1 -1
- package/src/contracts.ts +8 -3
- package/src/credentials.ts +134 -0
- package/src/deepseek.ts +1 -1
- package/src/exa.ts +1 -1
- package/src/fetch-extractor-worker.mjs +14 -0
- package/src/fetch-tool.ts +5 -4
- package/src/fetcher.ts +109 -50
- package/src/index.ts +50 -11
- package/src/parallel-responses.ts +1 -1
- package/src/parallel.ts +1 -1
- package/src/research-tool.ts +6 -7
- package/src/search-tool.ts +10 -11
- package/src/x-browser-page.ts +191 -0
- package/src/x-browser.ts +181 -0
- package/src/x-cdp.ts +101 -0
- package/src/x-dom.d.mts +7 -0
- package/src/x-dom.mjs +77 -0
- package/src/x-public.ts +396 -0
- package/src/x.ts +1 -1
package/src/fetcher.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
|
-
import type { FetchRequest, FetchedContent, FetchOutputFormat } from "./contracts";
|
|
2
|
+
import type { FetchRequest, FetchedContent, FetchOutputFormat, FetchExtraction, FetchWarning, XReadMode } from "./contracts";
|
|
3
|
+
import { parseXUrl, readFxTwitter, type XUrl } from "./x-public";
|
|
4
|
+
import type { XBrowserReader, XBrowserSelection } from "./x-browser";
|
|
3
5
|
import { SafeFetchError } from "./fetch-errors";
|
|
4
6
|
import { closeResponseBody, fetchRemoteUrl, type Lookup, type ResponseBody } from "./ssrf";
|
|
5
7
|
import type { DirectTransport } from "./direct-transport";
|
|
@@ -27,12 +29,13 @@ const MAX_OUTPUT_BYTES = 32_000;
|
|
|
27
29
|
|
|
28
30
|
type NormalizedFetchRequest = Required<Pick<FetchRequest, "url" | "maxLength" | "offset" | "format" | "readable" | "allowRawHtmlFallback" | "captionLanguage">> & {
|
|
29
31
|
readonly maxPages: number | undefined;
|
|
32
|
+
readonly xMode: XReadMode | undefined;
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
interface ExtractedContent {
|
|
33
36
|
readonly content: string;
|
|
34
37
|
readonly outputFormat: FetchOutputFormat;
|
|
35
|
-
readonly extraction:
|
|
38
|
+
readonly extraction: FetchExtraction;
|
|
36
39
|
readonly documentFormat?: string;
|
|
37
40
|
readonly title?: string;
|
|
38
41
|
readonly fellBackToRaw?: boolean;
|
|
@@ -49,6 +52,8 @@ export interface FetcherOptions {
|
|
|
49
52
|
readonly pdfMaxOutputBytes?: number;
|
|
50
53
|
readonly youtubeMaxOutputBytes?: number;
|
|
51
54
|
readonly documentConverter?: DocumentConverter;
|
|
55
|
+
/** Explicitly owned, user-authorized browser access. Never global auth discovery. */
|
|
56
|
+
readonly xBrowser?: XBrowserReader;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export async function fetchContent(
|
|
@@ -77,6 +82,14 @@ export async function fetchContent(
|
|
|
77
82
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
78
83
|
|
|
79
84
|
try {
|
|
85
|
+
const xTarget = parseXUrl(normalized.url);
|
|
86
|
+
const xBrowser = xTarget ? await options.xBrowser?.select(controller.signal) : undefined;
|
|
87
|
+
const xDefaultMode = xTarget ? xBrowser?.mode ?? "auto" : "direct";
|
|
88
|
+
// A persistent user-selected direct-only policy cannot be overridden by a tool call.
|
|
89
|
+
const xMode = xDefaultMode === "direct" ? "direct" : normalized.xMode ?? xDefaultMode;
|
|
90
|
+
if (xTarget && xMode !== "direct") {
|
|
91
|
+
return await fetchXContent(xTarget, xMode, normalized, controller.signal, options, maxResponseBytes, xBrowser);
|
|
92
|
+
}
|
|
80
93
|
if (parseYouTubeUrl(normalized.url) !== undefined) {
|
|
81
94
|
return await fetchYouTubeContent(normalized, controller.signal, options, options.now ?? (() => new Date()));
|
|
82
95
|
}
|
|
@@ -130,34 +143,9 @@ export async function fetchContent(
|
|
|
130
143
|
: documentCandidate
|
|
131
144
|
? await extractAnyDocContent(bytes, mimeType, finalUrl, controller.signal, options.documentConverter)
|
|
132
145
|
: await extractWithDeadline(decodeText(bytes, contentType, mimeType), mimeType, normalized, controller.signal);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...(sliced.truncated ? [{ code: "truncated" as const, message: sliced.pagingLimitReached ? "Content was truncated at the maximum paging offset" : "Content was truncated to the configured output bound" }] : []),
|
|
137
|
-
];
|
|
138
|
-
const title = extracted.title === undefined ? undefined : boundText(extracted.title, MAX_TITLE_LENGTH);
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
url: url.url.href,
|
|
142
|
-
...(url.url.href === normalized.url ? {} : { sourceUrl: normalized.url }),
|
|
143
|
-
...(title === undefined ? {} : { title }),
|
|
144
|
-
content: sliced.content,
|
|
145
|
-
contentTrust: "untrusted",
|
|
146
|
-
...(contentType === "" ? {} : { contentType }),
|
|
147
|
-
outputFormat: extracted.outputFormat,
|
|
148
|
-
extraction: extracted.extraction,
|
|
149
|
-
...(extracted.documentFormat === undefined ? {} : { documentFormat: extracted.documentFormat }),
|
|
150
|
-
fetchedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
151
|
-
status: response.status,
|
|
152
|
-
redirectCount,
|
|
153
|
-
bytesRead: bytes.byteLength,
|
|
154
|
-
truncated: sliced.truncated,
|
|
155
|
-
offset: sliced.offset,
|
|
156
|
-
...(sliced.nextOffset === undefined ? {} : { nextOffset: sliced.nextOffset }),
|
|
157
|
-
totalCharacters: extracted.content.length,
|
|
158
|
-
warnings,
|
|
159
|
-
...(extracted.fellBackToRaw ? { fellBackToRaw: true } : {}),
|
|
160
|
-
};
|
|
146
|
+
return finishContent(extracted, normalized, {
|
|
147
|
+
url: url.url.href, ...(contentType ? { contentType } : {}), status: response.status, redirectCount, bytesRead: bytes.byteLength,
|
|
148
|
+
}, options.now);
|
|
161
149
|
} catch (error) {
|
|
162
150
|
if (timedOut && !signal?.aborted && isCanceled(error)) {
|
|
163
151
|
throw new SafeFetchError({ kind: "timeout", message: `Fetch timed out after ${timeoutMs} ms`, retryable: true, cause: error });
|
|
@@ -172,6 +160,90 @@ export async function fetchContent(
|
|
|
172
160
|
}
|
|
173
161
|
}
|
|
174
162
|
|
|
163
|
+
function finishContent(
|
|
164
|
+
extracted: ExtractedContent,
|
|
165
|
+
request: NormalizedFetchRequest,
|
|
166
|
+
metadata: Pick<FetchedContent, "url" | "contentType" | "status" | "redirectCount" | "bytesRead">,
|
|
167
|
+
now: () => Date = () => new Date(),
|
|
168
|
+
notices: readonly FetchWarning[] = [],
|
|
169
|
+
): FetchedContent {
|
|
170
|
+
const sliced = sliceOutput(extracted.content, request.offset, request.maxLength);
|
|
171
|
+
return {
|
|
172
|
+
...metadata,
|
|
173
|
+
...(metadata.url === request.url ? {} : { sourceUrl: request.url }),
|
|
174
|
+
...(extracted.title === undefined ? {} : { title: boundText(extracted.title, MAX_TITLE_LENGTH) }),
|
|
175
|
+
content: sliced.content, contentTrust: "untrusted", outputFormat: extracted.outputFormat,
|
|
176
|
+
extraction: extracted.extraction, fetchedAt: now().toISOString(), truncated: sliced.truncated, offset: sliced.offset,
|
|
177
|
+
...(extracted.documentFormat === undefined ? {} : { documentFormat: extracted.documentFormat }),
|
|
178
|
+
...(sliced.nextOffset === undefined ? {} : { nextOffset: sliced.nextOffset }),
|
|
179
|
+
totalCharacters: extracted.content.length,
|
|
180
|
+
warnings: [
|
|
181
|
+
...notices,
|
|
182
|
+
...(extracted.fellBackToRaw ? [{ code: "raw-fallback" as const, message: "Readable extraction failed; bounded raw HTML was returned" }] : []),
|
|
183
|
+
...(sliced.truncated ? [{ code: "truncated" as const, message: sliced.pagingLimitReached ? "Content was truncated at the maximum paging offset" : "Content was truncated to the configured output bound" }] : []),
|
|
184
|
+
],
|
|
185
|
+
...(extracted.fellBackToRaw ? { fellBackToRaw: true } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function fetchXContent(
|
|
190
|
+
target: XUrl, mode: XReadMode, request: NormalizedFetchRequest,
|
|
191
|
+
signal: AbortSignal, options: FetcherOptions, maxBytes: number, selection?: XBrowserSelection,
|
|
192
|
+
): Promise<FetchedContent> {
|
|
193
|
+
if (mode === "auto" || mode === "browser") {
|
|
194
|
+
// A signed-in X page loads application bundles as well as source content.
|
|
195
|
+
// Keep explicit caller limits, but allow the existing 20 MiB hard cap by default.
|
|
196
|
+
const browser = await selection?.read(target, signal, options.maxResponseBytes ?? MAX_FETCH_RESPONSE_BYTES, options.lookup);
|
|
197
|
+
if (browser) {
|
|
198
|
+
const extracted = await extractWithDeadline(`<html><head><title></title></head><body><article>${browser.html}</article></body></html>`, "text/html", { ...request, readable: false }, signal);
|
|
199
|
+
return finishContent({ ...extracted, title: browser.title, extraction: "x-browser" }, request, {
|
|
200
|
+
url: target.url, status: 200, redirectCount: 0, bytesRead: browser.bytesRead, contentType: "text/html",
|
|
201
|
+
}, options.now, [
|
|
202
|
+
{ code: "authenticated", message: "Read in your authorized browser using its existing session; content may not be public and was not sent to FxTwitter" },
|
|
203
|
+
...(target.kind === "post" ? [{ code: "partial" as const, message: "Only loaded author posts are included; thread completeness is unknown" }] : []),
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
if (mode === "browser") throw new SafeFetchError({ kind: "extraction", message: "X browser is not authorized. Run /search-x connect once, or use public mode for free FxTwitter reads" });
|
|
207
|
+
}
|
|
208
|
+
let bytesRead = 0;
|
|
209
|
+
let redirects = 0;
|
|
210
|
+
const read = async (url: string) => {
|
|
211
|
+
if (signal.aborted) throw new SafeFetchError({ kind: "canceled", message: "X read canceled" });
|
|
212
|
+
const remote = await fetchRemoteUrl(url, {
|
|
213
|
+
signal, lookup: options.lookup, transport: options.transport,
|
|
214
|
+
maxRedirects: url.startsWith("https://api.fxtwitter.com/") ? 0 : 5,
|
|
215
|
+
headers: { "accept-encoding": "identity" },
|
|
216
|
+
});
|
|
217
|
+
const { response } = remote;
|
|
218
|
+
redirects += remote.redirectCount;
|
|
219
|
+
if (response.status < 200 || response.status >= 300) {
|
|
220
|
+
await closeResponseBody(response.body);
|
|
221
|
+
throw new SafeFetchError({ kind: "http", status: response.status, message: `X retrieval returned HTTP ${response.status}`, retryable: response.status === 429 || response.status >= 500 });
|
|
222
|
+
}
|
|
223
|
+
const remaining = maxBytes - bytesRead;
|
|
224
|
+
const declared = parseContentLength(response.headers.get("content-length"));
|
|
225
|
+
if (remaining <= 0 || declared !== undefined && declared > remaining) {
|
|
226
|
+
await closeResponseBody(response.body);
|
|
227
|
+
throw new SafeFetchError({ kind: "responseTooLarge", message: "X retrieval exceeds the aggregate response-byte limit" });
|
|
228
|
+
}
|
|
229
|
+
const bytes = await readBoundedBody(response.body, remaining, signal);
|
|
230
|
+
bytesRead += bytes.byteLength;
|
|
231
|
+
return { bytes, url: remote.url.url.href, status: response.status, redirectCount: remote.redirectCount };
|
|
232
|
+
};
|
|
233
|
+
if (!target.postId) {
|
|
234
|
+
// Article IDs are not post IDs. Try only the supplied public page, never a
|
|
235
|
+
// guessed publishing post or a search that could return unrelated evidence.
|
|
236
|
+
const page = await read(target.url);
|
|
237
|
+
const extracted = await extractWithDeadline(new TextDecoder().decode(page.bytes), "text/html", request, signal);
|
|
238
|
+
if (extracted.extraction !== "x-article") throw new SafeFetchError({ kind: "extraction", message: "X Article body is unavailable. Provide its /handle/article/POSTID or publishing post URL, or authorize the browser with /search-x connect" });
|
|
239
|
+
return finishContent(extracted, request, { url: page.url, contentType: "text/html", status: page.status, bytesRead, redirectCount: redirects }, options.now);
|
|
240
|
+
}
|
|
241
|
+
const result = await readFxTwitter(target, read);
|
|
242
|
+
return finishContent({ content: result.content, title: result.title, outputFormat: "markdown", extraction: "fxtwitter" }, request, {
|
|
243
|
+
url: result.url, contentType: "application/json", status: 200, redirectCount: redirects, bytesRead,
|
|
244
|
+
}, options.now, result.warnings.map((message) => ({ code: "partial", message })));
|
|
245
|
+
}
|
|
246
|
+
|
|
175
247
|
export function validateFetchRequest(request: FetchRequest): NormalizedFetchRequest {
|
|
176
248
|
if (typeof request.url !== "string" || request.url.trim().length === 0) {
|
|
177
249
|
throw new SafeFetchError({ kind: "invalidRequest", message: "URL must not be empty" });
|
|
@@ -192,6 +264,9 @@ export function validateFetchRequest(request: FetchRequest): NormalizedFetchRequ
|
|
|
192
264
|
if (format !== "markdown" && format !== "text" && format !== "html") {
|
|
193
265
|
throw new SafeFetchError({ kind: "invalidRequest", message: "format is not supported" });
|
|
194
266
|
}
|
|
267
|
+
if (request.xMode !== undefined && !["auto", "public", "browser", "direct"].includes(request.xMode)) {
|
|
268
|
+
throw new SafeFetchError({ kind: "invalidRequest", message: "xMode is not supported" });
|
|
269
|
+
}
|
|
195
270
|
const maxPages = request.maxPages;
|
|
196
271
|
if (maxPages !== undefined && (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 500)) {
|
|
197
272
|
throw new SafeFetchError({ kind: "invalidRequest", message: "maxPages must be between 1 and 500" });
|
|
@@ -209,6 +284,7 @@ export function validateFetchRequest(request: FetchRequest): NormalizedFetchRequ
|
|
|
209
284
|
allowRawHtmlFallback: request.allowRawHtmlFallback ?? true,
|
|
210
285
|
maxPages,
|
|
211
286
|
captionLanguage,
|
|
287
|
+
xMode: request.xMode,
|
|
212
288
|
};
|
|
213
289
|
}
|
|
214
290
|
|
|
@@ -270,26 +346,9 @@ async function fetchYouTubeContent(
|
|
|
270
346
|
language: request.captionLanguage,
|
|
271
347
|
maxOutputBytes: options.youtubeMaxOutputBytes,
|
|
272
348
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
url: canonicalUrl,
|
|
277
|
-
...(canonicalUrl === request.url ? {} : { sourceUrl: request.url }),
|
|
278
|
-
content: sliced.content,
|
|
279
|
-
contentTrust: "untrusted",
|
|
280
|
-
contentType: "text/plain",
|
|
281
|
-
outputFormat: "text",
|
|
282
|
-
extraction: "youtube-transcript",
|
|
283
|
-
fetchedAt: now().toISOString(),
|
|
284
|
-
status: 200,
|
|
285
|
-
redirectCount: 0,
|
|
286
|
-
bytesRead: result.bytesRead,
|
|
287
|
-
truncated: sliced.truncated,
|
|
288
|
-
offset: request.offset,
|
|
289
|
-
...(sliced.nextOffset === undefined ? {} : { nextOffset: sliced.nextOffset }),
|
|
290
|
-
totalCharacters: result.text.length,
|
|
291
|
-
warnings: sliced.truncated ? [{ code: "truncated", message: "Content was truncated to the configured output bound" }] : [],
|
|
292
|
-
};
|
|
349
|
+
return finishContent({ content: result.text, outputFormat: "text", extraction: "youtube-transcript" }, request, {
|
|
350
|
+
url: `https://www.youtube.com/watch?v=${result.videoId}`, contentType: "text/plain", status: 200, redirectCount: 0, bytesRead: result.bytesRead,
|
|
351
|
+
}, now);
|
|
293
352
|
}
|
|
294
353
|
|
|
295
354
|
function isPdfUrl(value: string): boolean {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { XBrowserAccess, type XBrowserName } from "./x-browser";
|
|
4
|
+
import { SafeFetchError } from "./fetch-errors";
|
|
2
5
|
import { createAnthropicProvider } from "./anthropic";
|
|
3
6
|
import { createBraveProvider, BraveQuotaTracker } from "./brave";
|
|
4
7
|
import { createBraveAnswersProvider } from "./brave-answers";
|
|
@@ -18,6 +21,7 @@ import { createXAIProvider } from "./xai";
|
|
|
18
21
|
import { availableProviderHints } from "./provider-availability";
|
|
19
22
|
import { registerWebResearch } from "./research-tool";
|
|
20
23
|
import { registerWebSearch } from "./search-tool";
|
|
24
|
+
import { loadDirectCredentials } from "./credentials";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Native grounding is selected for supported active models. Exa is the
|
|
@@ -25,8 +29,10 @@ import { registerWebSearch } from "./search-tool";
|
|
|
25
29
|
* cheaper second metered path, and Brave is the paced last-resort keyword
|
|
26
30
|
* path. Automatic availability failures may use one visible fallback.
|
|
27
31
|
*/
|
|
28
|
-
export default function (pi: ExtensionAPI): void {
|
|
29
|
-
const
|
|
32
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
33
|
+
const agentDir = getAgentDir();
|
|
34
|
+
const credentials = await loadDirectCredentials(agentDir);
|
|
35
|
+
const braveKey = credentials.BRAVE_API_KEY;
|
|
30
36
|
// Brave is a conservative last-resort path: pace starts at 1 RPS and never claims
|
|
31
37
|
// to know the account's billing balance. Set `=0` only with explicit metered
|
|
32
38
|
// opt-in when that pacing is intentionally not wanted.
|
|
@@ -40,15 +46,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
40
46
|
const xaiX = createXAIProvider({ tool: "x_search" });
|
|
41
47
|
const anthropic = createAnthropicProvider();
|
|
42
48
|
const meta = createMetaProvider();
|
|
43
|
-
const exaKey =
|
|
44
|
-
const deepseekKey =
|
|
45
|
-
const parallelKey =
|
|
46
|
-
const xToken =
|
|
49
|
+
const exaKey = credentials.EXA_API_KEY;
|
|
50
|
+
const deepseekKey = credentials.DEEPSEEK_API_KEY;
|
|
51
|
+
const parallelKey = credentials.PARALLEL_API_KEY;
|
|
52
|
+
const xToken = credentials.X_API_BEARER_TOKEN;
|
|
47
53
|
const exa = createExaProvider({ apiKey: exaKey });
|
|
48
54
|
const deepseek = createDeepSeekProvider({ apiKey: deepseekKey });
|
|
49
55
|
const cloudflareUpstream = process.env.PI_SEARCH_CLOUDFLARE_PROVIDER;
|
|
50
56
|
const cloudflareAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
51
|
-
const cloudflareToken =
|
|
57
|
+
const cloudflareToken = credentials.CLOUDFLARE_API_TOKEN;
|
|
52
58
|
// Credentials alone do not enable gateway spending or select Ceramic.
|
|
53
59
|
const cloudflareConfigured = ["ceramic", "exa", "linkup"].includes(cloudflareUpstream ?? "")
|
|
54
60
|
&& Boolean(cloudflareAccount?.trim()) && Boolean(cloudflareToken?.trim());
|
|
@@ -113,7 +119,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
113
119
|
parallelResponses: parallelResponsesEnabled,
|
|
114
120
|
x: xToken !== undefined && xToken.trim().length > 0,
|
|
115
121
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
const xBrowser = new XBrowserAccess(join(agentDir, "pi-search", "x"));
|
|
123
|
+
registerWebSearch(pi, route, { availableProviderHints: availableHints, fetcherOptions: { xBrowser } });
|
|
124
|
+
registerWebFetch(pi, { xBrowser });
|
|
125
|
+
registerWebResearch(pi, route, { availableProviderHints: availableHints, xBrowser });
|
|
126
|
+
pi.on("session_shutdown", async () => { await xBrowser.shutdown(); });
|
|
127
|
+
pi.registerCommand("search-x", {
|
|
128
|
+
description: "X access: connect [brave|chrome|chromium], disconnect, status, auto, public, or direct",
|
|
129
|
+
handler: async (args, ctx) => {
|
|
130
|
+
try {
|
|
131
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
132
|
+
const [command = "status", browser = "brave"] = parts;
|
|
133
|
+
if (parts.length > (command === "connect" ? 2 : 1)) throw new SafeFetchError({ kind: "invalidRequest", message: "Unexpected X command arguments" });
|
|
134
|
+
switch (command) {
|
|
135
|
+
case "connect": {
|
|
136
|
+
if (!ctx.hasUI) throw new SafeFetchError({ kind: "extraction", message: "Run /search-x connect in an interactive Pi session to authorize your existing browser" });
|
|
137
|
+
if (!["brave", "chrome", "chromium"].includes(browser)) throw new SafeFetchError({ kind: "invalidRequest", message: "Choose brave, chrome, or chromium" });
|
|
138
|
+
const authorized = await xBrowser.connect(() => ctx.ui.confirm("Use your existing X login", `Allow pi-search to use ${browser} for read-only X extraction? Enable remote debugging in its inspect page and approve its native connection prompt. Only temporary X tabs are used; no browser is launched and no cookies are copied.`), browser as XBrowserName);
|
|
139
|
+
ctx.ui.notify(authorized ? "Browser connected. Automatic X reads now use its existing login; no paid API is used." : "X browser authorization canceled.", "info");
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "disconnect":
|
|
143
|
+
await xBrowser.disconnect();
|
|
144
|
+
ctx.ui.notify(`X browser access disabled. Your browser and X login are unchanged. ${await xBrowser.status()}`, "info");
|
|
145
|
+
break;
|
|
146
|
+
case "auto": case "public": case "direct":
|
|
147
|
+
await xBrowser.setMode(command);
|
|
148
|
+
ctx.ui.notify(await xBrowser.status(), "info");
|
|
149
|
+
break;
|
|
150
|
+
case "status": ctx.ui.notify(await xBrowser.status(), "info"); break;
|
|
151
|
+
default: ctx.ui.notify("Usage: /search-x connect [brave|chrome|chromium] | disconnect | status | auto | public | direct", "warning");
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
ctx.ui.notify(error instanceof SafeFetchError ? error.message : "X browser operation failed. Check remote debugging and approve the connection in your running browser.", "error");
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
119
158
|
}
|
package/src/parallel.ts
CHANGED
package/src/research-tool.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
1
|
import {
|
|
3
2
|
defineTool,
|
|
4
3
|
type ExtensionAPI,
|
|
@@ -6,7 +5,7 @@ import {
|
|
|
6
5
|
type ToolDefinition,
|
|
7
6
|
} from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
-
import { Type, type Static
|
|
8
|
+
import { Type, type Static } from "typebox";
|
|
10
9
|
import type { FetchedContent, Provider, ProviderUsage, ResearchRequest, ResearchResponse, SearchProviderSelection, SearchRequest, SearchResult, SearchWarning } from "./contracts";
|
|
11
10
|
import { validateResearchBudget } from "./contracts";
|
|
12
11
|
import { toolUsage } from "./grounding";
|
|
@@ -20,12 +19,12 @@ import { renderModelUrl, renderSafeUrl } from "./url-rendering";
|
|
|
20
19
|
import { compactText, sanitizeBlock } from "./render-text";
|
|
21
20
|
import { SEARCH_PROVIDER_HINT_IDS, type SearchProviderHintId } from "./contracts";
|
|
22
21
|
|
|
23
|
-
const ResearchProviderSchema =
|
|
22
|
+
const ResearchProviderSchema = Type.Enum(["native", ...SEARCH_PROVIDER_HINT_IDS] as const, { type: "string", description: "Provider hint; omit for automatic routing" });
|
|
24
23
|
|
|
25
24
|
/** Full tool schema for a given set of dispatchable provider hints. */
|
|
26
25
|
function webResearchParametersFor(hints: readonly SearchProviderHintId[]) {
|
|
27
26
|
const ids = ["native", ...hints] as const;
|
|
28
|
-
const schema =
|
|
27
|
+
const schema = Type.Enum(ids, { type: "string", description: "Provider hint; omit for automatic routing" });
|
|
29
28
|
return Type.Object({
|
|
30
29
|
...WebResearchParameters.properties,
|
|
31
30
|
provider: Type.Optional(schema),
|
|
@@ -457,7 +456,7 @@ export async function executeResearch(
|
|
|
457
456
|
} catch (error) {
|
|
458
457
|
if (error instanceof SearchToolError && error.usage !== undefined) recordUsage(error.usage, estimatedCost);
|
|
459
458
|
else if (estimatedCost > 0) warnings.push(warning("Research retained the failed call's estimated cost reservation; actual billing is unconfirmed"));
|
|
460
|
-
if (deadlineController.signal.aborted || signal?.aborted) {
|
|
459
|
+
if (deadlineController.signal.aborted || signal?.aborted || remaining(deadline) <= 1) {
|
|
461
460
|
stopReason = signal?.aborted ? "canceled" : "deadline";
|
|
462
461
|
warnings.push(warning(`Research search failed for query ${JSON.stringify(query)}: ${error instanceof Error ? error.message : String(error)}`));
|
|
463
462
|
break;
|
|
@@ -511,7 +510,7 @@ export async function executeResearch(
|
|
|
511
510
|
break;
|
|
512
511
|
}
|
|
513
512
|
} catch (error) {
|
|
514
|
-
if (deadlineController.signal.aborted || signal?.aborted) {
|
|
513
|
+
if (deadlineController.signal.aborted || signal?.aborted || remaining(deadline) <= 1) {
|
|
515
514
|
stopReason = signal?.aborted ? "canceled" : "deadline";
|
|
516
515
|
break;
|
|
517
516
|
}
|
|
@@ -520,7 +519,7 @@ export async function executeResearch(
|
|
|
520
519
|
}
|
|
521
520
|
}
|
|
522
521
|
}
|
|
523
|
-
if (deadlineController.signal.aborted && stopReason === "completed") stopReason = signal?.aborted ? "canceled" : "deadline";
|
|
522
|
+
if ((deadlineController.signal.aborted || remaining(deadline) <= 1) && stopReason === "completed") stopReason = signal?.aborted ? "canceled" : "deadline";
|
|
524
523
|
if (stopReason === "completed" && hadFailedFetch) stopReason = "partial";
|
|
525
524
|
return boundedResponse(currentResponse(), normalized.budget.maxOutputChars);
|
|
526
525
|
} finally {
|
package/src/search-tool.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
1
|
import {
|
|
3
2
|
defineTool,
|
|
4
3
|
type ExtensionAPI,
|
|
@@ -6,7 +5,7 @@ import {
|
|
|
6
5
|
type ToolDefinition,
|
|
7
6
|
} from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
-
import { Type, type Static
|
|
8
|
+
import { Type, type Static } from "typebox";
|
|
10
9
|
import { SEARCH_PROVIDER_HINT_IDS, type FetchedContent, type Provider, type ProviderContext, type ProviderModel, type SearchProviderHintId, type SearchProviderSelection, type SearchRequest, type SearchResponse } from "./contracts";
|
|
11
10
|
import { toFetchToolError } from "./fetch-errors";
|
|
12
11
|
import { boundUtf8, fetchContent, type FetcherOptions } from "./fetcher";
|
|
@@ -36,13 +35,13 @@ import {
|
|
|
36
35
|
} from "./search";
|
|
37
36
|
import { MAX_SEARCH_DOMAIN_LENGTH } from "./search-cleanup";
|
|
38
37
|
|
|
39
|
-
const SearchModeSchema =
|
|
40
|
-
const SearchProviderSchema =
|
|
38
|
+
const SearchModeSchema = Type.Enum(["auto", "keyword", "fresh"] as const, { type: "string", description: "Search mode; auto selects the provider path" });
|
|
39
|
+
const SearchProviderSchema = Type.Enum(["native", ...SEARCH_PROVIDER_HINT_IDS] as const, { type: "string", description: "Provider hint; omit for automatic routing" });
|
|
41
40
|
|
|
42
41
|
/** Build the provider-enum schema from hints this installation can dispatch. */
|
|
43
|
-
function providerSchemaFor(hints: readonly SearchProviderHintId[])
|
|
42
|
+
function providerSchemaFor(hints: readonly SearchProviderHintId[]) {
|
|
44
43
|
const ids = ["native", ...hints] as const;
|
|
45
|
-
return
|
|
44
|
+
return Type.Enum(ids, { type: "string", description: "Provider hint; omit for automatic routing" });
|
|
46
45
|
}
|
|
47
46
|
export const SearchDomainsSchema = Type.Object(
|
|
48
47
|
{
|
|
@@ -58,11 +57,11 @@ export const SearchDateRangeSchema = Type.Object(
|
|
|
58
57
|
},
|
|
59
58
|
{ description: "Optional publication date range" },
|
|
60
59
|
);
|
|
61
|
-
const SearchContextSizeSchema =
|
|
62
|
-
const ReturnTokenBudgetSchema =
|
|
60
|
+
const SearchContextSizeSchema = Type.Enum(["low", "medium", "high"] as const, { type: "string", description: "Provider-native search context size" });
|
|
61
|
+
const ReturnTokenBudgetSchema = Type.Enum(["default", "unlimited"] as const, { type: "string", description: "Provider-native returned-token budget" });
|
|
63
62
|
const SearchUserLocationSchema = Type.Object(
|
|
64
63
|
{
|
|
65
|
-
type:
|
|
64
|
+
type: Type.Enum(["approximate"] as const, { type: "string" }),
|
|
66
65
|
country: Type.Optional(Type.String({ minLength: 2, maxLength: 2, description: "Two-letter ISO country code" })),
|
|
67
66
|
region: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_SEARCH_LOCATION_FIELD_LENGTH })),
|
|
68
67
|
city: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_SEARCH_LOCATION_FIELD_LENGTH })),
|
|
@@ -98,12 +97,12 @@ export const WebSearchParameters = Type.Object({
|
|
|
98
97
|
social: Type.Optional(SocialSearchSchema),
|
|
99
98
|
executionModel: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_EXECUTION_MODEL_LENGTH, description: "Model id when deliberately selecting a model-mediated provider" })),
|
|
100
99
|
provider: Type.Optional(SearchProviderSchema),
|
|
101
|
-
answerMode: Type.Optional(
|
|
100
|
+
answerMode: Type.Optional(Type.Enum(["auto", "evidence"] as const, { type: "string", description: "Return a provider answer when available or evidence only" })),
|
|
102
101
|
searchContextSize: Type.Optional(SearchContextSizeSchema),
|
|
103
102
|
returnTokenBudget: Type.Optional(ReturnTokenBudgetSchema),
|
|
104
103
|
externalWebAccess: Type.Optional(Type.Boolean({ description: "Allow live internet access when supported" })),
|
|
105
104
|
userLocation: Type.Optional(SearchUserLocationSchema),
|
|
106
|
-
searchContentTypes: Type.Optional(Type.Array(
|
|
105
|
+
searchContentTypes: Type.Optional(Type.Array(Type.Enum(["text", "image"] as const, { type: "string" }), { minItems: 1, maxItems: MAX_SEARCH_CONTENT_TYPES })),
|
|
107
106
|
imageSettings: Type.Optional(SearchImageSettingsSchema),
|
|
108
107
|
includeContent: Type.Optional(Type.Boolean({ default: false, description: "Fetch selected result pages for source context" })),
|
|
109
108
|
contentResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_CONTENT_RESULTS, default: DEFAULT_CONTENT_RESULTS, description: `Number of result pages to fetch when includeContent is enabled (1-${MAX_CONTENT_RESULTS})` })),
|