pi-lean-search 0.4.0 → 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 +42 -5
- package/index.ts +41 -46
- package/package.json +4 -7
- package/search-config.ts +32 -26
- package/web-search-tool.ts +85 -119
- package/ship-manifest.test.ts +0 -12
- package/verify-ship-manifest.ts +0 -10
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* aggregation
|
|
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
|
|
61
|
+
async function probe(
|
|
62
62
|
url: string,
|
|
63
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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,7 +188,7 @@ 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
|
|
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
|
|
@@ -232,7 +218,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
232
218
|
pi.events.on(TOOLSET_EVENTS.restored, syncSearchState);
|
|
233
219
|
|
|
234
220
|
// ── Session start: health probe + glyph ──────────────────
|
|
235
|
-
pi.on("session_start", async (
|
|
221
|
+
pi.on("session_start", async (event, ctx) => {
|
|
236
222
|
_lastCtx = ctx;
|
|
237
223
|
|
|
238
224
|
// Re-read config in case it changed between sessions
|
|
@@ -241,6 +227,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
227
|
if (!_searxngUrl) {
|
|
242
228
|
_lastHealth = null;
|
|
243
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
|
+
}
|
|
244
239
|
return;
|
|
245
240
|
}
|
|
246
241
|
|
|
@@ -302,7 +297,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
297
|
"Set `searxng.url` in your Pi settings.json.",
|
|
303
298
|
"error",
|
|
304
299
|
);
|
|
305
|
-
_lastHealth =
|
|
300
|
+
_lastHealth = null;
|
|
306
301
|
renderSearchGlyph(ctx);
|
|
307
302
|
return;
|
|
308
303
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
}
|
package/web-search-tool.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
161
|
-
if (
|
|
162
|
-
|
|
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
|
|
198
|
-
return { template:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
403
|
-
{
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
package/ship-manifest.test.ts
DELETED
|
@@ -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
|
-
});
|
package/verify-ship-manifest.ts
DELETED
|
@@ -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";
|