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:
2026-05-25 11:25:41 -04:00
commit ebd7218b95
13 changed files with 4546 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.direnv
_scratch

55
README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}