pi-lean-search 0.3.3 → 0.5.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-lean-search
1
+ # pi-lean-search User Guide
2
2
 
3
3
  > SearXNG search tool for Pi. Pairs with [pi-lean-portal](https://www.npmjs.com/package/pi-lean-portal)'s
4
4
  > `/web` toggle — search-only installs are valid, or add it to a portal install
@@ -8,17 +8,31 @@
8
8
 
9
9
  ## Quick start
10
10
 
11
+ `web-search` needs a SearXNG instance to talk to. If you don't have one,
12
+ the fastest route is the official Docker image:
13
+
11
14
  ```bash
12
- pi install npm:pi-lean-portal # recommended: browser + /web toggle
13
- pi install npm:pi-lean-search # adds web-search to /web on|off
15
+ docker run -d --name searxng -p 8888:8080 searxng/searxng
14
16
  ```
15
17
 
16
- Or get the full suite in one command:
18
+ Any reachable SearXNG instance works — self-hosted or public. See the
19
+ [SearXNG docs](https://docs.searxng.org/) for other install methods and
20
+ instance administration.
21
+
22
+ Then install the tool:
17
23
 
18
24
  ```bash
19
- pi install npm:pi-lean-dimension # portal + search, requires SearXNG server
25
+ pi install npm:pi-lean-search
20
26
  ```
21
27
 
28
+ Pair with [pi-lean-portal](https://www.npmjs.com/package/pi-lean-portal) for
29
+ browser tools + the `/web` toggle, or install
30
+ [pi-lean-dimension](https://www.npmjs.com/package/pi-lean-dimension) to get
31
+ both in one command.
32
+
33
+ Finally, point the tool at your instance — [Configuration](#configuration)
34
+ below.
35
+
22
36
  ## Usage
23
37
 
24
38
  | Command / Tool | Description |
@@ -45,8 +59,29 @@ Set the URL of your SearXNG instance in your Pi settings file:
45
59
  ```
46
60
 
47
61
  - **Self-hosted SearXNG:** Run your own instance ([docs](https://docs.searxng.org/)).
62
+ - **Public instance:** Any SearXNG instance you can reach works — set its URL here. Check the instance's terms or rate limits before pointing an automated tool at it.
48
63
  - No URL configured? The tool returns a setup message on its first call — no errors, no broken prompts.
49
64
 
65
+ ### Example output
66
+
67
+ A `web-search` call renders as a numbered list the agent reads:
68
+
69
+ ```text
70
+ 1. Example result title
71
+ https://example.com/page
72
+ one-line snippet from the page
73
+ [engine] | score: 1.00
74
+ 2. …
75
+ ```
76
+
77
+ The snippet line is omitted when the page has no content. The
78
+ `[engine] | score:` suffix appears when the result carries engine/score
79
+ metadata; the engine tag itself is shown only when multiple results are
80
+ requested.
81
+
82
+ SearXNG instant answers (calculator, weather, translations) are passed
83
+ through when the query triggers them.
84
+
50
85
  ## Graceful degradation
51
86
 
52
87
  If SearXNG is unreachable or unconfigured, the `web-search` tool returns a clear
@@ -54,6 +89,8 @@ message pointing you toward setup instructions. It never throws or breaks the ag
54
89
 
55
90
  ## Tests
56
91
 
92
+ From the [monorepo root](https://github.com/coreyryanhanson/pi-lean-dimension):
93
+
57
94
  ```bash
58
95
  npx vitest run packages/pi-lean-search/
59
96
  ```
package/index.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "pi-tool-masking";
24
24
  import type { ToolsetSpec, ToolsetChangedEvent } from "pi-tool-masking";
25
25
  import { readSearxngUrl } from "./search-config.js";
26
- import { webSearchTool } from "./web-search-tool.js";
26
+ import { webSearchTool, normalizeBaseUrl } from "./web-search-tool.js";
27
27
 
28
28
  // ─── Toolset spec ────────────────────────────────────────────────
29
29
 
@@ -54,63 +54,34 @@ let _lastCtx: ExtensionContext | null = null;
54
54
  // ─── Health probes ───────────────────────────────────────────────
55
55
 
56
56
  /**
57
- * Lightweight server probe — fetches the SearXNG root HTML page.
58
- * Fast (~ms) because it only needs the HTTP response, not the full
59
- * aggregation pipeline.
57
+ * Shared probe — fetch a URL with a timeout, report reachability.
58
+ * With `parseJson`, additionally require the body to parse as JSON
59
+ * (full-pipeline check: triggers SearXNG upstream aggregation, slower).
60
60
  */
61
- async function checkServerReachable(
61
+ async function probe(
62
62
  url: string,
63
- signal?: AbortSignal,
63
+ timeoutMs: number,
64
+ signal: AbortSignal | undefined,
65
+ opts?: { parseJson?: boolean },
64
66
  ): Promise<boolean> {
65
67
  try {
66
68
  const controller = new AbortController();
67
- const timeoutId = setTimeout(() => controller.abort(), 2000);
69
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
68
70
 
69
71
  let res: Response;
70
72
  try {
71
73
  const mergedSignal = signal
72
74
  ? AbortSignal.any([signal, controller.signal])
73
75
  : controller.signal;
74
- res = await fetch(url, { signal: mergedSignal });
76
+ const init: RequestInit = { signal: mergedSignal };
77
+ if (opts?.parseJson) init.headers = { Accept: "application/json" };
78
+ res = await fetch(url, init);
75
79
  } finally {
76
80
  clearTimeout(timeoutId);
77
81
  }
78
82
 
79
- return res.ok && res.status === 200;
80
- } catch {
81
- return false;
82
- }
83
- }
84
-
85
- /**
86
- * Full-pipeline probe — verifies the search API actually works end-to-end.
87
- * Triggers upstream engine aggregation, so it takes longer (5s timeout).
88
- */
89
- async function checkSearchReachable(
90
- url: string,
91
- signal?: AbortSignal,
92
- ): Promise<boolean> {
93
- const normalized = url.replace(/\/+$/, "");
94
- const searchUrl = `${normalized}/search?q=ping&format=json`;
95
-
96
- try {
97
- const controller = new AbortController();
98
- const timeoutId = setTimeout(() => controller.abort(), 5000);
99
-
100
- let res: Response;
101
- try {
102
- const mergedSignal = signal
103
- ? AbortSignal.any([signal, controller.signal])
104
- : controller.signal;
105
- res = await fetch(searchUrl, {
106
- signal: mergedSignal,
107
- headers: { Accept: "application/json" },
108
- });
109
- } finally {
110
- clearTimeout(timeoutId);
111
- }
112
-
113
- if (!res.ok || res.status !== 200) return false;
83
+ if (!res.ok) return false;
84
+ if (!opts?.parseJson) return true;
114
85
  const text = await res.text();
115
86
  if (!text) return false;
116
87
  JSON.parse(text);
@@ -120,6 +91,21 @@ async function checkSearchReachable(
120
91
  }
121
92
  }
122
93
 
94
+ /** Lightweight server probe — SearXNG root page, HTTP response only. */
95
+ function checkServerReachable(url: string, signal?: AbortSignal) {
96
+ return probe(url, 2000, signal);
97
+ }
98
+
99
+ /** Full-pipeline probe — `/search?q=ping&format=json`, JSON body required. */
100
+ function checkSearchReachable(url: string, signal?: AbortSignal) {
101
+ return probe(
102
+ `${normalizeBaseUrl(url)}/search?q=ping&format=json`,
103
+ 5000,
104
+ signal,
105
+ { parseJson: true },
106
+ );
107
+ }
108
+
123
109
  // ─── Status slot helpers ─────────────────────────────────────────
124
110
 
125
111
  /**
@@ -202,26 +188,16 @@ export default function (pi: ExtensionAPI) {
202
188
  const searchToolset = defineToolset(pi, SEARCH_WEB_SPEC);
203
189
 
204
190
  // ── Co-activation: mirror pi-lean-dimension.web changed events ─
205
- // Listen on changed ONLY, not restored (§10.1).
191
+ // Listen on changed ONLY, not restored.
206
192
  //
207
193
  // Focus-mode guard: while allowlist focus holds the line, skip
208
194
  // co-activation. The focus set is authoritative, so a web `changed` event
209
195
  // — including one a stale library `doRestore` emits during resume — must
210
196
  // not disable search or write a focus-indistinguishable {enabled} entry.
211
- // The published `DefaultResolutionMode` type doesn't name `"allowlist"`
212
- // (it ships in pi-tool-masking 1.2.0), so the string cast is load-bearing:
213
- // an allowlist-capable consumer sharing the `globalThis` module state
214
- // writes `"allowlist"` into it and we read it back here. No-op for
215
- // ordinary users on published versions, where nothing ever writes
216
- // `"allowlist"`.
217
- //
218
- // Cleanup at the ^1.2.0 bump: drop the `as string` cast once
219
- // `DefaultResolutionMode` names `"allowlist"` — same as the matching cast
220
- // in pi-lean-portal's browser-toggle focus guard.
221
197
  pi.events.on(TOOLSET_EVENTS.changed, (data: unknown) => {
222
198
  const event = data as ToolsetChangedEvent;
223
199
  if (event.id === "pi-lean-dimension.web") {
224
- if ((getDefaultResolutionMode() as string) === "allowlist") return;
200
+ if (getDefaultResolutionMode() === "allowlist") return;
225
201
  if (event.enabled) {
226
202
  searchToolset.enable(pi);
227
203
  } else {
@@ -242,7 +218,7 @@ export default function (pi: ExtensionAPI) {
242
218
  pi.events.on(TOOLSET_EVENTS.restored, syncSearchState);
243
219
 
244
220
  // ── Session start: health probe + glyph ──────────────────
245
- pi.on("session_start", async (_event, ctx) => {
221
+ pi.on("session_start", async (event, ctx) => {
246
222
  _lastCtx = ctx;
247
223
 
248
224
  // Re-read config in case it changed between sessions
@@ -251,6 +227,15 @@ export default function (pi: ExtensionAPI) {
251
227
  if (!_searxngUrl) {
252
228
  _lastHealth = null;
253
229
  renderSearchGlyph(ctx);
230
+ // Discoverability hint only on Pi process boot — not on /new,
231
+ // /resume, or /fork, where it would be repeated noise.
232
+ if (event.reason === "startup") {
233
+ ctx.ui.notify(
234
+ "SearXNG is not configured (set `searxng.url` in settings.json) — " +
235
+ "web-search is disabled. Run /searxng-status after configuring.",
236
+ "warning",
237
+ );
238
+ }
254
239
  return;
255
240
  }
256
241
 
@@ -312,7 +297,7 @@ export default function (pi: ExtensionAPI) {
312
297
  "Set `searxng.url` in your Pi settings.json.",
313
298
  "error",
314
299
  );
315
- _lastHealth = false;
300
+ _lastHealth = null;
316
301
  renderSearchGlyph(ctx);
317
302
  return;
318
303
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.3.3",
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.",
3
+ "version": "0.5.0",
4
+ "description": "SearXNG web search for Pi. Self-hosted or public instance, no API keys or quotas; returns a setup message instead of erroring when unreachable. Pairs with pi-lean-portal's /web toggle or works standalone.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -24,8 +24,6 @@
24
24
  "LICENSE",
25
25
  "web-search-tool.ts",
26
26
  "search-config.ts",
27
- "verify-ship-manifest.ts",
28
- "ship-manifest.test.ts",
29
27
  "README.md"
30
28
  ],
31
29
  "pi": {
@@ -38,14 +36,13 @@
38
36
  "test": "vitest run"
39
37
  },
40
38
  "dependencies": {
41
- "pi-tool-masking": "^1.1.0"
39
+ "pi-tool-masking": "^1.3.0"
42
40
  },
43
41
  "peerDependencies": {
44
42
  "@earendil-works/pi-ai": "*",
45
43
  "@earendil-works/pi-coding-agent": "*",
46
44
  "@earendil-works/pi-tui": "*",
47
- "pi-lean-portal": "*",
48
- "typebox": "*"
45
+ "pi-lean-portal": "*"
49
46
  },
50
47
  "peerDependenciesMeta": {
51
48
  "pi-lean-portal": {
package/search-config.ts CHANGED
@@ -2,7 +2,11 @@
2
2
  * Config reader for pi-lean-search.
3
3
  *
4
4
  * Reads `searxng.url` from Pi's merged settings.json files
5
- * (global ~/.pi/agent/settings.json + project-local .pi/settings.json).
5
+ * (global settings.json + project-local .pi/settings.json).
6
+ *
7
+ * The global path honors `PI_CODING_AGENT_DIR`, matching pi-tool-masking's
8
+ * `settingsPath()` — otherwise a relocated agent dir would read its toolset
9
+ * defaults from one file and `searxng.url` from another.
6
10
  *
7
11
  * The expected shape in settings.json:
8
12
  * ```json
@@ -10,14 +14,16 @@
10
14
  * ```
11
15
  */
12
16
 
13
- import { existsSync, readFileSync } from "node:fs";
17
+ import { readFileSync } from "node:fs";
14
18
  import { homedir } from "node:os";
15
19
  import { join } from "node:path";
16
20
 
17
21
  // ─── Config paths ─────────────────────────────────────────────────
18
22
 
19
- /** Global pi settings path. */
20
- const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
23
+ /** Global pi settings dir (`$PI_CODING_AGENT_DIR` or `~/.pi/agent`). */
24
+ function globalSettingsDir(): string {
25
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
26
+ }
21
27
 
22
28
  /** Project-local pi settings path (relative to cwd). */
23
29
  const PROJECT_SETTINGS_PATH = ".pi/settings.json";
@@ -25,24 +31,24 @@ const PROJECT_SETTINGS_PATH = ".pi/settings.json";
25
31
  // ─── Reader ───────────────────────────────────────────────────────
26
32
 
27
33
  function readSettingsFile(path: string): Record<string, unknown> {
28
- try {
29
- if (!existsSync(path)) return {};
30
- const raw = readFileSync(path, "utf-8");
31
- const parsed = JSON.parse(raw);
32
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
33
- return parsed as Record<string, unknown>;
34
- }
35
- return {};
36
- } catch {
37
- return {};
38
- }
34
+ try {
35
+ // Missing/invalid files throw and fall through to the catch → {}.
36
+ const raw = readFileSync(path, "utf-8");
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
39
+ return parsed as Record<string, unknown>;
40
+ }
41
+ return {};
42
+ } catch {
43
+ return {};
44
+ }
39
45
  }
40
46
 
41
47
  /**
42
48
  * Read the configured SearXNG URL from merged Pi settings.
43
49
  *
44
50
  * Looks up `searxng.url` in:
45
- * 1. `~/.pi/agent/settings.json` (global)
51
+ * 1. `<agent dir>/settings.json` (global; `PI_CODING_AGENT_DIR` or `~/.pi/agent`)
46
52
  * 2. `.pi/settings.json` (project-local, overrides global)
47
53
  *
48
54
  * Returns the URL string if configured, or `undefined` if absent
@@ -50,14 +56,14 @@ function readSettingsFile(path: string): Record<string, unknown> {
50
56
  * message when no URL is configured).
51
57
  */
52
58
  export function readSearxngUrl(): string | undefined {
53
- const global = readSettingsFile(GLOBAL_SETTINGS_PATH);
54
- const project = readSettingsFile(PROJECT_SETTINGS_PATH);
55
- const merged = { ...global, ...project };
56
-
57
- const searxng = merged.searxng;
58
- if (searxng && typeof searxng === "object" && !Array.isArray(searxng)) {
59
- const url = (searxng as Record<string, unknown>).url;
60
- if (typeof url === "string" && url.length > 0) return url;
61
- }
62
- return undefined;
59
+ const global = readSettingsFile(join(globalSettingsDir(), "settings.json"));
60
+ const project = readSettingsFile(PROJECT_SETTINGS_PATH);
61
+ const merged = { ...global, ...project };
62
+
63
+ const searxng = merged.searxng;
64
+ if (searxng && typeof searxng === "object" && !Array.isArray(searxng)) {
65
+ const url = (searxng as Record<string, unknown>).url;
66
+ if (typeof url === "string" && url.length > 0) return url;
67
+ }
68
+ return undefined;
63
69
  }
@@ -27,10 +27,7 @@ interface SearXNGAnswerLegacy {
27
27
 
28
28
  interface SearXNGAnswerTranslationItem {
29
29
  text: string;
30
- transliteration?: string;
31
- definitions?: string[];
32
30
  synonyms?: string[];
33
- examples?: string[];
34
31
  }
35
32
 
36
33
  interface SearXNGAnswerTranslations {
@@ -45,7 +42,7 @@ interface SearXNGWeatherQuantity {
45
42
  }
46
43
 
47
44
  interface SearXNGWeatherItem {
48
- location?: { name: string; latitude?: number; longitude?: number };
45
+ location?: { name: string };
49
46
  temperature?: SearXNGWeatherQuantity;
50
47
  condition?: string;
51
48
  summary?: string;
@@ -58,7 +55,6 @@ interface SearXNGWeatherItem {
58
55
  interface SearXNGAnswerWeather {
59
56
  template: "answer/weather.html";
60
57
  current: SearXNGWeatherItem;
61
- forecasts?: SearXNGWeatherItem[];
62
58
  service?: string;
63
59
  }
64
60
 
@@ -75,6 +71,21 @@ interface SearXNGResponse {
75
71
 
76
72
  // ─── Answer rendering ────────────────────────────────────────────
77
73
 
74
+ // Duck-types an unknown SearXNG answer template so it still renders.
75
+ function unknownAnswerFallback(a: SearXNGAnswer): {
76
+ template: string;
77
+ answer?: string;
78
+ } {
79
+ // SAFETY: SearXNG can return answer templates not in the union above;
80
+ // the payload is untyped JSON, so duck-type it as a generic record and
81
+ // pick up a bare `answer` string if present.
82
+ const f = a as unknown as Record<string, unknown>;
83
+ return {
84
+ template: String(f.template ?? "?"),
85
+ ...(typeof f.answer === "string" ? { answer: f.answer } : {}),
86
+ };
87
+ }
88
+
78
89
  // Box width (chars). Title row: `┌─ <title> ` + dashes to reach BOX_W.
79
90
  const BOX_W = 40;
80
91
 
@@ -101,7 +112,8 @@ function windLine(c: SearXNGWeatherItem): string {
101
112
  function renderAnswers(answers: SearXNGAnswer[]): string {
102
113
  const blocks: string[] = [];
103
114
 
104
- for (const a of answers.slice(0, 3)) {
115
+ // Callers pre-slice to 3 — don't cap again here.
116
+ for (const a of answers) {
105
117
  let title: string;
106
118
  let lines: string[];
107
119
  switch (a.template) {
@@ -157,14 +169,13 @@ function renderAnswers(answers: SearXNGAnswer[]): string {
157
169
  break;
158
170
  }
159
171
  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 ?? "?")}]`);
172
+ const fallback = unknownAnswerFallback(a);
173
+ if (fallback.answer === undefined) {
174
+ blocks.push(`[answer: ${fallback.template}]`);
166
175
  continue;
167
176
  }
177
+ title = "Answer";
178
+ lines = [trunc(fallback.answer, 500)];
168
179
  }
169
180
  }
170
181
  blocks.push(box(title, lines));
@@ -194,19 +205,23 @@ function answerDetail(a: SearXNGAnswer): {
194
205
  case "answer/weather.html":
195
206
  return { template: a.template, text: a.current?.summary ?? "" };
196
207
  default: {
197
- const f = a as unknown as Record<string, unknown>;
198
- return { template: String(f.template ?? "?"), text: "" };
208
+ const fallback = unknownAnswerFallback(a);
209
+ return { template: fallback.template, text: fallback.answer ?? "" };
199
210
  }
200
211
  }
201
212
  }
202
213
 
203
214
  // ─── URL building ─────────────────────────────────────────────────
204
215
 
205
- function buildSearchUrl(
216
+ /** Strip trailing slashes so `${base}/search` never double-slashes. */
217
+ export function normalizeBaseUrl(baseUrl: string): string {
218
+ return baseUrl.replace(/\/+$/, "");
219
+ }
220
+
221
+ export function buildSearchUrl(
206
222
  baseUrl: string,
207
223
  query: string,
208
224
  options: {
209
- count: number;
210
225
  pageno: number;
211
226
  language: string;
212
227
  safesearch: string;
@@ -215,14 +230,12 @@ function buildSearchUrl(
215
230
  engines: string;
216
231
  },
217
232
  ): string {
218
- const normalized = baseUrl.replace(/\/+$/, "");
233
+ const normalized = normalizeBaseUrl(baseUrl);
219
234
  const params = new URLSearchParams({
220
235
  format: "json",
221
236
  q: query,
222
237
  });
223
238
 
224
- params.set("limit", String(options.count));
225
-
226
239
  if (options.language) params.set("language", options.language);
227
240
  if (options.safesearch) params.set("safesearch", options.safesearch);
228
241
  if (options.time_range) params.set("time_range", options.time_range);
@@ -235,6 +248,14 @@ function buildSearchUrl(
235
248
 
236
249
  // ─── Tool definition ──────────────────────────────────────────────
237
250
 
251
+ /** Uniform failure return: a text block plus tool details. */
252
+ function fail<T extends object>(text: string, details: T) {
253
+ return {
254
+ content: [{ type: "text" as const, text }],
255
+ details,
256
+ };
257
+ }
258
+
238
259
  export const webSearchTool = defineTool({
239
260
  name: "web-search",
240
261
  label: "Web Search",
@@ -309,8 +330,7 @@ export const webSearchTool = defineTool({
309
330
  ),
310
331
  engines: Type.Optional(
311
332
  Type.String({
312
- description:
313
- 'Comma-separated upstream search engines (e.g. "google,bing")',
333
+ description: 'Comma-separated upstream search engines (e.g. "google,bing")',
314
334
  }),
315
335
  ),
316
336
  }),
@@ -331,21 +351,15 @@ export const webSearchTool = defineTool({
331
351
  // ── Config check: graceful degradation when unconfigured ──
332
352
  const searxngUrl = readSearxngUrl();
333
353
  if (!searxngUrl) {
334
- return {
335
- content: [
336
- {
337
- type: "text" as const,
338
- text:
339
- "Web search is not configured. " +
340
- "Set `searxng.url` in `~/.pi/agent/settings.json` " +
341
- "or `.pi/settings.json` to your SearXNG instance URL. " +
342
- "For example:\n" +
343
- ' ```json\n { "searxng": { "url": "http://localhost:8888" } }\n ```\n' +
344
- "See the pi-lean-search README for self-host vs public instance options.",
345
- },
346
- ],
347
- details: { error: true, unconfigured: true },
348
- };
354
+ return fail(
355
+ "Web search is not configured. " +
356
+ "Set `searxng.url` in `~/.pi/agent/settings.json` " +
357
+ "or `.pi/settings.json` to your SearXNG instance URL. " +
358
+ "For example:\n" +
359
+ ' ```json\n { "searxng": { "url": "http://localhost:8888" } }\n ```\n' +
360
+ "See the pi-lean-search README for self-host vs public instance options.",
361
+ { unconfigured: true },
362
+ );
349
363
  }
350
364
 
351
365
  // ── Timeout ──
@@ -353,7 +367,6 @@ export const webSearchTool = defineTool({
353
367
 
354
368
  // ── Build URL ──
355
369
  const url = buildSearchUrl(searxngUrl, query, {
356
- count,
357
370
  pageno,
358
371
  language,
359
372
  safesearch,
@@ -372,10 +385,7 @@ export const webSearchTool = defineTool({
372
385
  });
373
386
  }
374
387
  if (_signal?.aborted) {
375
- return {
376
- content: [{ type: "text" as const, text: "Web search cancelled." }],
377
- details: { cancelled: true },
378
- };
388
+ return fail("Web search cancelled.", { cancelled: true });
379
389
  }
380
390
 
381
391
  const timeoutId = setTimeout(() => {
@@ -398,61 +408,32 @@ export const webSearchTool = defineTool({
398
408
  connectionErr.name === "AbortError"
399
409
  ) {
400
410
  if (timedOut) {
401
- return {
402
- content: [
403
- {
404
- type: "text" as const,
405
- text:
406
- `Web search timed out after ${timeoutSeconds}s. ` +
407
- `The SearXNG instance at \`${searxngUrl}\` may be slow ` +
408
- "or unresponsive.",
409
- },
410
- ],
411
- details: {
412
- error: true,
413
- timedOut: true,
414
- timeout: timeoutSeconds,
415
- },
416
- };
411
+ return fail(
412
+ `Web search timed out after ${timeoutSeconds}s. ` +
413
+ `The SearXNG instance at \`${searxngUrl}\` may be slow ` +
414
+ "or unresponsive.",
415
+ { timedOut: true, timeout: timeoutSeconds },
416
+ );
417
417
  }
418
- return {
419
- content: [
420
- {
421
- type: "text" as const,
422
- text: "Web search was cancelled.",
423
- },
424
- ],
425
- details: { cancelled: true },
426
- };
418
+ return fail("Web search was cancelled.", { cancelled: true });
427
419
  }
428
- return {
429
- content: [
430
- {
431
- type: "text" as const,
432
- text:
433
- "Web search connection failed: " +
434
- (connectionErr instanceof Error
435
- ? connectionErr.message
436
- : String(connectionErr)),
437
- },
438
- ],
439
- details: { error: true, connectionError: true },
440
- };
420
+ return fail(
421
+ "Web search connection failed: " +
422
+ (connectionErr instanceof Error
423
+ ? connectionErr.message
424
+ : String(connectionErr)),
425
+ { connectionError: true },
426
+ );
441
427
  }
442
428
 
443
429
  clearTimeout(timeoutId);
444
430
 
445
431
  // ── Layer 2: HTTP error handling ──
446
432
  if (!response.ok) {
447
- return {
448
- content: [
449
- {
450
- type: "text" as const,
451
- text: `SearXNG error: HTTP ${response.status} ${response.statusText}`,
452
- },
453
- ],
454
- details: { error: true, status: response.status },
455
- };
433
+ return fail(
434
+ `SearXNG error: HTTP ${response.status} ${response.statusText}`,
435
+ { status: response.status },
436
+ );
456
437
  }
457
438
 
458
439
  // ── Layer 3: JSON parse error handling ──
@@ -463,20 +444,12 @@ export const webSearchTool = defineTool({
463
444
  ? (JSON.parse(text) as SearXNGResponse)
464
445
  : { results: [], answers: [], suggestions: [] };
465
446
  } catch (parseErr) {
466
- return {
467
- content: [
468
- {
469
- type: "text" as const,
470
- text:
471
- "Web search returned unexpected response format. " +
472
- "SearXNG may be misconfigured. Error: " +
473
- (parseErr instanceof Error
474
- ? parseErr.message
475
- : String(parseErr)),
476
- },
477
- ],
478
- details: { error: true, parseError: true },
479
- };
447
+ return fail(
448
+ "Web search returned unexpected response format. " +
449
+ "SearXNG may be misconfigured. Error: " +
450
+ (parseErr instanceof Error ? parseErr.message : String(parseErr)),
451
+ { parseError: true },
452
+ );
480
453
  }
481
454
 
482
455
  // ── Deduplicate results by URL ──
@@ -492,8 +465,8 @@ export const webSearchTool = defineTool({
492
465
  (a, b) => (b.score ?? 0) - (a.score ?? 0),
493
466
  );
494
467
 
495
- // Slice to requested count
496
- const results = sortedResults.slice(0, Math.min(count, 100));
468
+ // Slice to requested count (schema caps `count` at 100, enforced by runtime validation)
469
+ const results = sortedResults.slice(0, count);
497
470
 
498
471
  // Render answer blocks (before empty-results check so answers show even with zero web results)
499
472
  const renderedAnswers = (data.answers ?? []).slice(0, 3);
@@ -551,8 +524,7 @@ export const webSearchTool = defineTool({
551
524
 
552
525
  // Suggestions section
553
526
  if (data.suggestions?.length) {
554
- const suggestionCount = Math.min(data.suggestions.length, 3);
555
- output += `Suggestions: ${data.suggestions.slice(0, suggestionCount).join(", ")}`;
527
+ output += `Suggestions: ${data.suggestions.slice(0, 3).join(", ")}`;
556
528
  }
557
529
 
558
530
  return {
@@ -574,19 +546,13 @@ export const webSearchTool = defineTool({
574
546
  };
575
547
  } catch (unexpectedErr) {
576
548
  clearTimeout(timeoutId);
577
- return {
578
- content: [
579
- {
580
- type: "text" as const,
581
- text:
582
- "An unexpected error occurred during web search: " +
583
- (unexpectedErr instanceof Error
584
- ? unexpectedErr.message
585
- : String(unexpectedErr)),
586
- },
587
- ],
588
- details: { error: true, unexpectedError: true },
589
- };
549
+ return fail(
550
+ "An unexpected error occurred during web search: " +
551
+ (unexpectedErr instanceof Error
552
+ ? unexpectedErr.message
553
+ : String(unexpectedErr)),
554
+ { unexpectedError: true },
555
+ );
590
556
  }
591
557
  },
592
558
 
@@ -1,12 +0,0 @@
1
- import { verifyShipManifest } from "./verify-ship-manifest.js";
2
- import { describe, expect, it } from "vitest";
3
-
4
- describe("publish manifest", () => {
5
- it("`package.json` `files` array covers every production .ts module across the tree", () => {
6
- expect(verifyShipManifest(import.meta.url).missing).toEqual([]);
7
- });
8
-
9
- it("every `files` entry points at something on disk — a stale entry ships nothing", () => {
10
- expect(verifyShipManifest(import.meta.url).stale).toEqual([]);
11
- });
12
- });
@@ -1,10 +0,0 @@
1
- /**
2
- * Ship-manifest verification helper (thin re-export).
3
- *
4
- * Re-exports the shared {@link verifyShipManifest} from pi-lean-portal's
5
- * core/shared/ship-manifest.ts. Kept so `ship-manifest.test.ts` can import
6
- * from a local path without depending on a specific cross-package layout.
7
- */
8
-
9
- export type { ShipManifestResult } from "../pi-lean-portal/core/shared/ship-manifest.js";
10
- export { verifyShipManifest } from "../pi-lean-portal/core/shared/ship-manifest.js";