@nijaru/pi-search 0.0.8 → 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 CHANGED
@@ -56,11 +56,50 @@ location, live-access, and content-type controls when requested.
56
56
 
57
57
  ## Configuration
58
58
 
59
- The extension reads credentials only at construction or through Pi's model
60
- registry; it never logs keys. Keys can live in a shell-local secret injector
61
- (e.g. `fnox`) rather than global environment files — because providers are
62
- registered only when their key is present at construction, an unset
63
- variable simply leaves that provider disabled.
59
+ Direct-provider keys can come from environment variables or from shell
60
+ commands in the optional user-only file
61
+ `~/.pi/agent/pi-search/credentials.json` (under `getAgentDir()` when
62
+ `PI_CODING_AGENT_DIR` is set). For example, with Fnox installed:
63
+
64
+ ```json
65
+ {
66
+ "EXA_API_KEY": "!fnox --config \"$HOME/.config/fnox/config.toml\" --profile pi --no-defaults --if-missing error --non-interactive get EXA_API_KEY",
67
+ "BRAVE_API_KEY": "!fnox --config \"$HOME/.config/fnox/config.toml\" --profile pi --no-defaults --if-missing error --non-interactive get BRAVE_API_KEY"
68
+ }
69
+ ```
70
+
71
+ Any shell command that prints a key can be used; Fnox is not a dependency.
72
+ The file must be a flat object whose only supported names are `BRAVE_API_KEY`,
73
+ `EXA_API_KEY`, `DEEPSEEK_API_KEY`, `PARALLEL_API_KEY`, `X_API_BEARER_TOKEN`, and
74
+ `CLOUDFLARE_API_TOKEN`. Values must be nonempty `!<shell command>` references,
75
+ not plaintext keys. Do not embed secrets in the commands either.
76
+ `CLOUDFLARE_ACCOUNT_ID` and all billing/enable settings remain environment
77
+ variables; credentials alone do not enable spending or opt-in providers.
78
+
79
+ **Treat this file as executable code.** Keep it writable only by your user and
80
+ review its commands. No project credentials file is loaded. Commands run with
81
+ Pi's permissions and inherited environment, from the agent directory, using
82
+ `/bin/sh` on macOS/Linux or `cmd.exe` on Windows—not your interactive Fish
83
+ configuration. Use installed executables on Pi's `PATH` or absolute paths;
84
+ commands must not require terminal input.
85
+
86
+ Commands run once per extension load, concurrently, before tools are registered,
87
+ including loads outside a session. Each has a 10-second timeout and a 16 KiB
88
+ stdout limit. Trimmed stdout becomes the private adapter credential; stderr is
89
+ discarded. Keys are not written to `process.env` or logged. Ordinary subprocess
90
+ cleanup uses a dedicated process group on macOS/Linux and `taskkill /t` on
91
+ Windows; commands must not launch detached daemons. Windows tree cleanup after
92
+ a shell has already exited is best-effort.
93
+
94
+ A configured command overrides the corresponding inherited variable. Invalid
95
+ JSON, unsupported names, literal/nonstring values, command failure, empty
96
+ stdout, timeout, or excessive output fails extension loading with a sanitized
97
+ error—never an env fallback. A missing file or omitted entry preserves env
98
+ support, including its existing whitespace handling. Restart Pi or run `/reload`
99
+ to pick up changes or refreshed keys; there is no per-request refresh. Native
100
+ provider auth still resolves through Pi's model registry at request time.
101
+
102
+ Alternatively, configure direct keys and routing settings in the environment:
64
103
 
