Add Tavily-search
This commit is contained in:
19
extensions/tavily-search/CHANGELOG.md
Normal file
19
extensions/tavily-search/CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.0](https://github.com/jmcombs/pi-extensions/compare/tavily-search/v1.0.0...tavily-search/v2.0.0) (2026-05-04)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **tavily-search:** Consumers running Node 20 must upgrade to Node 22 or later before installing this version.
|
||||
|
||||
### Features
|
||||
|
||||
* **tavily-search:** require Node >=22.0.0 ([416bb91](https://github.com/jmcombs/pi-extensions/commit/416bb9140d40b48494308e4256df7c6f35c5dc2e))
|
||||
|
||||
## 1.0.0 (2026-05-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **tavily-search:** initial Tavily web search extension ([a7cfc69](https://github.com/jmcombs/pi-extensions/commit/a7cfc694f0fe972eea4a7e02550a4074cdb1c025))
|
||||
21
extensions/tavily-search/LICENSE
Normal file
21
extensions/tavily-search/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jeremy Combs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
123
extensions/tavily-search/README.md
Normal file
123
extensions/tavily-search/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @jmcombs/pi-tavily-search
|
||||
|
||||
A [Pi coding agent](https://pi.dev) extension that adds real-time web search via the
|
||||
[Tavily API](https://tavily.com).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Globally (recommended)
|
||||
pi install npm:@jmcombs/pi-tavily-search
|
||||
|
||||
# For a single session, without installing
|
||||
pi -e npm:@jmcombs/pi-tavily-search
|
||||
```
|
||||
|
||||
A Tavily API key is required. [Sign up at tavily.com](https://tavily.com) (free tier
|
||||
available) to get one, then configure it using one of the methods below.
|
||||
|
||||
## What It Adds
|
||||
|
||||
- **Tool**: `tavily_search` — performs an advanced Tavily web search and returns up to
|
||||
five formatted results (title, URL, content) plus the raw API response under
|
||||
`details.raw`. The tool is callable by the LLM whenever it needs current
|
||||
information from the public web.
|
||||
|
||||
## Configuration
|
||||
|
||||
You must configure a Tavily API key. Pi resolves the key in this order:
|
||||
|
||||
1. `AuthStorage` under the `tavily` key (`~/.pi/agent/auth.json`) — **recommended**.
|
||||
2. The `TAVILY_API_KEY` environment variable.
|
||||
|
||||
### Option 1 — `~/.pi/agent/auth.json` (recommended)
|
||||
|
||||
#### Plain key
|
||||
|
||||
```json
|
||||
{
|
||||
"tavily": {
|
||||
"type": "api_key",
|
||||
"key": "tvly-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Shell-resolved key (macOS Keychain)
|
||||
|
||||
```json
|
||||
{
|
||||
"tavily": {
|
||||
"type": "api_key",
|
||||
"key": "!security find-generic-password -ws tavily"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Shell-resolved key (1Password)
|
||||
|
||||
```json
|
||||
{
|
||||
"tavily": {
|
||||
"type": "api_key",
|
||||
"key": "!op read 'op://Personal/tavily/credential'"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Shell-resolved key (`pass`)
|
||||
|
||||
```json
|
||||
{
|
||||
"tavily": {
|
||||
"type": "api_key",
|
||||
"key": "!pass show tavily"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `!`-prefixed value is executed by your shell at lookup time, so no secret is
|
||||
ever stored on disk in plaintext.
|
||||
|
||||
### Option 2 — environment variable
|
||||
|
||||
```bash
|
||||
export TAVILY_API_KEY="tvly-..."
|
||||
```
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- Search depth: `advanced`
|
||||
- Max results returned: 5
|
||||
- The tool honors Pi's abort signal — pressing **Esc** during a search cancels the
|
||||
HTTP request.
|
||||
- If the API key is missing the tool returns an error result with a helpful
|
||||
configuration hint instead of throwing.
|
||||
- Non-2xx responses from Tavily surface as tool errors (with status, status text,
|
||||
and response body) rather than throwing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pi `>= 0.72.0` (uses `AuthStorage` and `ExtensionAPI`)
|
||||
- Node `>= 20.6.0`
|
||||
- A Tavily API key
|
||||
|
||||
## Development
|
||||
|
||||
This package lives in the [pi-extensions monorepo](https://github.com/jmcombs/pi-extensions).
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
npm ci
|
||||
npm run check # full quality gate
|
||||
|
||||
# Try local changes against a real pi session
|
||||
pi -e ./packages/tavily-search
|
||||
```
|
||||
|
||||
The smoke test in `index.test.ts` does **not** mock the Tavily API; it only
|
||||
verifies registration shape. Real end-to-end behavior is exercised via `pi -e`.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE) © Jeremy Combs
|
||||
98
extensions/tavily-search/index.test.ts
Normal file
98
extensions/tavily-search/index.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Smoke test for @jmcombs/pi-tavily-search.
|
||||
*
|
||||
* Verifies the extension's registration surface against a minimal, real-shape
|
||||
* `ExtensionAPI` stub. **No external API is mocked.** End-to-end behavior of
|
||||
* the tool is exercised manually via `pi -e ./packages/tavily-search` against
|
||||
* a real Tavily key (see README).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import factory, { type TavilySearchInput } from "./index.js";
|
||||
|
||||
interface CapturedTool {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
parameters: unknown;
|
||||
}
|
||||
|
||||
function createApiStub(): { api: ExtensionAPI; tools: CapturedTool[] } {
|
||||
const tools: CapturedTool[] = [];
|
||||
const notImplemented = (method: string) => () => {
|
||||
throw new Error(`ExtensionAPI.${method} not implemented in test stub`);
|
||||
};
|
||||
|
||||
const api = {
|
||||
on: (() => {
|
||||
/* tavily-search subscribes to no events */
|
||||
}) as unknown as ExtensionAPI["on"],
|
||||
registerTool: ((tool: ToolDefinition) => {
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
});
|
||||
}) as unknown as ExtensionAPI["registerTool"],
|
||||
registerCommand: notImplemented("registerCommand"),
|
||||
registerShortcut: notImplemented("registerShortcut"),
|
||||
registerFlag: notImplemented("registerFlag"),
|
||||
getFlag: notImplemented("getFlag"),
|
||||
registerMessageRenderer: notImplemented("registerMessageRenderer"),
|
||||
sendMessage: notImplemented("sendMessage"),
|
||||
sendUserMessage: notImplemented("sendUserMessage"),
|
||||
appendEntry: notImplemented("appendEntry"),
|
||||
setSessionName: notImplemented("setSessionName"),
|
||||
getSessionName: notImplemented("getSessionName"),
|
||||
setLabel: notImplemented("setLabel"),
|
||||
exec: notImplemented("exec"),
|
||||
getActiveTools: notImplemented("getActiveTools"),
|
||||
getAllTools: notImplemented("getAllTools"),
|
||||
setActiveTools: notImplemented("setActiveTools"),
|
||||
getCommands: notImplemented("getCommands"),
|
||||
setModel: notImplemented("setModel"),
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
return { api, tools };
|
||||
}
|
||||
|
||||
describe("@jmcombs/pi-tavily-search", () => {
|
||||
it("exports a default factory function", () => {
|
||||
expect(typeof factory).toBe("function");
|
||||
});
|
||||
|
||||
it("registers exactly one tool, named tavily_search", () => {
|
||||
const { api, tools } = createApiStub();
|
||||
factory(api);
|
||||
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.name).toBe("tavily_search");
|
||||
expect(tools[0]?.label).toBe("Tavily Web Search");
|
||||
expect(tools[0]?.description).toMatch(/tavily/i);
|
||||
});
|
||||
|
||||
it("declares a TypeBox schema requiring a non-empty query string", () => {
|
||||
const { api, tools } = createApiStub();
|
||||
factory(api);
|
||||
|
||||
const params = tools[0]?.parameters as {
|
||||
type: string;
|
||||
properties: { query: { type: string; minLength?: number } };
|
||||
required: string[];
|
||||
};
|
||||
|
||||
expect(params.type).toBe("object");
|
||||
expect(params.properties.query.type).toBe("string");
|
||||
expect(params.properties.query.minLength).toBe(1);
|
||||
expect(params.required).toContain("query");
|
||||
});
|
||||
|
||||
it("publicly exports the TavilySearchInput type for downstream extensions", () => {
|
||||
// Compile-time only: this assignment fails the build if the exported type
|
||||
// ever drifts from its actual schema shape.
|
||||
const sample: TavilySearchInput = { query: "pi coding agent" };
|
||||
expect(sample.query).toBe("pi coding agent");
|
||||
});
|
||||
});
|
||||
138
extensions/tavily-search/index.ts
Normal file
138
extensions/tavily-search/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @jmcombs/pi-tavily-search — Real-time web search for the Pi coding agent.
|
||||
*
|
||||
* Registers a `tavily_search` tool that the LLM can call to perform a Tavily
|
||||
* web search. The Tavily API key is resolved from (in order):
|
||||
* 1. `AuthStorage` under the "tavily" key (`~/.pi/agent/auth.json`)
|
||||
* 2. The `TAVILY_API_KEY` environment variable
|
||||
*
|
||||
* See README.md for configuration details and recommended secret-storage
|
||||
* patterns (env var, plain auth.json, or shell-resolved 1Password / Keychain).
|
||||
*/
|
||||
|
||||
import { AuthStorage, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "typebox";
|
||||
|
||||
const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
|
||||
|
||||
// ── Tool parameter schema ──────────────────────────────────────────────
|
||||
|
||||
const tavilySearchSchema = Type.Object({
|
||||
query: Type.String({
|
||||
description: "The search query to perform.",
|
||||
minLength: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TavilySearchInput = Static<typeof tavilySearchSchema>;
|
||||
|
||||
// ── Tavily API response types ──────────────────────────────────────────
|
||||
//
|
||||
// Documented at https://docs.tavily.com/documentation/api-reference/endpoint/search
|
||||
// We model only the fields we actually consume; unknown fields pass through
|
||||
// untouched in the `details.raw` field returned by the tool.
|
||||
|
||||
interface TavilySearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
score?: number;
|
||||
raw_content?: string | null;
|
||||
}
|
||||
|
||||
interface TavilySearchResponse {
|
||||
query?: string;
|
||||
answer?: string;
|
||||
results?: TavilySearchResult[];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const MISSING_KEY_MESSAGE = [
|
||||
"Error: No Tavily API key configured.",
|
||||
"",
|
||||
"Configure one of the following:",
|
||||
" • Environment variable: export TAVILY_API_KEY=<your-key>",
|
||||
' • ~/.pi/agent/auth.json: { "tavily": { "type": "api_key", "key": "<your-key>" } }',
|
||||
' • Shell-resolved: { "tavily": { "type": "api_key", "key": "!security find-generic-password -ws tavily" } }',
|
||||
' • 1Password: { "tavily": { "type": "api_key", "key": "!op read \'op://Personal/tavily/credential\'" } }',
|
||||
].join("\n");
|
||||
|
||||
function formatResults(data: TavilySearchResponse, query: string): string {
|
||||
const results = data.results ?? [];
|
||||
if (results.length === 0) {
|
||||
return `No search results found for "${query}".`;
|
||||
}
|
||||
|
||||
const formatted = results
|
||||
.map((r) => `Title: ${r.title}\nURL: ${r.url}\nContent: ${r.content}\n`)
|
||||
.join("\n---\n");
|
||||
|
||||
const answer = data.answer ? `Answer: ${data.answer}\n\n` : "";
|
||||
return `${answer}Search results for "${query}":\n\n${formatted}`;
|
||||
}
|
||||
|
||||
// ── Extension factory ──────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI): void {
|
||||
const authStorage = AuthStorage.create();
|
||||
|
||||
pi.registerTool({
|
||||
name: "tavily_search",
|
||||
label: "Tavily Web Search",
|
||||
description:
|
||||
"Performs a web search using the Tavily API to get real-time information from the internet.",
|
||||
parameters: tavilySearchSchema,
|
||||
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
||||
const apiKey = (await authStorage.getApiKey("tavily")) ?? process.env.TAVILY_API_KEY;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
content: [{ type: "text", text: MISSING_KEY_MESSAGE }],
|
||||
details: { error: "missing_api_key" },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(TAVILY_SEARCH_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query: params.query,
|
||||
search_depth: "advanced",
|
||||
max_results: 5,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Tavily API error: ${String(response.status)} ${response.statusText}\n${errorText}`,
|
||||
},
|
||||
],
|
||||
details: { status: response.status, body: errorText },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TavilySearchResponse;
|
||||
return {
|
||||
content: [{ type: "text", text: formatResults(data, params.query) }],
|
||||
details: { raw: data },
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error performing Tavily search: ${message}` }],
|
||||
details: { error: message },
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
3821
extensions/tavily-search/package-lock.json
generated
Normal file
3821
extensions/tavily-search/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
extensions/tavily-search/package.json
Normal file
45
extensions/tavily-search/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@jmcombs/pi-tavily-search",
|
||||
"version": "2.0.0",
|
||||
"description": "Pi extension that performs real-time web search via the Tavily API.",
|
||||
"homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/tavily-search",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmcombs/pi-extensions.git",
|
||||
"directory": "packages/tavily-search"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/jmcombs/pi-extensions/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Jeremy Combs",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"files": [
|
||||
"index.ts",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"tavily",
|
||||
"web-search",
|
||||
"search",
|
||||
"rag"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/tavily-search/preview.png"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
}
|
||||
}
|
||||
5
extensions/tavily-search/tsconfig.json
Normal file
5
extensions/tavily-search/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user