@mammothb/pi-web 6.0.0

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.
@@ -0,0 +1,24 @@
1
+ # Read the documentation before using the `docker-compose.yml` file:
2
+ # https://docs.searxng.org/admin/installation-docker.html
3
+ name: searxng
4
+ services:
5
+ core:
6
+ container_name: searxng-core
7
+ image: docker.io/searxng/searxng:${SEARXNG_VERSION:-latest}
8
+ restart: always
9
+ ports:
10
+ - ${SEARXNG_HOST:+${SEARXNG_HOST}:}${SEARXNG_PORT:-8080}:${SEARXNG_PORT:-8080}
11
+ env_file: ./.env
12
+ volumes:
13
+ - ./core-config/:/etc/searxng/:Z
14
+ - core-data:/var/cache/searxng/
15
+ valkey:
16
+ container_name: searxng-valkey
17
+ image: docker.io/valkey/valkey:9-alpine
18
+ command: valkey-server --save 30 1 --loglevel warning
19
+ restart: always
20
+ volumes:
21
+ - valkey-data:/data/
22
+ volumes:
23
+ core-data:
24
+ valkey-data:
package/src/config.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { loadPiConfig } from "@mammothb/pi-shared";
2
+
3
+ export interface WebsearchConfig {
4
+ /** Which provider to use. */
5
+ provider: "exa-mcp" | "searxng";
6
+ /** Exa MCP provider configuration. */
7
+ exaMcp: {
8
+ /** MCP server URL */
9
+ url: string;
10
+ /** MCP tool name */
11
+ tool: string;
12
+ };
13
+ /** SearXNG provider configuration. */
14
+ searxng: {
15
+ /** SearXNG instance URL */
16
+ url: string;
17
+ /** SafeSearch level: 0 (off), 1 (moderate), 2 (strict) */
18
+ safesearch: 0 | 1 | 2;
19
+ /**
20
+ * Optional path to a custom management script.
21
+ * Must accept "up" and "down" commands (same interface as the default script).
22
+ * When set, this script is used instead of the built-in `bin/searxng` script.
23
+ */
24
+ script?: string;
25
+ };
26
+ /** Request timeout in milliseconds */
27
+ timeoutMs: number;
28
+ /** Default values for search parameters */
29
+ defaults: {
30
+ numResults: number;
31
+ type: "auto" | "fast" | "deep";
32
+ livecrawl: "fallback" | "preferred";
33
+ contextMaxCharacters: number;
34
+ };
35
+ }
36
+
37
+ export const DEFAULT_CONFIG: WebsearchConfig = {
38
+ provider: "exa-mcp",
39
+ exaMcp: {
40
+ url: "https://mcp.exa.ai/mcp",
41
+ tool: "web_search_exa",
42
+ },
43
+ searxng: {
44
+ url: "http://localhost:8080",
45
+ safesearch: 0,
46
+ script: undefined,
47
+ },
48
+ timeoutMs: 25_000,
49
+ defaults: {
50
+ numResults: 8,
51
+ type: "auto",
52
+ livecrawl: "fallback",
53
+ contextMaxCharacters: 10_000,
54
+ },
55
+ };
56
+
57
+ /**
58
+ * Deep-merge two configs. Arrays and primitives from `override` replace those
59
+ * in `base`. Objects are merged recursively.
60
+ */
61
+ function mergeConfig(
62
+ base: WebsearchConfig,
63
+ override: Record<string, unknown>,
64
+ ): WebsearchConfig {
65
+ const merged = { ...base };
66
+
67
+ if (
68
+ typeof override.provider === "string" &&
69
+ (override.provider === "exa-mcp" || override.provider === "searxng")
70
+ ) {
71
+ merged.provider = override.provider;
72
+ }
73
+ if (override.exaMcp && typeof override.exaMcp === "object") {
74
+ merged.exaMcp = {
75
+ ...base.exaMcp,
76
+ ...(override.exaMcp as Record<string, unknown>),
77
+ };
78
+ }
79
+ if (override.searxng && typeof override.searxng === "object") {
80
+ merged.searxng = {
81
+ ...base.searxng,
82
+ ...(override.searxng as Record<string, unknown>),
83
+ };
84
+ }
85
+ if (override.defaults && typeof override.defaults === "object") {
86
+ merged.defaults = {
87
+ ...base.defaults,
88
+ ...(override.defaults as Record<string, unknown>),
89
+ };
90
+ }
91
+ if (typeof override.timeoutMs === "number") {
92
+ merged.timeoutMs = override.timeoutMs;
93
+ }
94
+
95
+ return merged;
96
+ }
97
+
98
+ /**
99
+ * Load config from JSON files. Project config (`.pi/pi-web.json`)
100
+ * overrides global config (`~/.pi/agent/pi-web.json`).
101
+ *
102
+ * Returns the default config if no config files exist.
103
+ */
104
+ export function loadConfig(cwd: string): WebsearchConfig {
105
+ return loadPiConfig("pi-web.json", cwd, DEFAULT_CONFIG, mergeConfig);
106
+ }
@@ -0,0 +1,28 @@
1
+ import type { Format, Header } from "./types";
2
+
3
+ export function buildHeaders(format: Format): Header {
4
+ let acceptHeader = "*/*";
5
+ switch (format) {
6
+ case "markdown":
7
+ acceptHeader =
8
+ "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1";
9
+ break;
10
+ case "text":
11
+ acceptHeader =
12
+ "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1";
13
+ break;
14
+ case "html":
15
+ acceptHeader =
16
+ "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1";
17
+ break;
18
+ default:
19
+ acceptHeader =
20
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8";
21
+ }
22
+ return {
23
+ "User-Agent":
24
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
25
+ Accept: acceptHeader,
26
+ "Accept-Language": "en-US,en;q=0.9",
27
+ };
28
+ }
@@ -0,0 +1,57 @@
1
+ import { Parser } from "htmlparser2";
2
+ import TurndownService from "turndown";
3
+
4
+ export function toMarkdown(contentType: string, html: string): string {
5
+ if (!contentType.includes("text/html")) {
6
+ return html;
7
+ }
8
+ const turndownService = new TurndownService({
9
+ headingStyle: "atx",
10
+ hr: "---",
11
+ bulletListMarker: "-",
12
+ codeBlockStyle: "fenced",
13
+ emDelimiter: "*",
14
+ });
15
+ turndownService.remove(["link", "meta", "script", "style"]);
16
+ return turndownService.turndown(html);
17
+ }
18
+
19
+ export function toText(contentType: string, html: string): string {
20
+ if (!contentType.includes("text/html")) {
21
+ return html;
22
+ }
23
+
24
+ const tagsToSkip = [
25
+ "script",
26
+ "style",
27
+ "noscript",
28
+ "iframe",
29
+ "object",
30
+ "embed",
31
+ ];
32
+ let text = "";
33
+ let skipDepth = 0;
34
+
35
+ const parser = new Parser({
36
+ onopentag(name, _attribs, _isImplied) {
37
+ if (skipDepth > 0 || tagsToSkip.includes(name)) {
38
+ skipDepth++;
39
+ }
40
+ },
41
+ ontext(data) {
42
+ if (skipDepth === 0) {
43
+ text += data;
44
+ }
45
+ },
46
+ onclosetag(_name, _isImplied) {
47
+ if (skipDepth > 0) {
48
+ skipDepth--;
49
+ }
50
+ },
51
+ });
52
+
53
+ parser.write(html);
54
+ parser.end();
55
+
56
+ return text.trim();
57
+ }
@@ -0,0 +1,130 @@
1
+ import { Value } from "typebox/value";
2
+ import type { SearchArgs, SearchProvider } from "../types";
3
+ import { McpResultPayload } from "../types";
4
+
5
+ /**
6
+ * Try to parse a JSON object from a string, returning the first text content.
7
+ */
8
+ function tryParsePayload(payload: string): string | undefined {
9
+ const trimmed = payload.trim();
10
+ if (!trimmed.startsWith("{")) {
11
+ return undefined;
12
+ }
13
+ try {
14
+ const data = Value.Parse(McpResultPayload, JSON.parse(trimmed));
15
+ return data.result.content.find((item) => item.text)?.text;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ /** Parse an MCP response body, handling both plain JSON and SSE streams. */
22
+ export function parseResponse(body: string): string | undefined {
23
+ const trimmed = body.trim();
24
+
25
+ // Try direct JSON parse first
26
+ if (trimmed) {
27
+ const direct = tryParsePayload(trimmed);
28
+ if (direct) {
29
+ return direct;
30
+ }
31
+ }
32
+
33
+ // Try SSE lines: "data: {...}"
34
+ for (const line of body.split("\n")) {
35
+ if (!line.startsWith("data: ")) {
36
+ continue;
37
+ }
38
+ const data = tryParsePayload(line.slice(6));
39
+ if (data) {
40
+ return data;
41
+ }
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Configuration for the Exa MCP provider.
49
+ */
50
+ export interface ExaMcpConfig {
51
+ url: string;
52
+ tool: string;
53
+ timeoutMs: number;
54
+ }
55
+
56
+ function buildMcpRequest(toolName: string, args: SearchArgs) {
57
+ const value = Object.fromEntries(
58
+ Object.entries(args).filter(([_, v]) => v !== undefined),
59
+ );
60
+ return {
61
+ jsonrpc: "2.0" as const,
62
+ id: 1,
63
+ method: "tools/call" as const,
64
+ params: { name: toolName, arguments: value },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Create an Exa MCP search provider.
70
+ *
71
+ * Communicates with an MCP-compatible server via JSON-RPC over HTTP.
72
+ * The MCP server handles the actual search and returns formatted text.
73
+ */
74
+ export function createExaMcpProvider(config: ExaMcpConfig): SearchProvider {
75
+ const { url, tool, timeoutMs } = config;
76
+
77
+ return {
78
+ name: "exa-mcp",
79
+
80
+ usageNotes:
81
+ "\n - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)\n - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)",
82
+
83
+ async search(
84
+ args: SearchArgs,
85
+ signal?: AbortSignal,
86
+ ): Promise<string | undefined> {
87
+ const controller = new AbortController();
88
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
89
+
90
+ // Forward external signal
91
+ const onAbort = () => controller.abort();
92
+ if (signal) {
93
+ if (signal.aborted) {
94
+ throw new Error("Request aborted");
95
+ }
96
+ signal.addEventListener("abort", onAbort, { once: true });
97
+ }
98
+
99
+ try {
100
+ const response = await fetch(url, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Accept: "application/json, text/event-stream",
105
+ },
106
+ body: JSON.stringify(buildMcpRequest(tool, args)),
107
+ signal: controller.signal,
108
+ });
109
+
110
+ if (!response.ok) {
111
+ throw new Error(
112
+ `Exa MCP returned HTTP ${response.status}: ${response.statusText}`,
113
+ );
114
+ }
115
+
116
+ return parseResponse(await response.text());
117
+ } catch (error) {
118
+ if (controller.signal.aborted && !signal?.aborted) {
119
+ throw new Error("Request timed out");
120
+ }
121
+ throw error;
122
+ } finally {
123
+ clearTimeout(timeoutId);
124
+ if (signal) {
125
+ signal.removeEventListener("abort", onAbort);
126
+ }
127
+ }
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,29 @@
1
+ import type { WebsearchConfig } from "../../config";
2
+ import type { SearchProvider } from "../types";
3
+ import { createExaMcpProvider } from "./exa-mcp";
4
+ import { createSearxngProvider } from "./searxng";
5
+
6
+ /**
7
+ * Create a search provider based on the current configuration.
8
+ */
9
+ export function createProvider(config: WebsearchConfig): SearchProvider {
10
+ switch (config.provider) {
11
+ case "exa-mcp": {
12
+ return createExaMcpProvider({
13
+ url: config.exaMcp.url,
14
+ tool: config.exaMcp.tool,
15
+ timeoutMs: config.timeoutMs,
16
+ });
17
+ }
18
+ case "searxng": {
19
+ return createSearxngProvider({
20
+ url: config.searxng.url,
21
+ safesearch: config.searxng.safesearch,
22
+ timeoutMs: config.timeoutMs,
23
+ });
24
+ }
25
+ default: {
26
+ throw new Error(`Unknown provider: ${config.provider}`);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,172 @@
1
+ import type { SearchArgs, SearchProvider } from "../types";
2
+
3
+ /**
4
+ * Configuration for the SearXNG provider.
5
+ */
6
+ export interface SearxngConfig {
7
+ /** SearXNG instance URL (e.g. "http://localhost:8888"). */
8
+ url: string;
9
+ /** SafeSearch level: 0 (off), 1 (moderate), 2 (strict). */
10
+ safesearch: 0 | 1 | 2;
11
+ /** Request timeout in milliseconds. */
12
+ timeoutMs: number;
13
+ }
14
+
15
+ interface SearxngRawResult {
16
+ title?: string | null;
17
+ url?: string | null;
18
+ content?: string | null;
19
+ engine?: string | null;
20
+ }
21
+
22
+ interface SearxngResponse {
23
+ results: SearxngRawResult[];
24
+ }
25
+
26
+ /**
27
+ * Check whether an error is retryable.
28
+ *
29
+ * Retries on connection errors (ECONNREFUSED, etc.) and HTTP 502/503/504
30
+ * (gateway errors that can occur during container startup). Does NOT retry
31
+ * on abort/timeout, 4xx client errors, or response parsing errors.
32
+ */
33
+ function isRetryable(error: unknown): boolean {
34
+ // Don't retry abort/timeout
35
+ if (error instanceof DOMException && error.name === "AbortError") {
36
+ return false;
37
+ }
38
+ // Connection errors (ECONNREFUSED, ECONNRESET, ETIMEDOUT, etc.)
39
+ if (error instanceof TypeError) {
40
+ return true;
41
+ }
42
+ // HTTP 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout)
43
+ if (error instanceof Error && /HTTP 50[234]/.test(error.message)) {
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Retry a function with exponential backoff.
51
+ *
52
+ * Uses a time budget (80% of timeoutMs) rather than a fixed retry count.
53
+ * This ensures the total retry period stays within the configured timeout
54
+ * while leaving enough time for the actual search request to complete.
55
+ */
56
+ async function withRetry<T>(
57
+ fn: () => Promise<T>,
58
+ signal: AbortSignal,
59
+ timeoutMs: number,
60
+ ): Promise<T> {
61
+ const retryBudget = Math.min(timeoutMs * 0.8, 20_000);
62
+ const baseDelay = 300;
63
+ const startTime = Date.now();
64
+
65
+ for (let attempt = 0; ; attempt++) {
66
+ try {
67
+ return await fn();
68
+ } catch (error) {
69
+ if (signal.aborted || !isRetryable(error)) {
70
+ throw error;
71
+ }
72
+
73
+ const elapsed = Date.now() - startTime;
74
+ if (elapsed >= retryBudget) {
75
+ throw error;
76
+ }
77
+
78
+ const delay = Math.min(baseDelay * 2 ** attempt, retryBudget - elapsed);
79
+ await new Promise((resolve) => setTimeout(resolve, delay));
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Create a SearXNG search provider.
86
+ *
87
+ * Calls a SearXNG instance's `/search` endpoint with `format=json`
88
+ * and returns formatted text results.
89
+ *
90
+ * Automatically retries on connection errors and 502/503/504 responses
91
+ * so that searches work even while the Docker container is still starting.
92
+ */
93
+ export function createSearxngProvider(config: SearxngConfig): SearchProvider {
94
+ const { url, safesearch, timeoutMs } = config;
95
+
96
+ return {
97
+ name: "searxng",
98
+
99
+ usageNotes:
100
+ "\n - Results are fetched from a self-hosted SearXNG metasearch instance",
101
+
102
+ async search(
103
+ args: SearchArgs,
104
+ signal?: AbortSignal,
105
+ ): Promise<string | undefined> {
106
+ const controller = new AbortController();
107
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
108
+
109
+ // Forward external signal
110
+ const onAbort = () => controller.abort();
111
+ if (signal) {
112
+ if (signal.aborted) {
113
+ throw new Error("Request aborted");
114
+ }
115
+ signal.addEventListener("abort", onAbort, { once: true });
116
+ }
117
+
118
+ try {
119
+ const searchUrl = new URL("/search", url);
120
+ searchUrl.searchParams.set("q", args.query);
121
+ searchUrl.searchParams.set("format", "json");
122
+ searchUrl.searchParams.set("safesearch", String(safesearch));
123
+
124
+ const text = await withRetry(
125
+ async () => {
126
+ const response = await fetch(searchUrl.toString(), {
127
+ signal: controller.signal,
128
+ headers: { Accept: "application/json" },
129
+ });
130
+
131
+ if (!response.ok) {
132
+ throw new Error(
133
+ `SearXNG returned HTTP ${response.status}: ${response.statusText}`,
134
+ );
135
+ }
136
+
137
+ const data = (await response.json()) as SearxngResponse;
138
+ const results = (data.results ?? [])
139
+ .filter((r): r is typeof r & { url: string } => r.url != null)
140
+ .slice(0, args.numResults)
141
+ .map((r, i) => {
142
+ const title = r.title ?? "Untitled";
143
+ const url = r.url;
144
+ const content = r.content ?? "";
145
+ return `## **${i + 1}.** ${title}\n**URL:** ${url}\n${content}`;
146
+ });
147
+
148
+ if (results.length === 0) {
149
+ return "";
150
+ }
151
+
152
+ return results.join("\n\n---\n\n");
153
+ },
154
+ controller.signal,
155
+ timeoutMs,
156
+ );
157
+
158
+ return text || undefined;
159
+ } catch (error) {
160
+ if (controller.signal.aborted && !signal?.aborted) {
161
+ throw new Error("Request timed out");
162
+ }
163
+ throw error;
164
+ } finally {
165
+ clearTimeout(timeoutId);
166
+ if (signal) {
167
+ signal.removeEventListener("abort", onAbort);
168
+ }
169
+ }
170
+ },
171
+ };
172
+ }