65
104
  ```bash
66
105
  # Optional metered search for non-native/local models
@@ -170,9 +209,9 @@ are then enforced locally on results that report a date. Results with a known
170
209
  publication date are filtered to the requested inclusive range; undated
171
210
  results remain visible with an explicit warning.
172
211
 
173
- The adapter reads `DEEPSEEK_API_KEY` from the environment, like the other
174
- direct providers. A DeepSeek login stored only in Pi's model registry does not
175
- register it.
212
+ The adapter receives `DEEPSEEK_API_KEY` from the environment or the user
213
+ credentials command file, like the other direct providers. A DeepSeek login
214
+ stored only in Pi's model registry does not register it.
176
215
 
177
216
  ### Local and virtual models
178
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-search",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Provider-neutral web search and fetching tools for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,9 +41,9 @@
41
41
  "live:smoke": "bun scripts/live-smoke.ts"
42
42
  },
43
43
  "devDependencies": {
44
- "@earendil-works/pi-ai": "1.4.0",
45
- "@earendil-works/pi-coding-agent": "1.4.0",
46
- "@earendil-works/pi-tui": "1.4.0",
44
+ "@earendil-works/pi-ai": "1.5.0",
45
+ "@earendil-works/pi-coding-agent": "1.5.0",
46
+ "@earendil-works/pi-tui": "1.5.0",
47
47
  "@types/bun": "1.3.14",
48
48
  "@types/turndown": "^5.0.6",
49
49
  "typebox": "1.3.10",
@@ -53,7 +53,7 @@ const capabilities: ProviderCapabilities = {
53
53
  };
54
54
 
55
55
  const profile: ProviderProfile = {
56
- auth: "environment",
56
+ auth: "direct",
57
57
  costModel: "usage-based",
58
58
  };
59
59
 
package/src/brave.ts CHANGED
@@ -164,7 +164,7 @@ const capabilities: ProviderCapabilities = {
164
164
  };
165
165
 
166
166
  const profile: ProviderProfile = {
167
- auth: "environment",
167
+ auth: "direct",
168
168
  costModel: "per-request",
169
169
  };
170
170
 
package/src/cloudflare.ts CHANGED
@@ -37,7 +37,7 @@ export interface CloudflareAdapterOptions {
37
37
 
38
38
  const capabilities: ProviderCapabilities = { semantic: true, excerpts: true };
39
39
  // BYOK agreements and gateway credits do not establish actual billed costs.
40
- const profile: ProviderProfile = { auth: "environment", costModel: "unknown" };
40
+ const profile: ProviderProfile = { auth: "direct", costModel: "unknown" };
41
41
  const unsupportedOptions = [
42
42
  "domains", "dateRange", "social", "executionModel", "searchContextSize",
43
43
  "returnTokenBudget", "externalWebAccess", "userLocation", "searchContentTypes", "imageSettings",
package/src/contracts.ts CHANGED
@@ -95,7 +95,8 @@ export interface ProviderProfile {
95
95
  readonly typicalLatencyMs?: number;
96
96
  readonly estimatedCostUsd?: number;
97
97
  readonly costModel: "free" | "per-request" | "usage-based" | "unknown";
98
- readonly auth: "none" | "environment" | "modelRegistry";
98
+ /** Direct credentials are supplied at construction, from env or a user command. */
99
+ readonly auth: "none" | "direct" | "modelRegistry";
99
100
  }
100
101
 
101
102
  /** A single provider-reported quota or rate-limit window. */
@@ -0,0 +1,134 @@
1
+ import { spawn, execFile, type ChildProcess } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ const SECRET_NAMES = [
6
+ "BRAVE_API_KEY",
7
+ "EXA_API_KEY",
8
+ "DEEPSEEK_API_KEY",
9
+ "PARALLEL_API_KEY",
10
+ "X_API_BEARER_TOKEN",
11
+ "CLOUDFLARE_API_TOKEN",
12
+ ] as const;
13
+ type SecretName = (typeof SECRET_NAMES)[number];
14
+ export type DirectCredentials = Readonly<Partial<Record<SecretName, string>>>;
15
+ const TIMEOUT_MS = 10_000;
16
+ const MAX_STDOUT_BYTES = 16_384;
17
+
18
+ function configError(message: string): Error {
19
+ // Never attach original errors: JSON parsers and subprocess errors can include secrets.
20
+ return new Error(`pi-search credentials.json: ${message}`);
21
+ }
22
+
23
+ async function killProcessTree(child: ChildProcess): Promise<void> {
24
+ if (child.pid === undefined) return;
25
+ if (process.platform === "win32") {
26
+ await new Promise<void>((resolve) => {
27
+ execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
28
+ timeout: 1_000, windowsHide: true, maxBuffer: 1_024,
29
+ }, () => resolve());
30
+ });
31
+ } else {
32
+ // The shell and its ordinary descendants share a dedicated process group.
33
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* group already gone */ }
34
+ }
35
+ try { child.kill("SIGKILL"); } catch { /* process already gone */ }
36
+ }
37
+
38
+ function resolveCommand(name: SecretName, command: string, agentDir: string, env: NodeJS.ProcessEnv): Promise<string> {
39
+ return new Promise((resolve, reject) => {
40
+ let child: ChildProcess;
41
+ try {
42
+ child = spawn(command, {
43
+ shell: true,
44
+ cwd: agentDir,
45
+ env,
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ detached: process.platform !== "win32",
48
+ windowsHide: true,
49
+ });
50
+ } catch {
51
+ reject(configError(`${name} command could not start`));
52
+ return;
53
+ }
54
+ const chunks: Buffer[] = [];
55
+ let bytes = 0;
56
+ let failure: Error | undefined;
57
+ let cleanup: Promise<void> | undefined;
58
+ let settled = false;
59
+ let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
60
+ const stop = (): Promise<void> => cleanup ??= killProcessTree(child);
61
+ const finish = async (code: number | null): Promise<void> => {
62
+ if (settled) return;
63
+ settled = true;
64
+ clearTimeout(timer);
65
+ if (cleanupTimer !== undefined) clearTimeout(cleanupTimer);
66
+ await stop();
67
+ child.stdout?.destroy();
68
+ if (failure !== undefined) return reject(failure);
69
+ if (code !== 0) return reject(configError(`${name} command failed`));
70
+ const value = Buffer.concat(chunks, bytes).toString("utf8").trim();
71
+ if (value.length === 0) return reject(configError(`${name} command returned empty output`));
72
+ resolve(value);
73
+ };
74
+ const fail = (reason: string): void => {
75
+ if (settled || failure !== undefined) return;
76
+ failure = configError(`${name} command ${reason}`);
77
+ void stop();
78
+ child.stdout?.destroy();
79
+ // A detached descendant can retain a pipe after the shell has exited.
80
+ cleanupTimer = setTimeout(() => { void finish(null); }, 1_500);
81
+ };
82
+ const timer = setTimeout(() => fail("timed out (10s limit)"), TIMEOUT_MS);
83
+ child.stdout?.on("data", (chunk: Buffer) => {
84
+ if (failure !== undefined || settled) return;
85
+ bytes += chunk.length;
86
+ if (bytes > MAX_STDOUT_BYTES) return fail("exceeded the stdout limit (16 KiB)");
87
+ chunks.push(chunk);
88
+ });
89
+ child.stdout?.once("error", () => fail("output could not be read"));
90
+ child.once("error", () => fail("could not start"));
91
+ // Clean up background children even when the shell exits successfully.
92
+ child.once("exit", () => { void stop(); });
93
+ child.once("close", (code) => { void finish(code); });
94
+ });
95
+ }
96
+
97
+ /** Load only the user agent directory; never consult project config or mutate the environment. */
98
+ export async function loadDirectCredentials(agentDir: string, env: NodeJS.ProcessEnv = process.env): Promise<DirectCredentials> {
99
+ const inherited = { ...env };
100
+ const credentials: Partial<Record<SecretName, string>> = {};
101
+ for (const name of SECRET_NAMES) {
102
+ // Keep existing env semantics: whitespace-only values are unavailable, but
103
+ // nonempty values are passed to adapters unchanged.
104
+ if (inherited[name] !== undefined) credentials[name] = inherited[name];
105
+ }
106
+ let text: string;
107
+ try {
108
+ text = await readFile(join(agentDir, "pi-search", "credentials.json"), "utf8");
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return credentials;
111
+ throw configError("could not read configuration");
112
+ }
113
+ let config: unknown;
114
+ try { config = JSON.parse(text); } catch { throw configError("invalid JSON"); }
115
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
116
+ throw configError("expected a flat object of credential commands");
117
+ }
118
+ const commands: [SecretName, string][] = [];
119
+ for (const [name, value] of Object.entries(config)) {
120
+ if (!SECRET_NAMES.includes(name as SecretName)) throw configError("unsupported credential name");
121
+ if (typeof value !== "string" || !value.startsWith("!") || value.slice(1).trim().length === 0 || value.includes("\0")) {
122
+ throw configError(`${name} must be a nonempty !<shell command> reference, not a literal secret`);
123
+ }
124
+ commands.push([name as SecretName, value.slice(1)]);
125
+ }
126
+ // Validate everything first, then wait for every bounded command to clean up,
127
+ // including when another command fails. At most six commands run concurrently.
128
+ const results = await Promise.allSettled(commands.map(([name, command]) => resolveCommand(name, command, agentDir, inherited)));
129
+ for (const [index, result] of results.entries()) {
130
+ if (result.status === "rejected") throw result.reason;
131
+ credentials[commands[index]![0]] = result.value;
132
+ }
133
+ return credentials;
134
+ }
package/src/deepseek.ts CHANGED
@@ -41,7 +41,7 @@ const capabilities: ProviderCapabilities = {
41
41
  };
42
42
 
43
43
  const profile: ProviderProfile = {
44
- auth: "environment",
44
+ auth: "direct",
45
45
  costModel: "per-request",
46
46
  estimatedCostUsd: DEEPSEEK_ESTIMATED_SEARCH_COST_USD,
47
47
  };
package/src/exa.ts CHANGED
@@ -36,7 +36,7 @@ const capabilities: ProviderCapabilities = {
36
36
  };
37
37
 
38
38
  const profile: ProviderProfile = {
39
- auth: "environment",
39
+ auth: "direct",
40
40
  costModel: "usage-based",
41
41
  estimatedCostUsd: EXA_ESTIMATED_SEARCH_COST_USD,
42
42
  };
package/src/fetch-tool.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
1
  import { defineTool, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
2
  import { Text } from "@earendil-works/pi-tui";
4
- import { Type, type Static, type TUnsafe } from "typebox";
3
+ import { Type, type Static } from "typebox";
5
4
  import type { FetchRequest, FetchedContent } from "./contracts";
6
5
  import { toFetchToolError } from "./fetch-errors";
7
6
  import {
@@ -15,7 +14,7 @@ import {
15
14
  import { renderModelUrl, renderSafeUrl } from "./url-rendering";
16
15
  import { compactText } from "./render-text";
17
16
 
18
- const FetchFormatSchema = StringEnum(["markdown", "text", "html"] as const) as TUnsafe<"markdown" | "text" | "html">;
17
+ const FetchFormatSchema = Type.Enum(["markdown", "text", "html"] as const, { type: "string" });
19
18
 
20
19
  export const WebFetchParameters = Type.Object({
21
20
  url: Type.String({ minLength: 1, maxLength: 8_192, description: "HTTP(S) URL to fetch" }),
@@ -24,7 +23,7 @@ export const WebFetchParameters = Type.Object({
24
23
  ),
25
24
  offset: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_FETCH_OFFSET, description: `Character offset for bounded paging (0-${MAX_FETCH_OFFSET})` })),
26
25
  format: Type.Optional(FetchFormatSchema),
27
- xMode: Type.Optional(StringEnum(["auto", "public", "browser", "direct"] as const, { description: "X URLs: auto prefers your authorized browser, otherwise free FxTwitter (sends the post ID). public uses FxTwitter; browser requires /search-x connect; direct bypasses both. No paid fallback." }) as TUnsafe<"auto" | "public" | "browser" | "direct">),
26
+ xMode: Type.Optional(Type.Enum(["auto", "public", "browser", "direct"] as const, { type: "string", description: "X URLs: auto prefers your authorized browser, otherwise free FxTwitter (sends the post ID). public uses FxTwitter; browser requires /search-x connect; direct bypasses both. No paid fallback." })),
28
27
  maxPages: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, description: "Maximum PDF pages to parse with the bounded page-limited PDF path" })),
29
28
  captionLanguage: Type.Optional(Type.String({ minLength: 1, maxLength: 32, description: "YouTube caption language (default en)" })),
30
29
  readable: Type.Optional(Type.Boolean({ description: "Extract the main readable article content (default true)" })),
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { createXAIProvider } from "./xai";
21
21
  import { availableProviderHints } from "./provider-availability";
22
22
  import { registerWebResearch } from "./research-tool";
23
23
  import { registerWebSearch } from "./search-tool";
24
+ import { loadDirectCredentials } from "./credentials";
24
25
 
25
26
  /**
26
27
  * Native grounding is selected for supported active models. Exa is the
@@ -28,8 +29,10 @@ import { registerWebSearch } from "./search-tool";
28
29
  * cheaper second metered path, and Brave is the paced last-resort keyword
29
30
  * path. Automatic availability failures may use one visible fallback.
30
31
  */
31
- export default function (pi: ExtensionAPI): void {
32
- 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;
33
36
  // Brave is a conservative last-resort path: pace starts at 1 RPS and never claims
34
37
  // to know the account's billing balance. Set `=0` only with explicit metered
35
38
  // opt-in when that pacing is intentionally not wanted.
@@ -43,15 +46,15 @@ export default function (pi: ExtensionAPI): void {
43
46
  const xaiX = createXAIProvider({ tool: "x_search" });
44
47
  const anthropic = createAnthropicProvider();
45
48
  const meta = createMetaProvider();
46
- const exaKey = process.env.EXA_API_KEY;
47
- const deepseekKey = process.env.DEEPSEEK_API_KEY;
48
- const parallelKey = process.env.PARALLEL_API_KEY;
49
- 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;
50
53
  const exa = createExaProvider({ apiKey: exaKey });
51
54
  const deepseek = createDeepSeekProvider({ apiKey: deepseekKey });
52
55
  const cloudflareUpstream = process.env.PI_SEARCH_CLOUDFLARE_PROVIDER;
53
56
  const cloudflareAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
54
- const cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
57
+ const cloudflareToken = credentials.CLOUDFLARE_API_TOKEN;
55
58
  // Credentials alone do not enable gateway spending or select Ceramic.
56
59
  const cloudflareConfigured = ["ceramic", "exa", "linkup"].includes(cloudflareUpstream ?? "")
57
60
  && Boolean(cloudflareAccount?.trim()) && Boolean(cloudflareToken?.trim());
@@ -116,7 +119,7 @@ export default function (pi: ExtensionAPI): void {
116
119
  parallelResponses: parallelResponsesEnabled,
117
120
  x: xToken !== undefined && xToken.trim().length > 0,
118
121
  });
119
- const xBrowser = new XBrowserAccess(join(getAgentDir(), "pi-search", "x"));
122
+ const xBrowser = new XBrowserAccess(join(agentDir, "pi-search", "x"));
120
123
  registerWebSearch(pi, route, { availableProviderHints: availableHints, fetcherOptions: { xBrowser } });
121
124
  registerWebFetch(pi, { xBrowser });
122
125
  registerWebResearch(pi, route, { availableProviderHints: availableHints, xBrowser });
@@ -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})` })),
package/src/x.ts CHANGED
@@ -36,7 +36,7 @@ const capabilities: ProviderCapabilities = {
36
36
  };
37
37
 
38
38
  const profile: ProviderProfile = {
39
- auth: "environment",
39
+ auth: "direct",
40
40
  costModel: "usage-based",
41
41
  };
42
42