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.
- package/LICENSE +661 -0
- package/README.md +55 -0
- package/index.ts +292 -0
- package/package.json +52 -0
- package/search-config.ts +63 -0
- package/ship-manifest.test.ts +12 -0
- package/verify-ship-manifest.ts +96 -0
- package/web-search-tool.ts +462 -0
|
@@ -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
|
+
});
|