@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 +47 -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 +2 -1
- package/src/credentials.ts +134 -0
- package/src/deepseek.ts +1 -1
- package/src/exa.ts +1 -1
- package/src/fetch-tool.ts +3 -4
- package/src/index.ts +11 -8
- 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.ts +1 -1
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
174
|
-
direct providers. A DeepSeek login
|
|
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.
|
|
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.
|
|
45
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
46
|
-
"@earendil-works/pi-tui": "1.
|
|
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",
|
package/src/brave-answers.ts
CHANGED
package/src/brave.ts
CHANGED
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: "
|
|
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
|
-
|
|
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
package/src/exa.ts
CHANGED
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
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
47
|
-
const deepseekKey =
|
|
48
|
-
const parallelKey =
|
|
49
|
-
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;
|
|
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 =
|
|
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(
|
|
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 });
|
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})` })),
|