pi-lean-search 0.1.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,462 @@
1
+ /**
2
+ * web-search tool definition for pi-lean-search.
3
+ *
4
+ * Searches the web via a SearXNG instance. Degrades gracefully when
5
+ * no SearXNG URL is configured — returns a setup message on first call.
6
+ *
7
+ * Adapted from the prototype at:
8
+ * /root/lab/startup_scripts/firecracker/config/pi/extensions/searxng-search/index.ts
9
+ *
10
+ * Changes from prototype:
11
+ * - Config read from Pi settings.json (searxng.url) instead of env vars
12
+ * - No injectUnavailabilityNotice (graceful degradation via tool output only)
13
+ * - Health state management lives in index.ts, not in the tool
14
+ */
15
+
16
+ import { defineTool } from "@earendil-works/pi-coding-agent";
17
+ import { Type, StringEnum } from "@earendil-works/pi-ai";
18
+ import { Text } from "@earendil-works/pi-tui";
19
+ import { readSearxngUrl } from "./search-config.js";
20
+
21
+ // ─── Interfaces ───────────────────────────────────────────────────
22
+
23
+ interface SearXNGResult {
24
+ title: string;
25
+ url: string;
26
+ content: string;
27
+ engine: string;
28
+ score?: number;
29
+ }
30
+
31
+ interface SearXNGResponse {
32
+ results: SearXNGResult[];
33
+ answers: string[];
34
+ suggestions: string[];
35
+ }
36
+
37
+ // ─── URL building ─────────────────────────────────────────────────
38
+
39
+ function buildSearchUrl(
40
+ baseUrl: string,
41
+ query: string,
42
+ options: {
43
+ count: number;
44
+ language: string;
45
+ safesearch: string;
46
+ time_range: string;
47
+ category: string;
48
+ engines: string;
49
+ },
50
+ ): string {
51
+ const normalized = baseUrl.replace(/\/+$/, "");
52
+ const params = new URLSearchParams({
53
+ format: "json",
54
+ q: query,
55
+ });
56
+
57
+ params.set("limit", String(options.count));
58
+
59
+ if (options.language) params.set("language", options.language);
60
+ if (options.safesearch) params.set("safesearch", options.safesearch);
61
+ if (options.time_range) params.set("time_range", options.time_range);
62
+ if (options.category) params.set("categories", options.category);
63
+ if (options.engines) params.set("engines", options.engines);
64
+
65
+ return `${normalized}/search?${params.toString()}`;
66
+ }
67
+
68
+ // ─── Tool definition ──────────────────────────────────────────────
69
+
70
+ export const webSearchTool = defineTool({
71
+ name: "web-search",
72
+ label: "Web Search",
73
+ description:
74
+ "Search the web using the local SearXNG instance. " +
75
+ "Use for finding current information, research, news, and fact-checking.",
76
+ promptSnippet:
77
+ "Search the web via a local SearXNG instance — use for up-to-date facts, verification, or research.",
78
+ promptGuidelines:
79
+ 'Use when you need recent/current information not already known. Increase `count` for broad research; keep it small for quick lookups. Filter by time_range="day" for breaking news, category="news" for journalism. Set language to match the query (e.g. "de" for German, "es" for Spanish).',
80
+
81
+ parameters: Type.Object({
82
+ query: Type.String({ description: "The search query" }),
83
+ count: Type.Optional(
84
+ Type.Number({
85
+ description: "Number of results to return (default: 5). Max is 100.",
86
+ minimum: 1,
87
+ maximum: 100,
88
+ }),
89
+ ),
90
+ timeout: Type.Optional(
91
+ Type.Number({
92
+ description:
93
+ "Request timeout in seconds (default: 15, max configurable: 30)",
94
+ minimum: 1,
95
+ maximum: 30,
96
+ }),
97
+ ),
98
+ language: Type.Optional(
99
+ Type.String({
100
+ description:
101
+ 'Language code for results (e.g. "en", "de", "es"). Empty string or omit for any language.',
102
+ }),
103
+ ),
104
+ safesearch: Type.Optional(
105
+ StringEnum(["0", "1", "2"], {
106
+ description: "Filtering: off=0, moderate=1, strict=2",
107
+ }),
108
+ ),
109
+ time_range: Type.Optional(
110
+ StringEnum(["day", "week", "month", "year"], {
111
+ description: "Recency filter: day, week, month, or year",
112
+ }),
113
+ ),
114
+ category: Type.Optional(
115
+ StringEnum(
116
+ [
117
+ "general",
118
+ "news",
119
+ "science",
120
+ "images",
121
+ "videos",
122
+ "files",
123
+ "it",
124
+ "social media",
125
+ ],
126
+ {
127
+ description: "Result category (e.g. news, science, images)",
128
+ },
129
+ ),
130
+ ),
131
+ engines: Type.Optional(
132
+ Type.String({
133
+ description:
134
+ 'Comma-separated upstream search engines (e.g. "google,bing")',
135
+ }),
136
+ ),
137
+ }),
138
+
139
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
140
+ const {
141
+ query,
142
+ count = 5,
143
+ timeout: userTimeout,
144
+ language = "",
145
+ safesearch = "0",
146
+ time_range = "",
147
+ category = "",
148
+ engines = "",
149
+ } = params;
150
+
151
+ // ── Config check: graceful degradation when unconfigured ──
152
+ const searxngUrl = readSearxngUrl();
153
+ if (!searxngUrl) {
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text" as const,
158
+ text:
159
+ "Web search is not configured. " +
160
+ "Set `searxng.url` in `~/.pi/agent/settings.json` " +
161
+ "or `.pi/settings.json` to your SearXNG instance URL. " +
162
+ "For example:\n" +
163
+ ' ```json\n { "searxng": { "url": "http://localhost:8888" } }\n ```\n' +
164
+ "See the pi-lean-search README for self-host vs public instance options.",
165
+ },
166
+ ],
167
+ details: { error: true, unconfigured: true },
168
+ };
169
+ }
170
+
171
+ // ── Timeout ──
172
+ const timeoutSeconds = Math.min(Math.max(userTimeout ?? 15, 1), 30);
173
+
174
+ // ── Build URL ──
175
+ const url = buildSearchUrl(searxngUrl, query, {
176
+ count,
177
+ language,
178
+ safesearch,
179
+ time_range,
180
+ category,
181
+ engines,
182
+ });
183
+
184
+ // ── AbortController for timeout + cancellation ──
185
+ const controller = new AbortController();
186
+ let timedOut = false;
187
+
188
+ if (_signal) {
189
+ _signal.addEventListener("abort", () => controller.abort(), {
190
+ once: true,
191
+ });
192
+ }
193
+ if (_signal?.aborted) {
194
+ return {
195
+ content: [{ type: "text" as const, text: "Web search cancelled." }],
196
+ details: { cancelled: true },
197
+ };
198
+ }
199
+
200
+ const timeoutId = setTimeout(() => {
201
+ timedOut = true;
202
+ controller.abort();
203
+ }, timeoutSeconds * 1000);
204
+
205
+ try {
206
+ // ── Layer 1: Connection-level error handling ──
207
+ let response: Response;
208
+ try {
209
+ response = await fetch(url, {
210
+ signal: controller.signal as AbortSignal,
211
+ headers: { Accept: "application/json" },
212
+ });
213
+ } catch (connectionErr) {
214
+ clearTimeout(timeoutId);
215
+ if (
216
+ connectionErr instanceof DOMException &&
217
+ connectionErr.name === "AbortError"
218
+ ) {
219
+ if (timedOut) {
220
+ return {
221
+ content: [
222
+ {
223
+ type: "text" as const,
224
+ text:
225
+ `Web search timed out after ${timeoutSeconds}s. ` +
226
+ `The SearXNG instance at \`${searxngUrl}\` may be slow ` +
227
+ "or unresponsive.",
228
+ },
229
+ ],
230
+ details: {
231
+ error: true,
232
+ timedOut: true,
233
+ timeout: timeoutSeconds,
234
+ },
235
+ };
236
+ }
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text" as const,
241
+ text: "Web search was cancelled.",
242
+ },
243
+ ],
244
+ details: { cancelled: true },
245
+ };
246
+ }
247
+ return {
248
+ content: [
249
+ {
250
+ type: "text" as const,
251
+ text:
252
+ "Web search connection failed: " +
253
+ (connectionErr instanceof Error
254
+ ? connectionErr.message
255
+ : String(connectionErr)),
256
+ },
257
+ ],
258
+ details: { error: true, connectionError: true },
259
+ };
260
+ }
261
+
262
+ clearTimeout(timeoutId);
263
+
264
+ // ── Layer 2: HTTP error handling ──
265
+ if (!response.ok) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text" as const,
270
+ text: `SearXNG error: HTTP ${response.status} ${response.statusText}`,
271
+ },
272
+ ],
273
+ details: { error: true, status: response.status },
274
+ };
275
+ }
276
+
277
+ // ── Layer 3: JSON parse error handling ──
278
+ let data: SearXNGResponse;
279
+ try {
280
+ const text = await response.text();
281
+ data = text
282
+ ? (JSON.parse(text) as SearXNGResponse)
283
+ : { results: [], answers: [], suggestions: [] };
284
+ } catch (parseErr) {
285
+ return {
286
+ content: [
287
+ {
288
+ type: "text" as const,
289
+ text:
290
+ "Web search returned unexpected response format. " +
291
+ "SearXNG may be misconfigured. Error: " +
292
+ (parseErr instanceof Error
293
+ ? parseErr.message
294
+ : String(parseErr)),
295
+ },
296
+ ],
297
+ details: { error: true, parseError: true },
298
+ };
299
+ }
300
+
301
+ // ── Deduplicate results by URL ──
302
+ const seenUrls = new Set<string>();
303
+ const uniqueResults = (data.results || []).filter((r) => {
304
+ if (seenUrls.has(r.url)) return false;
305
+ seenUrls.add(r.url);
306
+ return true;
307
+ });
308
+
309
+ // Sort by relevance score (descending), missing scores as 0
310
+ const sortedResults = uniqueResults.sort(
311
+ (a, b) => (b.score ?? 0) - (a.score ?? 0),
312
+ );
313
+
314
+ // Slice to requested count
315
+ const results = sortedResults.slice(0, Math.min(count, 100));
316
+
317
+ if (results.length === 0) {
318
+ return {
319
+ content: [
320
+ {
321
+ type: "text" as const,
322
+ text: `No web search results found for "${query}".`,
323
+ },
324
+ ],
325
+ details: { results: [] },
326
+ };
327
+ }
328
+
329
+ // Adaptive output formatting
330
+ const maxSnippetLen = count <= 3 ? 300 : 150;
331
+ let output = "";
332
+ for (const [i, r] of results.entries()) {
333
+ output += `${i + 1}. ${r.title}\n`;
334
+ output += ` ${r.url}\n`;
335
+ const snippet = (r.content || "")
336
+ .replace(/\s+/g, " ")
337
+ .trim()
338
+ .slice(0, maxSnippetLen);
339
+ if (snippet) {
340
+ output += ` ${snippet}\n`;
341
+ }
342
+ if (count > 1 && r.engine) {
343
+ output += ` [${r.engine}]`;
344
+ }
345
+ if (Number.isFinite(r.score)) {
346
+ output += ` | score: ${r.score!.toFixed(2)}`;
347
+ }
348
+ output += "\n\n";
349
+ }
350
+
351
+ // Suggestions section
352
+ if (data.suggestions?.length) {
353
+ const suggestionCount = Math.min(data.suggestions.length, 3);
354
+ output += `Suggestions: ${data.suggestions.slice(0, suggestionCount).join(", ")}`;
355
+ }
356
+
357
+ return {
358
+ content: [{ type: "text" as const, text: output.trim() }],
359
+ details: {
360
+ resultCount: results.length,
361
+ query,
362
+ timeout: timeoutSeconds,
363
+ results: results.map((r) => ({
364
+ title: r.title,
365
+ url: r.url,
366
+ engine: r.engine,
367
+ score: r.score,
368
+ })),
369
+ },
370
+ };
371
+ } catch (unexpectedErr) {
372
+ clearTimeout(timeoutId);
373
+ return {
374
+ content: [
375
+ {
376
+ type: "text" as const,
377
+ text:
378
+ "An unexpected error occurred during web search: " +
379
+ (unexpectedErr instanceof Error
380
+ ? unexpectedErr.message
381
+ : String(unexpectedErr)),
382
+ },
383
+ ],
384
+ details: { error: true, unexpectedError: true },
385
+ };
386
+ }
387
+ },
388
+
389
+ // ── TUI rendering ──────────────────────────────────────────
390
+
391
+ renderCall(args, theme, _context) {
392
+ const parts: string[] = [theme.fg("toolTitle", theme.bold("web-search "))];
393
+ parts.push(theme.fg("accent", `"${args.query}"`));
394
+ if (args.count) parts.push(theme.fg("dim", `count=${args.count}`));
395
+ if (args.category) parts.push(theme.fg("dim", `cat:${args.category}`));
396
+ if (args.time_range) parts.push(theme.fg("dim", `time:${args.time_range}`));
397
+ return new Text(parts.join(" "), 0, 0);
398
+ },
399
+
400
+ renderResult(result, { expanded, isPartial }, theme, _context) {
401
+ if (isPartial) {
402
+ return new Text(theme.fg("warning", "Searching…"), 0, 0);
403
+ }
404
+
405
+ const details = result.details as Record<string, unknown> | undefined;
406
+
407
+ if (details?.cancelled) {
408
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
409
+ }
410
+ if (details?.timedOut) {
411
+ return new Text(
412
+ theme.fg("error", `Timed out (${details.timeout ?? "?"}s)`),
413
+ 0,
414
+ 0,
415
+ );
416
+ }
417
+ if (details?.connectionError) {
418
+ return new Text(theme.fg("error", "Connection failed"), 0, 0);
419
+ }
420
+ if (details?.unconfigured) {
421
+ return new Text(theme.fg("warning", "Not configured"), 0, 0);
422
+ }
423
+ if (details?.parseError) {
424
+ return new Text(theme.fg("error", "Bad response"), 0, 0);
425
+ }
426
+ if (details?.status) {
427
+ return new Text(theme.fg("error", `HTTP ${details.status}`), 0, 0);
428
+ }
429
+
430
+ const results = details?.results as
431
+ | Array<{ title: string; url: string; engine?: string; score?: number }>
432
+ | undefined;
433
+ const resultCount = details?.resultCount as number | undefined;
434
+ const query = details?.query as string | undefined;
435
+
436
+ if (!results || results.length === 0) {
437
+ const msg = query ? `No results for "${query}"` : "No results";
438
+ return new Text(theme.fg("dim", msg), 0, 0);
439
+ }
440
+
441
+ let text =
442
+ theme.fg("muted", `🔍 ${resultCount ?? results.length} result(s) for `) +
443
+ theme.fg("accent", `"${query ?? "?"}"`);
444
+
445
+ const display = expanded ? results : results.slice(0, 5);
446
+
447
+ for (const r of display) {
448
+ const score =
449
+ typeof r.score === "number"
450
+ ? ` ${theme.fg("dim", `⭐ ${r.score.toFixed(2)}`)}`
451
+ : "";
452
+ text += `\n${theme.fg("toolTitle", r.title)}${score}`;
453
+ text += `\n${theme.fg("dim", r.url)}`;
454
+ }
455
+
456
+ if (!expanded && results.length > 5) {
457
+ text += `\n${theme.fg("muted", `… ${results.length - 5} more (expand)`)}`;
458
+ }
459
+
460
+ return new Text(text, 0, 0);
461
+ },
462
+ });