chore: initial commit
pi extension exposing a single search tool. Providers: Kagi (headless Firefox against kagi.com session-token endpoint) and SearXNG (JSON API). Config lives at ~/.pi/pi-search/config.json with env overrides.
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.direnv
|
||||
_scratch
|
||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# evan/pi-search
|
||||
|
||||
Web search extension for [pi coding agent](https://github.com/mariozechner/pi-coding-agent). Registers a single `search` tool. Choose your provider via config.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | How it works | Requires |
|
||||
| --------- | --------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `kagi` | Drives a headless Firefox session against `kagi.com/search?token=…&q=…` and scrapes results. | Kagi session token, `firefox`, `geckodriver` |
|
||||
| `searxng` | Calls a SearXNG instance's `/search?format=json` endpoint. | A SearXNG base URL with JSON format enabled |
|
||||
|
||||
The Kagi driver is shared across calls for the lifetime of the pi process, so you only pay browser startup once per session.
|
||||
|
||||
## Config
|
||||
|
||||
Drop a JSON file at `~/.pi/pi-search/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "searxng",
|
||||
"kagi": {
|
||||
"token": "<your kagi session token>"
|
||||
},
|
||||
"searxng": {
|
||||
"baseUrl": "https://search.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Env Var Overrides
|
||||
|
||||
| Variable | Overrides |
|
||||
| ------------------------- | -------------------- |
|
||||
| `PI_SEARCH_PROVIDER` | `provider` |
|
||||
| `KAGI_TOKEN` | `kagi.token` |
|
||||
| `PI_SEARCH_SEARXNG_URL` | `searxng.baseUrl` |
|
||||
|
||||
### Getting A Kagi Session Token
|
||||
|
||||
Open `kagi.com`, sign in, then go to **Settings → Session Link**. Copy the `token=` value from the link. Treat it like a password — it grants full account access.
|
||||
|
||||
## Tool
|
||||
|
||||
| Name | Args | Returns |
|
||||
| -------- | ------------------- | ------------------------------------------------------ |
|
||||
| `search` | `query: string` | Markdown list of `## [title](url)\n> description` items |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd ~/.pi/agent/extensions/pi-search
|
||||
npm install
|
||||
```
|
||||
|
||||
`pi` picks up the extension via the `pi.extensions` entry in `package.json`.
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776734388,
|
||||
"narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
31
flake.nix
Normal file
31
flake.nix
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
description = "pi-search extension development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self
|
||||
, nixpkgs
|
||||
, flake-utils
|
||||
,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
oxlint
|
||||
nodejs_22
|
||||
firefox
|
||||
geckodriver
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
57
index.ts
Normal file
57
index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Pi-Search Extension - Registers a single `search` tool. Provider (kagi or
|
||||
// searxng) is chosen via ~/.pi/pi-search/config.json or PI_SEARCH_PROVIDER.
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { ConfigError, loadConfig, resolveSettings } from "./src/config.ts";
|
||||
import { searchKagi } from "./src/providers/kagi.ts";
|
||||
import { searchSearxng } from "./src/providers/searxng.ts";
|
||||
import { SearchError, type SearchResult } from "./src/types.ts";
|
||||
|
||||
function formatResults(results: SearchResult[]): string {
|
||||
if (results.length === 0) return "(no results)";
|
||||
return results
|
||||
.map((r) => `## [${r.title}](${r.url})\n> ${r.description}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function runSearch(query: string): Promise<SearchResult[]> {
|
||||
const config = loadConfig();
|
||||
const settings = resolveSettings(config);
|
||||
switch (settings.provider) {
|
||||
case "kagi":
|
||||
return searchKagi({ query, token: settings.kagiToken ?? "" });
|
||||
case "searxng":
|
||||
return searchSearxng({ query, baseUrl: settings.searxngBaseUrl ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "search",
|
||||
label: "Web Search",
|
||||
description:
|
||||
"Search the web. Returns a markdown list of titles, URLs, and snippets.",
|
||||
promptSnippet: "Search the web for the given query",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Search query text" }),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
try {
|
||||
const results = await runSearch(params.query);
|
||||
return {
|
||||
content: [{ type: "text", text: formatResults(results) }],
|
||||
details: { raw: results },
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError || err instanceof SearchError) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Search error: ${err.message}` }],
|
||||
isError: true,
|
||||
details: { raw: [] as SearchResult[] },
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
3978
package-lock.json
generated
Normal file
3978
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@evan/pi-search",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Web search tool for pi: Kagi (session token via headless Firefox) or SearXNG (JSON API).",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "oxlint . --ignore-pattern=.direnv/** --ignore-pattern=node_modules/**"
|
||||
},
|
||||
"dependencies": {
|
||||
"selenium-webdriver": "^4.43.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/selenium-webdriver": "^4.35.5",
|
||||
"oxlint": "^1.62.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typebox": "^1.1.37",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
99
src/config.ts
Normal file
99
src/config.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type Provider = "kagi" | "searxng";
|
||||
|
||||
export interface PiSearchConfig {
|
||||
provider?: Provider;
|
||||
kagi?: {
|
||||
token?: string;
|
||||
};
|
||||
searxng?: {
|
||||
baseUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
// Default Config Path - Lives under ~/.pi/ alongside other pi state so the
|
||||
// extension is self-contained with the rest of the user's pi setup.
|
||||
export function defaultConfigPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return join(env.HOME ?? homedir(), ".pi", "pi-search", "config.json");
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateString(value: unknown, name: string): void {
|
||||
if (value !== undefined && typeof value !== "string") {
|
||||
throw new ConfigError(`${name} must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig(raw: unknown): asserts raw is PiSearchConfig {
|
||||
if (!isObject(raw)) {
|
||||
throw new ConfigError("config must be a JSON object");
|
||||
}
|
||||
validateString(raw.provider, "provider");
|
||||
if (raw.provider && !["kagi", "searxng"].includes(raw.provider as string)) {
|
||||
throw new ConfigError(
|
||||
`provider must be "kagi" or "searxng", got: ${raw.provider}`,
|
||||
);
|
||||
}
|
||||
if (raw.kagi !== undefined) {
|
||||
if (!isObject(raw.kagi)) throw new ConfigError("kagi must be an object");
|
||||
validateString(raw.kagi.token, "kagi.token");
|
||||
}
|
||||
if (raw.searxng !== undefined) {
|
||||
if (!isObject(raw.searxng))
|
||||
throw new ConfigError("searxng must be an object");
|
||||
validateString(raw.searxng.baseUrl, "searxng.baseUrl");
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(path: string = defaultConfigPath()): PiSearchConfig {
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new ConfigError(`failed to read ${path}: ${msg}`);
|
||||
}
|
||||
validateConfig(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Resolved Settings - Merges env vars over file config. Env wins so users
|
||||
// can override per-shell without editing the config file.
|
||||
export interface ResolvedSettings {
|
||||
provider: Provider;
|
||||
kagiToken?: string;
|
||||
searxngBaseUrl?: string;
|
||||
}
|
||||
|
||||
export function resolveSettings(
|
||||
config: PiSearchConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ResolvedSettings {
|
||||
const provider = (env.PI_SEARCH_PROVIDER as Provider) ?? config.provider;
|
||||
if (!provider) {
|
||||
throw new ConfigError(
|
||||
"no provider configured. Set provider in ~/.pi/pi-search/config.json or PI_SEARCH_PROVIDER env.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
kagiToken: env.KAGI_TOKEN ?? config.kagi?.token,
|
||||
searxngBaseUrl: env.PI_SEARCH_SEARXNG_URL ?? config.searxng?.baseUrl,
|
||||
};
|
||||
}
|
||||
66
src/driver.ts
Normal file
66
src/driver.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { Builder, WebDriver } from "selenium-webdriver";
|
||||
import firefox from "selenium-webdriver/firefox.js";
|
||||
|
||||
export { type WebDriver };
|
||||
|
||||
// Find Geckodriver - Resolved off PATH so Nix and system installs both work.
|
||||
// Bypasses selenium-manager which is x86-64 only and breaks on aarch64.
|
||||
function findGeckodriver(): string {
|
||||
try {
|
||||
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"geckodriver not found on $PATH. Install it (e.g. via Nix or your package manager).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDriver(): Promise<WebDriver> {
|
||||
const options = new firefox.Options();
|
||||
options.addArguments("--headless");
|
||||
const service = new firefox.ServiceBuilder(findGeckodriver());
|
||||
return new Builder()
|
||||
.forBrowser("firefox")
|
||||
.setFirefoxOptions(options)
|
||||
.setFirefoxService(service)
|
||||
.build();
|
||||
}
|
||||
|
||||
// Shared Driver - Reused across search calls so we pay geckodriver/Firefox
|
||||
// startup once per pi process instead of per query. Quit on exit.
|
||||
let sharedDriver: Promise<WebDriver> | undefined;
|
||||
let exitHookInstalled = false;
|
||||
|
||||
export function getSharedDriver(): Promise<WebDriver> {
|
||||
if (!sharedDriver) {
|
||||
sharedDriver = createDriver();
|
||||
if (!exitHookInstalled) {
|
||||
exitHookInstalled = true;
|
||||
const cleanup = () => {
|
||||
const d = sharedDriver;
|
||||
sharedDriver = undefined;
|
||||
if (d) void d.then((drv) => drv.quit().catch(() => {}));
|
||||
};
|
||||
process.once("exit", cleanup);
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
}
|
||||
}
|
||||
return sharedDriver;
|
||||
}
|
||||
|
||||
// Reset Shared Driver - Drops the cache so the next call re-spawns. Used
|
||||
// when a driver becomes unhealthy (session crashed, etc.).
|
||||
export async function resetSharedDriver(): Promise<void> {
|
||||
const d = sharedDriver;
|
||||
sharedDriver = undefined;
|
||||
if (d) {
|
||||
try {
|
||||
const drv = await d;
|
||||
await drv.quit();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/providers/kagi.ts
Normal file
65
src/providers/kagi.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getSharedDriver, resetSharedDriver } from "../driver.ts";
|
||||
import { SearchError, type SearchResult } from "../types.ts";
|
||||
|
||||
// Kagi Results Extractor - Runs in the page; selects each result's title
|
||||
// container and pulls the link + snippet. Mirrors glimpse's selector set.
|
||||
const KAGI_EXTRACT_SCRIPT = `return Array.from(document.querySelectorAll("div > .__sri-title"))
|
||||
.map((i) => i.parentElement)
|
||||
.map((i) => ({
|
||||
title: i.children[0].querySelector("a").innerText,
|
||||
url: i.children[0].querySelector("a").getAttribute("href"),
|
||||
description: i.querySelector(".__sri-body")?.innerText ?? "",
|
||||
}));`;
|
||||
|
||||
function buildKagiUrl(query: string, token: string): string {
|
||||
return `https://kagi.com/search?token=${encodeURIComponent(token)}&q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
export interface KagiSearchOptions {
|
||||
query: string;
|
||||
token: string;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export async function searchKagi({
|
||||
query,
|
||||
token,
|
||||
timeoutMs = 8000,
|
||||
intervalMs = 200,
|
||||
}: KagiSearchOptions): Promise<SearchResult[]> {
|
||||
if (!token) {
|
||||
throw new SearchError(
|
||||
"KAGI_TOKEN_REQUIRED",
|
||||
"Kagi search requires a session token. Set kagi.token in config or KAGI_TOKEN env.",
|
||||
);
|
||||
}
|
||||
|
||||
// Retry Once On Session Failure - Selenium sessions can die between
|
||||
// requests (e.g. browser crashed). One retry with a fresh driver hides
|
||||
// transient flakiness without masking real problems.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const driver = await getSharedDriver();
|
||||
await driver.get(buildKagiUrl(query, token));
|
||||
|
||||
const start = Date.now();
|
||||
let results: SearchResult[] = [];
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const raw = await driver.executeScript(KAGI_EXTRACT_SCRIPT);
|
||||
results = Array.isArray(raw) ? (raw as SearchResult[]) : [];
|
||||
if (results.length > 0) break;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
if (attempt === 0) {
|
||||
await resetSharedDriver();
|
||||
continue;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new SearchError("KAGI_SEARCH_FAILED", `Kagi search failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
75
src/providers/searxng.ts
Normal file
75
src/providers/searxng.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { SearchError, type SearchResult } from "../types.ts";
|
||||
|
||||
export interface SearxngSearchOptions {
|
||||
query: string;
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SearxngResponse {
|
||||
results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function searchSearxng({
|
||||
query,
|
||||
baseUrl,
|
||||
timeoutMs = 8000,
|
||||
}: SearxngSearchOptions): Promise<SearchResult[]> {
|
||||
if (!baseUrl) {
|
||||
throw new SearchError(
|
||||
"SEARXNG_URL_REQUIRED",
|
||||
"SearXNG search requires a base URL. Set searxng.baseUrl in config or PI_SEARCH_SEARXNG_URL env.",
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL("/search", baseUrl);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("format", "json");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new SearchError("SEARXNG_REQUEST_FAILED", `SearXNG request failed: ${msg}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new SearchError(
|
||||
"SEARXNG_BAD_STATUS",
|
||||
`SearXNG returned ${response.status} ${response.statusText}. Ensure the instance has JSON format enabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
let body: SearxngResponse;
|
||||
try {
|
||||
body = (await response.json()) as SearxngResponse;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new SearchError(
|
||||
"SEARXNG_INVALID_JSON",
|
||||
`SearXNG returned non-JSON response: ${msg}. Ensure the instance has JSON format enabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
const items = body.results ?? [];
|
||||
return items
|
||||
.filter((r) => r.url && r.title)
|
||||
.map((r) => ({
|
||||
title: r.title ?? "",
|
||||
url: r.url ?? "",
|
||||
description: r.content ?? "",
|
||||
}));
|
||||
}
|
||||
14
src/types.ts
Normal file
14
src/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class SearchError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "SearchError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["index.ts", "src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user