pi-lean-search 0.2.1 → 0.2.3
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/package.json +1 -1
- package/web-search-tool.ts +223 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pi extension. SearXNG search tool for Pi — pairs with pi-lean-portal's /web toggle; search-only installs are valid, or add it to a portal install for the full web-tools suite.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/web-search-tool.ts
CHANGED
|
@@ -18,12 +18,188 @@ interface SearXNGResult {
|
|
|
18
18
|
score?: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
interface SearXNGAnswerLegacy {
|
|
22
|
+
template: "answer/legacy.html";
|
|
23
|
+
answer: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
engine?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SearXNGAnswerTranslationItem {
|
|
29
|
+
text: string;
|
|
30
|
+
transliteration?: string;
|
|
31
|
+
definitions?: string[];
|
|
32
|
+
synonyms?: string[];
|
|
33
|
+
examples?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SearXNGAnswerTranslations {
|
|
37
|
+
template: "answer/translations.html";
|
|
38
|
+
translations: SearXNGAnswerTranslationItem[];
|
|
39
|
+
url?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SearXNGWeatherQuantity {
|
|
43
|
+
val: number | string;
|
|
44
|
+
unit: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SearXNGWeatherItem {
|
|
48
|
+
location?: { name: string; latitude?: number; longitude?: number };
|
|
49
|
+
temperature?: SearXNGWeatherQuantity;
|
|
50
|
+
condition?: string;
|
|
51
|
+
summary?: string;
|
|
52
|
+
feels_like?: SearXNGWeatherQuantity;
|
|
53
|
+
humidity?: SearXNGWeatherQuantity;
|
|
54
|
+
wind_speed?: SearXNGWeatherQuantity;
|
|
55
|
+
wind_from?: string | { name?: string };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SearXNGAnswerWeather {
|
|
59
|
+
template: "answer/weather.html";
|
|
60
|
+
current: SearXNGWeatherItem;
|
|
61
|
+
forecasts?: SearXNGWeatherItem[];
|
|
62
|
+
service?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type SearXNGAnswer =
|
|
66
|
+
| SearXNGAnswerLegacy
|
|
67
|
+
| SearXNGAnswerTranslations
|
|
68
|
+
| SearXNGAnswerWeather;
|
|
69
|
+
|
|
21
70
|
interface SearXNGResponse {
|
|
22
71
|
results: SearXNGResult[];
|
|
23
|
-
answers:
|
|
72
|
+
answers: SearXNGAnswer[];
|
|
24
73
|
suggestions: string[];
|
|
25
74
|
}
|
|
26
75
|
|
|
76
|
+
// ─── Answer rendering ────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
// Box width (chars). Title row: `┌─ <title> ` + dashes to reach BOX_W.
|
|
79
|
+
const BOX_W = 40;
|
|
80
|
+
|
|
81
|
+
function box(title: string, lines: string[]): string {
|
|
82
|
+
const top = `┌─ ${title} ${"─".repeat(Math.max(0, BOX_W - title.length - 4))}`;
|
|
83
|
+
const bottom = `└${"─".repeat(BOX_W - 1)}`;
|
|
84
|
+
return `${top}\n${lines.map((l) => `│ ${l}`).join("\n")}\n${bottom}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function trunc(s: string, max: number): string {
|
|
88
|
+
return s.length > max ? s.slice(0, max) + "[…]" : s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function windLine(c: SearXNGWeatherItem): string {
|
|
92
|
+
if (!c.wind_speed) return "";
|
|
93
|
+
let dir = "";
|
|
94
|
+
if (c.wind_from) {
|
|
95
|
+
dir =
|
|
96
|
+
typeof c.wind_from === "object" ? (c.wind_from?.name ?? "") : c.wind_from;
|
|
97
|
+
}
|
|
98
|
+
return `wind ${c.wind_speed.val}${c.wind_speed.unit}${dir ? ` ${dir}` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderAnswers(answers: SearXNGAnswer[]): string {
|
|
102
|
+
const blocks: string[] = [];
|
|
103
|
+
|
|
104
|
+
for (const a of answers.slice(0, 3)) {
|
|
105
|
+
let title: string;
|
|
106
|
+
let lines: string[];
|
|
107
|
+
switch (a.template) {
|
|
108
|
+
case "answer/legacy.html": {
|
|
109
|
+
title = "Answer";
|
|
110
|
+
lines = [trunc(a.answer, 500)];
|
|
111
|
+
if (a.url) lines.push(`source: ${a.url}`);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "answer/translations.html": {
|
|
115
|
+
const t = a.translations[0];
|
|
116
|
+
if (!t) continue;
|
|
117
|
+
title = "Translation";
|
|
118
|
+
lines = [t.text];
|
|
119
|
+
if (t.synonyms?.length) {
|
|
120
|
+
lines.push(
|
|
121
|
+
` synonyms: ${t.synonyms.slice(0, 2).join(", ")}${t.synonyms.length > 2 ? ", …" : ""}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const more = a.translations.length - 1;
|
|
125
|
+
if (more > 0) {
|
|
126
|
+
lines.push(
|
|
127
|
+
` ${more} more translation${more > 1 ? "s" : ""}${a.url ? ` (source: ${a.url})` : ""}`,
|
|
128
|
+
);
|
|
129
|
+
} else if (a.url) {
|
|
130
|
+
lines.push(`source: ${a.url}`);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "answer/weather.html": {
|
|
135
|
+
const c = a.current;
|
|
136
|
+
if (!c) continue;
|
|
137
|
+
title = "Weather";
|
|
138
|
+
const line1 =
|
|
139
|
+
c.summary ??
|
|
140
|
+
[
|
|
141
|
+
c.location?.name,
|
|
142
|
+
c.temperature ? `${c.temperature.val}${c.temperature.unit}` : "",
|
|
143
|
+
c.condition,
|
|
144
|
+
]
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.join(": ");
|
|
147
|
+
lines = [line1];
|
|
148
|
+
const details: string[] = [];
|
|
149
|
+
if (c.feels_like)
|
|
150
|
+
details.push(`feels like ${c.feels_like.val}${c.feels_like.unit}`);
|
|
151
|
+
if (c.humidity)
|
|
152
|
+
details.push(`humidity ${c.humidity.val}${c.humidity.unit}`);
|
|
153
|
+
if (c.wind_speed) details.push(windLine(c));
|
|
154
|
+
|
|
155
|
+
if (details.length) lines.push(details.join(" · "));
|
|
156
|
+
if (a.service) lines.push(`source: ${a.service}`);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
default: {
|
|
160
|
+
const f = a as unknown as Record<string, unknown>;
|
|
161
|
+
if ("answer" in f && typeof f.answer === "string") {
|
|
162
|
+
title = "Answer";
|
|
163
|
+
lines = [trunc(f.answer as string, 500)];
|
|
164
|
+
} else {
|
|
165
|
+
blocks.push(`[answer: ${String(f.template ?? "?")}]`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
blocks.push(box(title, lines));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return blocks.join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function answerDetail(a: SearXNGAnswer): {
|
|
177
|
+
template: string;
|
|
178
|
+
text: string;
|
|
179
|
+
url?: string;
|
|
180
|
+
} {
|
|
181
|
+
switch (a.template) {
|
|
182
|
+
case "answer/legacy.html":
|
|
183
|
+
return {
|
|
184
|
+
template: a.template,
|
|
185
|
+
text: a.answer,
|
|
186
|
+
...(a.url ? { url: a.url } : {}),
|
|
187
|
+
};
|
|
188
|
+
case "answer/translations.html":
|
|
189
|
+
return {
|
|
190
|
+
template: a.template,
|
|
191
|
+
text: a.translations?.[0]?.text ?? "",
|
|
192
|
+
...(a.url ? { url: a.url } : {}),
|
|
193
|
+
};
|
|
194
|
+
case "answer/weather.html":
|
|
195
|
+
return { template: a.template, text: a.current?.summary ?? "" };
|
|
196
|
+
default: {
|
|
197
|
+
const f = a as unknown as Record<string, unknown>;
|
|
198
|
+
return { template: String(f.template ?? "?"), text: "" };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
27
203
|
// ─── URL building ─────────────────────────────────────────────────
|
|
28
204
|
|
|
29
205
|
function buildSearchUrl(
|
|
@@ -70,6 +246,8 @@ export const webSearchTool = defineTool({
|
|
|
70
246
|
promptGuidelines: [
|
|
71
247
|
'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).',
|
|
72
248
|
"Use `pageno` (1-indexed) to fetch deeper pages when `count` results are not enough.",
|
|
249
|
+
"Instant answers (calculator, unit convert, random uuid, hashes, weather, translations) appear above results \u2014 phrase queries like `avg 1 2 3` or `weather berlin` to trigger them.",
|
|
250
|
+
'Target results precisely: prefix the query with a bang to route to a specific source \u2014 `!wp <term>` (Wikipedia), `!images <term>`, `!map <term>`, or `:<lang> !<bang> <term>` (e.g. `:fr !wp paris`). Use the `engines` param to restrict upstreams (e.g. engines="google,bing"). For site-scoped queries put the operator inside `query` \u2014 `site:github.com foo`, `inurl:`, `intitle:`, `filetype:pdf` \u2014 but note these only work with engines that support them (Google, Bing, DuckDuckGo) and are silently ignored by others.',
|
|
73
251
|
],
|
|
74
252
|
|
|
75
253
|
parameters: Type.Object({
|
|
@@ -317,7 +495,27 @@ export const webSearchTool = defineTool({
|
|
|
317
495
|
// Slice to requested count
|
|
318
496
|
const results = sortedResults.slice(0, Math.min(count, 100));
|
|
319
497
|
|
|
498
|
+
// Render answer blocks (before empty-results check so answers show even with zero web results)
|
|
499
|
+
const renderedAnswers = (data.answers ?? []).slice(0, 3);
|
|
500
|
+
const answerBlocks = renderAnswers(renderedAnswers);
|
|
501
|
+
|
|
320
502
|
if (results.length === 0) {
|
|
503
|
+
if (answerBlocks) {
|
|
504
|
+
return {
|
|
505
|
+
content: [
|
|
506
|
+
{
|
|
507
|
+
type: "text" as const,
|
|
508
|
+
text: answerBlocks + `\n\nNo web results for "${query}".`,
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
details: {
|
|
512
|
+
results: [],
|
|
513
|
+
query,
|
|
514
|
+
answers: renderedAnswers.map(answerDetail),
|
|
515
|
+
answerCount: renderedAnswers.length,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
321
519
|
return {
|
|
322
520
|
content: [
|
|
323
521
|
{
|
|
@@ -331,7 +529,7 @@ export const webSearchTool = defineTool({
|
|
|
331
529
|
|
|
332
530
|
// Adaptive output formatting
|
|
333
531
|
const maxSnippetLen = count <= 3 ? 300 : 150;
|
|
334
|
-
let output = "";
|
|
532
|
+
let output = answerBlocks ? answerBlocks + "\n\n" : "";
|
|
335
533
|
for (const [i, r] of results.entries()) {
|
|
336
534
|
output += `${i + 1}. ${r.title}\n`;
|
|
337
535
|
output += ` ${r.url}\n`;
|
|
@@ -370,6 +568,8 @@ export const webSearchTool = defineTool({
|
|
|
370
568
|
engine: r.engine,
|
|
371
569
|
score: r.score,
|
|
372
570
|
})),
|
|
571
|
+
answers: renderedAnswers.map(answerDetail),
|
|
572
|
+
answerCount: renderedAnswers.length,
|
|
373
573
|
},
|
|
374
574
|
};
|
|
375
575
|
} catch (unexpectedErr) {
|
|
@@ -438,16 +638,37 @@ export const webSearchTool = defineTool({
|
|
|
438
638
|
| undefined;
|
|
439
639
|
const resultCount = details?.resultCount as number | undefined;
|
|
440
640
|
const query = details?.query as string | undefined;
|
|
641
|
+
const answerCount = details?.answerCount as number | undefined;
|
|
642
|
+
const answers = details?.answers as
|
|
643
|
+
| Array<{ template: string; text: string; url?: string }>
|
|
644
|
+
| undefined;
|
|
645
|
+
|
|
646
|
+
const answerTexts =
|
|
647
|
+
expanded && answers?.length
|
|
648
|
+
? answers.map((a) => theme.fg("accent", a.text)).join("\n")
|
|
649
|
+
: "";
|
|
650
|
+
|
|
651
|
+
if (answerCount && (!results || results.length === 0)) {
|
|
652
|
+
let msg = `${theme.fg("muted", `💡 ${answerCount} answer(s) for `)}${theme.fg("accent", `"${query ?? "?"}"`)}`;
|
|
653
|
+
if (answerTexts) msg += `\n${answerTexts}`;
|
|
654
|
+
return new Text(msg, 0, 0);
|
|
655
|
+
}
|
|
441
656
|
|
|
442
657
|
if (!results || results.length === 0) {
|
|
443
658
|
const msg = query ? `No results for "${query}"` : "No results";
|
|
444
659
|
return new Text(theme.fg("dim", msg), 0, 0);
|
|
445
660
|
}
|
|
446
661
|
|
|
662
|
+
const answerBadge = answerCount
|
|
663
|
+
? theme.fg("muted", `💡 ${answerCount} answer(s) · `)
|
|
664
|
+
: "";
|
|
447
665
|
let text =
|
|
666
|
+
answerBadge +
|
|
448
667
|
theme.fg("muted", `🔍 ${resultCount ?? results.length} result(s) for `) +
|
|
449
668
|
theme.fg("accent", `"${query ?? "?"}"`);
|
|
450
669
|
|
|
670
|
+
if (answerTexts) text += `\n${answerTexts}`;
|
|
671
|
+
|
|
451
672
|
const display = expanded ? results : results.slice(0, 5);
|
|
452
673
|
|
|
453
674
|
for (const r of display) {
|