@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/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: "readability" | "raw" | "plain-text" | "markdown" | "document" | "pdf" | "youtube-transcript";
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
- const sliced = sliceOutput(extracted.content, normalized.offset, normalized.maxLength);
134
- const warnings = [
135
- ...(extracted.fellBackToRaw ? [{ code: "raw-fallback" as const, message: "Readable extraction failed; bounded raw HTML was returned" }] : []),
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
- const sliced = sliceOutput(result.text, request.offset, request.maxLength);
274
- const canonicalUrl = `https://www.youtube.com/watch?v=${result.videoId}`;
275
- return {
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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 braveKey = process.env.BRAVE_API_KEY;
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 = process.env.EXA_API_KEY;
44
- const deepseekKey = process.env.DEEPSEEK_API_KEY;
45
- const parallelKey = process.env.PARALLEL_API_KEY;
46
- const xToken = process.env.X_API_BEARER_TOKEN;
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 = process.env.CLOUDFLARE_API_TOKEN;
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
- registerWebSearch(pi, route, { availableProviderHints: availableHints });
117
- registerWebFetch(pi);
118
- registerWebResearch(pi, route, { availableProviderHints: availableHints });
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
  }
@@ -50,7 +50,7 @@ const capabilities: ProviderCapabilities = {
50
50
  };
51
51
 
52
52
  const profile: ProviderProfile = {
53
- auth: "environment",
53
+ auth: "direct",
54
54
  costModel: "usage-based",
55
55
  };
56
56
 
package/src/parallel.ts CHANGED
@@ -34,7 +34,7 @@ const capabilities: ProviderCapabilities = {
34
34
  };
35
35
 
36
36
  const profile: ProviderProfile = {
37
- auth: "environment",
37
+ auth: "direct",
38
38
  costModel: "usage-based",
39
39
  };
40
40
 
@@ -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, type TUnsafe } from "typebox";
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 = StringEnum(["native", ...SEARCH_PROVIDER_HINT_IDS] as const, { description: "Provider hint; omit for automatic routing" }) as TUnsafe<"native" | SearchProviderHintId>;
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 = StringEnum(ids as unknown as readonly string[], { description: "Provider hint; omit for automatic routing" }) as TUnsafe<"native" | SearchProviderHintId>;
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 {
@@ -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, type TUnsafe } from "typebox";
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 = StringEnum(["auto", "keyword", "fresh"] as const, { description: "Search mode; auto selects the provider path" }) as TUnsafe<"auto" | "keyword" | "fresh">;
40
- const SearchProviderSchema = StringEnum(["native", ...SEARCH_PROVIDER_HINT_IDS] as const, { description: "Provider hint; omit for automatic routing" }) as TUnsafe<"native" | SearchProviderHintId>;
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[]): TUnsafe<"native" | SearchProviderHintId> {
42
+ function providerSchemaFor(hints: readonly SearchProviderHintId[]) {
44
43
  const ids = ["native", ...hints] as const;
45
- return StringEnum(ids as unknown as readonly string[], { description: "Provider hint; omit for automatic routing" }) as TUnsafe<"native" | SearchProviderHintId>;
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 = StringEnum(["low", "medium", "high"] as const, { description: "Provider-native search context size" }) as TUnsafe<"low" | "medium" | "high">;
62
- const ReturnTokenBudgetSchema = StringEnum(["default", "unlimited"] as const, { description: "Provider-native returned-token budget" }) as TUnsafe<"default" | "unlimited">;
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: StringEnum(["approximate"] as const) as TUnsafe<"approximate">,
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(StringEnum(["auto", "evidence"] as const, { description: "Return a provider answer when available or evidence only" }) as TUnsafe<"auto" | "evidence">),
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(StringEnum(["text", "image"] as const) as TUnsafe<"text" | "image">, { minItems: 1, maxItems: MAX_SEARCH_CONTENT_TYPES })),
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})` })),