pi-lean-search 0.2.0 → 0.2.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/web-search-tool.ts +222 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -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: string[];
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,7 @@ 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.",
73
250
  ],
74
251
 
75
252
  parameters: Type.Object({
@@ -317,7 +494,27 @@ export const webSearchTool = defineTool({
317
494
  // Slice to requested count
318
495
  const results = sortedResults.slice(0, Math.min(count, 100));
319
496
 
497
+ // Render answer blocks (before empty-results check so answers show even with zero web results)
498
+ const renderedAnswers = (data.answers ?? []).slice(0, 3);
499
+ const answerBlocks = renderAnswers(renderedAnswers);
500
+
320
501
  if (results.length === 0) {
502
+ if (answerBlocks) {
503
+ return {
504
+ content: [
505
+ {
506
+ type: "text" as const,
507
+ text: answerBlocks + `\n\nNo web results for "${query}".`,
508
+ },
509
+ ],
510
+ details: {
511
+ results: [],
512
+ query,
513
+ answers: renderedAnswers.map(answerDetail),
514
+ answerCount: renderedAnswers.length,
515
+ },
516
+ };
517
+ }
321
518
  return {
322
519
  content: [
323
520
  {
@@ -331,7 +528,7 @@ export const webSearchTool = defineTool({
331
528
 
332
529
  // Adaptive output formatting
333
530
  const maxSnippetLen = count <= 3 ? 300 : 150;
334
- let output = "";
531
+ let output = answerBlocks ? answerBlocks + "\n\n" : "";
335
532
  for (const [i, r] of results.entries()) {
336
533
  output += `${i + 1}. ${r.title}\n`;
337
534
  output += ` ${r.url}\n`;
@@ -370,6 +567,8 @@ export const webSearchTool = defineTool({
370
567
  engine: r.engine,
371
568
  score: r.score,
372
569
  })),
570
+ answers: renderedAnswers.map(answerDetail),
571
+ answerCount: renderedAnswers.length,
373
572
  },
374
573
  };
375
574
  } catch (unexpectedErr) {
@@ -438,16 +637,37 @@ export const webSearchTool = defineTool({
438
637
  | undefined;
439
638
  const resultCount = details?.resultCount as number | undefined;
440
639
  const query = details?.query as string | undefined;
640
+ const answerCount = details?.answerCount as number | undefined;
641
+ const answers = details?.answers as
642
+ | Array<{ template: string; text: string; url?: string }>
643
+ | undefined;
644
+
645
+ const answerTexts =
646
+ expanded && answers?.length
647
+ ? answers.map((a) => theme.fg("accent", a.text)).join("\n")
648
+ : "";
649
+
650
+ if (answerCount && (!results || results.length === 0)) {
651
+ let msg = `${theme.fg("muted", `💡 ${answerCount} answer(s) for `)}${theme.fg("accent", `"${query ?? "?"}"`)}`;
652
+ if (answerTexts) msg += `\n${answerTexts}`;
653
+ return new Text(msg, 0, 0);
654
+ }
441
655
 
442
656
  if (!results || results.length === 0) {
443
657
  const msg = query ? `No results for "${query}"` : "No results";
444
658
  return new Text(theme.fg("dim", msg), 0, 0);
445
659
  }
446
660
 
661
+ const answerBadge = answerCount
662
+ ? theme.fg("muted", `💡 ${answerCount} answer(s) · `)
663
+ : "";
447
664
  let text =
665
+ answerBadge +
448
666
  theme.fg("muted", `🔍 ${resultCount ?? results.length} result(s) for `) +
449
667
  theme.fg("accent", `"${query ?? "?"}"`);
450
668
 
669
+ if (answerTexts) text += `\n${answerTexts}`;
670
+
451
671
  const display = expanded ? results : results.slice(0, 5);
452
672
 
453
673
  for (const r of display) {