test: add web search config loader
This commit is contained in:
25
.pi/agent/extensions/web-search/package.json
Normal file
25
.pi/agent/extensions/web-search/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pi-web-search-extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tsx --test src/**/*.test.ts"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"exa-js": "^2.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||
"@mariozechner/pi-tui": "^0.66.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
71
.pi/agent/extensions/web-search/src/config.test.ts
Normal file
71
.pi/agent/extensions/web-search/src/config.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadWebSearchConfig, WebSearchConfigError } from "./config.ts";
|
||||
|
||||
async function writeTempConfig(contents: unknown) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-search-config-"));
|
||||
const file = join(dir, "web-search.json");
|
||||
const body = typeof contents === "string" ? contents : JSON.stringify(contents, null, 2);
|
||||
await writeFile(file, body, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
test("loadWebSearchConfig returns a normalized default provider and provider lookup", async () => {
|
||||
const file = await writeTempConfig({
|
||||
defaultProvider: "exa-main",
|
||||
providers: [
|
||||
{
|
||||
name: "exa-main",
|
||||
type: "exa",
|
||||
apiKey: "exa-test-key",
|
||||
options: {
|
||||
defaultSearchLimit: 7,
|
||||
defaultFetchTextMaxCharacters: 9000,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const config = await loadWebSearchConfig(file);
|
||||
|
||||
assert.equal(config.defaultProviderName, "exa-main");
|
||||
assert.equal(config.defaultProvider.name, "exa-main");
|
||||
assert.equal(config.providersByName.get("exa-main")?.apiKey, "exa-test-key");
|
||||
assert.equal(config.providers[0]?.options?.defaultSearchLimit, 7);
|
||||
});
|
||||
|
||||
test("loadWebSearchConfig rejects a missing default provider target", async () => {
|
||||
const file = await writeTempConfig({
|
||||
defaultProvider: "missing",
|
||||
providers: [
|
||||
{
|
||||
name: "exa-main",
|
||||
type: "exa",
|
||||
apiKey: "exa-test-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => loadWebSearchConfig(file),
|
||||
(error) =>
|
||||
error instanceof WebSearchConfigError &&
|
||||
/defaultProvider \"missing\"/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test("loadWebSearchConfig rejects a missing file with a helpful example message", async () => {
|
||||
const file = join(tmpdir(), "pi-web-search-does-not-exist.json");
|
||||
|
||||
await assert.rejects(
|
||||
() => loadWebSearchConfig(file),
|
||||
(error) =>
|
||||
error instanceof WebSearchConfigError &&
|
||||
error.message.includes(file) &&
|
||||
error.message.includes('"defaultProvider"') &&
|
||||
error.message.includes('"providers"'),
|
||||
);
|
||||
});
|
||||
100
.pi/agent/extensions/web-search/src/config.ts
Normal file
100
.pi/agent/extensions/web-search/src/config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { WebSearchConfigSchema, type ExaProviderConfig, type WebSearchConfig } from "./schema.ts";
|
||||
|
||||
export interface ResolvedWebSearchConfig {
|
||||
path: string;
|
||||
defaultProviderName: string;
|
||||
defaultProvider: ExaProviderConfig;
|
||||
providers: ExaProviderConfig[];
|
||||
providersByName: Map<string, ExaProviderConfig>;
|
||||
}
|
||||
|
||||
export class WebSearchConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WebSearchConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultWebSearchConfigPath() {
|
||||
return join(homedir(), ".pi", "agent", "web-search.json");
|
||||
}
|
||||
|
||||
function exampleConfigSnippet() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
defaultProvider: "exa-main",
|
||||
providers: [
|
||||
{
|
||||
name: "exa-main",
|
||||
type: "exa",
|
||||
apiKey: "exa_...",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig {
|
||||
const providersByName = new Map<string, ExaProviderConfig>();
|
||||
|
||||
for (const provider of config.providers) {
|
||||
if (!provider.apiKey.trim()) {
|
||||
throw new WebSearchConfigError(`Provider \"${provider.name}\" in ${path} is missing a literal apiKey.`);
|
||||
}
|
||||
if (providersByName.has(provider.name)) {
|
||||
throw new WebSearchConfigError(`Duplicate provider name \"${provider.name}\" in ${path}.`);
|
||||
}
|
||||
providersByName.set(provider.name, provider);
|
||||
}
|
||||
|
||||
const defaultProvider = providersByName.get(config.defaultProvider);
|
||||
if (!defaultProvider) {
|
||||
throw new WebSearchConfigError(
|
||||
`defaultProvider \"${config.defaultProvider}\" does not match any configured provider in ${path}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
defaultProviderName: config.defaultProvider,
|
||||
defaultProvider,
|
||||
providers: [...providersByName.values()],
|
||||
providersByName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new WebSearchConfigError(
|
||||
`Missing web-search config at ${path}.\nCreate ${path} with contents like:\n${exampleConfigSnippet()}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new WebSearchConfigError(`Invalid JSON in ${path}: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
if (!Value.Check(WebSearchConfigSchema, parsed)) {
|
||||
const [firstError] = [...Value.Errors(WebSearchConfigSchema, parsed)];
|
||||
throw new WebSearchConfigError(
|
||||
`Invalid web-search config at ${path}: ${firstError?.path ?? "/"} ${firstError?.message ?? "failed validation"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeWebSearchConfig(parsed as WebSearchConfig, path);
|
||||
}
|
||||
45
.pi/agent/extensions/web-search/src/schema.ts
Normal file
45
.pi/agent/extensions/web-search/src/schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const ProviderOptionsSchema = Type.Object({
|
||||
defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
defaultFetchHighlightsMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
});
|
||||
|
||||
export const ExaProviderConfigSchema = Type.Object({
|
||||
name: Type.String({ minLength: 1 }),
|
||||
type: Type.Literal("exa"),
|
||||
apiKey: Type.String({ minLength: 1 }),
|
||||
options: Type.Optional(ProviderOptionsSchema),
|
||||
});
|
||||
|
||||
export const WebSearchConfigSchema = Type.Object({
|
||||
defaultProvider: Type.String({ minLength: 1 }),
|
||||
providers: Type.Array(ExaProviderConfigSchema, { minItems: 1 }),
|
||||
});
|
||||
|
||||
export const WebSearchParamsSchema = Type.Object({
|
||||
query: Type.String({ minLength: 1, description: "Search query" }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 25 })),
|
||||
includeDomains: Type.Optional(Type.Array(Type.String())),
|
||||
excludeDomains: Type.Optional(Type.Array(Type.String())),
|
||||
startPublishedDate: Type.Optional(Type.String()),
|
||||
endPublishedDate: Type.Optional(Type.String()),
|
||||
category: Type.Optional(Type.String()),
|
||||
provider: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export const WebFetchParamsSchema = Type.Object({
|
||||
urls: Type.Array(Type.String(), { minItems: 1 }),
|
||||
text: Type.Optional(Type.Boolean()),
|
||||
highlights: Type.Optional(Type.Boolean()),
|
||||
summary: Type.Optional(Type.Boolean()),
|
||||
textMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
provider: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export type ProviderOptions = Static<typeof ProviderOptionsSchema>;
|
||||
export type ExaProviderConfig = Static<typeof ExaProviderConfigSchema>;
|
||||
export type WebSearchConfig = Static<typeof WebSearchConfigSchema>;
|
||||
export type WebSearchParams = Static<typeof WebSearchParamsSchema>;
|
||||
export type WebFetchParams = Static<typeof WebFetchParamsSchema>;
|
||||
1498
docs/superpowers/plans/2026-04-09-web-search-tools.md
Normal file
1498
docs/superpowers/plans/2026-04-09-web-search-tools.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user