pi-lean-dimension 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.
@@ -5,7 +5,7 @@
5
5
  * instance and are not included here.
6
6
  */
7
7
 
8
- import { describe, it, expect, vi, beforeEach } from "vitest";
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
9
9
  import { readSearxngUrl } from "../search-config.js";
10
10
  import { webSearchTool } from "../web-search-tool.js";
11
11
 
@@ -150,4 +150,244 @@ describe("webSearchTool", () => {
150
150
  it("has renderResult function", () => {
151
151
  expect(typeof webSearchTool.renderResult).toBe("function");
152
152
  });
153
+
154
+ describe("renderResult answer badge", () => {
155
+ it("shows ๐Ÿ’ก badge when answerCount > 0 and no results", () => {
156
+ const mockTheme = {
157
+ fg: (_style: string, text: string) => text,
158
+ } as any;
159
+ const result = (webSearchTool.renderResult as any)(
160
+ {
161
+ content: [{ text: "" }],
162
+ details: { answerCount: 2, query: "weather" },
163
+ } as any,
164
+ { expanded: false, isPartial: false },
165
+ mockTheme,
166
+ {} as any,
167
+ ) as any;
168
+ expect(result.text).toContain("๐Ÿ’ก");
169
+ expect(result.text).toContain("2 answer(s)");
170
+ expect(result.text).toContain('"weather"');
171
+ });
172
+
173
+ it("shows ๐Ÿ’ก badge alongside ๐Ÿ” when both answers and results present", () => {
174
+ const mockTheme = {
175
+ fg: (_style: string, text: string) => text,
176
+ } as any;
177
+ const result = (webSearchTool.renderResult as any)(
178
+ {
179
+ content: [{ text: "" }],
180
+ details: {
181
+ answerCount: 1,
182
+ resultCount: 3,
183
+ query: "test",
184
+ results: [
185
+ { title: "A", url: "http://a.com", engine: "google" },
186
+ { title: "B", url: "http://b.com", engine: "google" },
187
+ { title: "C", url: "http://c.com", engine: "google" },
188
+ ],
189
+ },
190
+ } as any,
191
+ { expanded: false, isPartial: false },
192
+ mockTheme,
193
+ {} as any,
194
+ ) as any;
195
+ expect(result.text).toContain("๐Ÿ’ก");
196
+ expect(result.text).toContain("1 answer(s)");
197
+ expect(result.text).toContain("๐Ÿ”");
198
+ expect(result.text).toContain("3 result(s)");
199
+ });
200
+
201
+ it("shows answer text above results when expanded", () => {
202
+ const mockTheme = {
203
+ fg: (_style: string, text: string) => text,
204
+ } as any;
205
+ const result = (webSearchTool.renderResult as any)(
206
+ {
207
+ content: [{ text: "" }],
208
+ details: {
209
+ answerCount: 1,
210
+ resultCount: 1,
211
+ query: "weather berlin",
212
+ answers: [
213
+ {
214
+ template: "answer/weather.html",
215
+ text: "Berlin: 14ยฐC, light rain",
216
+ },
217
+ ],
218
+ results: [{ title: "A", url: "http://a.com" }],
219
+ },
220
+ } as any,
221
+ { expanded: true, isPartial: false },
222
+ mockTheme,
223
+ {} as any,
224
+ ) as any;
225
+ const answerIdx = result.text.indexOf("Berlin: 14ยฐC, light rain");
226
+ const resultIdx = result.text.indexOf("A");
227
+ expect(answerIdx).toBeGreaterThan(-1);
228
+ expect(resultIdx).toBeGreaterThan(-1);
229
+ expect(answerIdx).toBeLessThan(resultIdx);
230
+ });
231
+
232
+ it("shows answer text when expanded with no results", () => {
233
+ const mockTheme = {
234
+ fg: (_style: string, text: string) => text,
235
+ } as any;
236
+ const result = (webSearchTool.renderResult as any)(
237
+ {
238
+ content: [{ text: "" }],
239
+ details: {
240
+ answerCount: 1,
241
+ query: "avg 1 2 3",
242
+ answers: [{ template: "answer/legacy.html", text: "2" }],
243
+ },
244
+ } as any,
245
+ { expanded: true, isPartial: false },
246
+ mockTheme,
247
+ {} as any,
248
+ ) as any;
249
+ expect(result.text).toContain("๐Ÿ’ก");
250
+ expect(result.text).toContain("2");
251
+ });
252
+ });
253
+ });
254
+
255
+ // โ”€โ”€โ”€ Execute answer rendering (mocked fetch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
256
+
257
+ describe("execute answer rendering", () => {
258
+ beforeEach(() => {
259
+ vi.clearAllMocks();
260
+ // Make readSearxngUrl return a valid URL
261
+ vi.mocked(existsSync).mockReturnValue(true);
262
+ vi.mocked(readFileSync).mockReturnValue(
263
+ JSON.stringify({ searxng: { url: "http://localhost:8888" } }),
264
+ );
265
+ });
266
+
267
+ afterEach(() => {
268
+ vi.unstubAllGlobals();
269
+ });
270
+
271
+ const mockFetch = (json: unknown) =>
272
+ vi.stubGlobal(
273
+ "fetch",
274
+ vi.fn().mockResolvedValue({
275
+ ok: true,
276
+ status: 200,
277
+ text: async () => JSON.stringify(json),
278
+ }),
279
+ );
280
+
281
+ it("legacy answer renders + bypasses empty-results early return", async () => {
282
+ mockFetch({
283
+ results: [],
284
+ answers: [{ template: "answer/legacy.html", answer: "614.24" }],
285
+ suggestions: [],
286
+ });
287
+
288
+ const result = await webSearchTool.execute(
289
+ "call-1",
290
+ { query: "614.24" },
291
+ undefined,
292
+ undefined,
293
+ {} as any,
294
+ );
295
+ const r = result as any;
296
+ expect(r.content[0].text).toContain("614.24");
297
+ expect(r.content[0].text).not.toContain("No web search results found");
298
+ expect(r.content[0].text).toContain("No web results");
299
+ });
300
+
301
+ it("weather answer uses server summary", async () => {
302
+ mockFetch({
303
+ results: [],
304
+ answers: [
305
+ {
306
+ template: "answer/weather.html",
307
+ current: {
308
+ location: { name: "Berlin", latitude: 52.52, longitude: 13.405 },
309
+ temperature: { val: 14, unit: "ยฐC" },
310
+ condition: "light rain",
311
+ summary: "Berlin: 14ยฐC, light rain",
312
+ feels_like: { val: 12, unit: "ยฐC" },
313
+ humidity: { val: 80, unit: "%" },
314
+ wind_speed: { val: 18, unit: "km/h" },
315
+ wind_from: { name: "NW" },
316
+ },
317
+ service: "open-meteo",
318
+ },
319
+ ],
320
+ suggestions: [],
321
+ });
322
+
323
+ const result = await webSearchTool.execute(
324
+ "call-2",
325
+ { query: "weather berlin" },
326
+ undefined,
327
+ undefined,
328
+ {} as any,
329
+ );
330
+ // Uses server-provided summary
331
+ const r = result as any;
332
+ expect(r.content[0].text).toContain("Berlin: 14ยฐC, light rain");
333
+ // Secondary detail line
334
+ expect(r.content[0].text).toContain("feels like 12ยฐC");
335
+ expect(r.content[0].text).toContain("humidity 80%");
336
+ expect(r.content[0].text).toContain("wind 18km/h");
337
+ // forecasts not rendered
338
+ expect(r.content[0].text).not.toContain("forecast");
339
+ // source
340
+ expect(r.content[0].text).toContain("open-meteo");
341
+ });
342
+
343
+ it("unknown template falls back to legacy heuristic", async () => {
344
+ mockFetch({
345
+ results: [],
346
+ answers: [{ template: "answer/future.html", answer: "x" }],
347
+ suggestions: [],
348
+ });
349
+
350
+ const result = await webSearchTool.execute(
351
+ "call-3",
352
+ { query: "future" },
353
+ undefined,
354
+ undefined,
355
+ {} as any,
356
+ );
357
+ const r = result as any;
358
+ expect(r.content[0].text).toContain("x");
359
+ });
360
+
361
+ it("answers + results both present (primary success path)", async () => {
362
+ mockFetch({
363
+ results: [
364
+ {
365
+ title: "Result 1",
366
+ url: "http://example.com/1",
367
+ content: "First result",
368
+ engine: "google",
369
+ score: 0.95,
370
+ },
371
+ ],
372
+ answers: [{ template: "answer/legacy.html", answer: "42" }],
373
+ suggestions: [],
374
+ });
375
+
376
+ const result = await webSearchTool.execute(
377
+ "call-5",
378
+ { query: "answer of life" },
379
+ undefined,
380
+ undefined,
381
+ {} as any,
382
+ );
383
+ // Answer above results
384
+ const r = result as any;
385
+ const answerIdx = r.content[0].text.indexOf("42");
386
+ const resultIdx = r.content[0].text.indexOf("Result 1");
387
+ expect(answerIdx).toBeGreaterThanOrEqual(0);
388
+ expect(resultIdx).toBeGreaterThan(answerIdx);
389
+ // Both present
390
+ expect(r.content[0].text).toContain("42");
391
+ expect(r.content[0].text).toContain("Result 1");
392
+ });
153
393
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.2.1",
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",
@@ -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,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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-dimension",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi extension. The full Pi web-tools suite in one install โ€” interactive browsing (pi-lean-portal) plus SearXNG search (pi-lean-search), unified under a /web toggle that removes the tools from context when switched off.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,8 +32,8 @@
32
32
  ]
33
33
  },
34
34
  "dependencies": {
35
- "pi-lean-portal": "^0.2.1",
36
- "pi-lean-search": "^0.2.1"
35
+ "pi-lean-portal": "^0.2.3",
36
+ "pi-lean-search": "^0.2.3"
37
37
  },
38
38
  "bundledDependencies": [
39
39
  "pi-lean-portal",