pi-lean-search 0.4.0 → 0.6.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 -49
- package/package.json +4 -7
- package/search-config.ts +32 -26
- package/web-search-tool.ts +99 -124
- 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
|
/**
|
|
@@ -191,9 +177,6 @@ export { _resetStateForTest };
|
|
|
191
177
|
// ─── Extension entry point ───────────────────────────────────────
|
|
192
178
|
|
|
193
179
|
export default function (pi: ExtensionAPI) {
|
|
194
|
-
// Read config at startup
|
|
195
|
-
_searxngUrl = readSearxngUrl();
|
|
196
|
-
|
|
197
180
|
// ── Register the web-search tool ─────────────────────────
|
|
198
181
|
pi.registerTool(webSearchTool);
|
|
199
182
|
|
|
@@ -202,7 +185,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
185
|
const searchToolset = defineToolset(pi, SEARCH_WEB_SPEC);
|
|
203
186
|
|
|
204
187
|
// ── Co-activation: mirror pi-lean-dimension.web changed events ─
|
|
205
|
-
// Listen on changed ONLY, not restored
|
|
188
|
+
// Listen on changed ONLY, not restored.
|
|
206
189
|
//
|
|
207
190
|
// Focus-mode guard: while allowlist focus holds the line, skip
|
|
208
191
|
// co-activation. The focus set is authoritative, so a web `changed` event
|
|
@@ -232,7 +215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
232
215
|
pi.events.on(TOOLSET_EVENTS.restored, syncSearchState);
|
|
233
216
|
|
|
234
217
|
// ── Session start: health probe + glyph ──────────────────
|
|
235
|
-
pi.on("session_start", async (
|
|
218
|
+
pi.on("session_start", async (event, ctx) => {
|
|
236
219
|
_lastCtx = ctx;
|
|
237
220
|
|
|
238
221
|
// Re-read config in case it changed between sessions
|
|
@@ -241,6 +224,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
224
|
if (!_searxngUrl) {
|
|
242
225
|
_lastHealth = null;
|
|
243
226
|
renderSearchGlyph(ctx);
|
|
227
|
+
// Discoverability hint only on Pi process boot — not on /new,
|
|
228
|
+
// /resume, or /fork, where it would be repeated noise.
|
|
229
|
+
if (event.reason === "startup") {
|
|
230
|
+
ctx.ui.notify(
|
|
231
|
+
"SearXNG is not configured (set `searxng.url` in settings.json) — " +
|
|
232
|
+
"web-search is disabled. Run /searxng-status after configuring.",
|
|
233
|
+
"warning",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
244
236
|
return;
|
|
245
237
|
}
|
|
246
238
|
|
|
@@ -302,7 +294,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
294
|
"Set `searxng.url` in your Pi settings.json.",
|
|
303
295
|
"error",
|
|
304
296
|
);
|
|
305
|
-
_lastHealth =
|
|
297
|
+
_lastHealth = null;
|
|
306
298
|
renderSearchGlyph(ctx);
|
|
307
299
|
return;
|
|
308
300
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.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(() => {
|
|
@@ -383,6 +393,19 @@ export const webSearchTool = defineTool({
|
|
|
383
393
|
controller.abort();
|
|
384
394
|
}, timeoutSeconds * 1000);
|
|
385
395
|
|
|
396
|
+
// Abort can surface in either fetch or the body read; label it correctly.
|
|
397
|
+
const abortFail = () => {
|
|
398
|
+
if (timedOut) {
|
|
399
|
+
return fail(
|
|
400
|
+
`Web search timed out after ${timeoutSeconds}s. ` +
|
|
401
|
+
`The SearXNG instance at \`${searxngUrl}\` may be slow ` +
|
|
402
|
+
"or unresponsive.",
|
|
403
|
+
{ timedOut: true, timeout: timeoutSeconds },
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return fail("Web search was cancelled.", { cancelled: true });
|
|
407
|
+
};
|
|
408
|
+
|
|
386
409
|
try {
|
|
387
410
|
// ── Layer 1: Connection-level error handling ──
|
|
388
411
|
let response: Response;
|
|
@@ -392,67 +415,27 @@ export const webSearchTool = defineTool({
|
|
|
392
415
|
headers: { Accept: "application/json" },
|
|
393
416
|
});
|
|
394
417
|
} catch (connectionErr) {
|
|
395
|
-
clearTimeout(timeoutId);
|
|
396
418
|
if (
|
|
397
419
|
connectionErr instanceof DOMException &&
|
|
398
420
|
connectionErr.name === "AbortError"
|
|
399
421
|
) {
|
|
400
|
-
|
|
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
|
-
};
|
|
417
|
-
}
|
|
418
|
-
return {
|
|
419
|
-
content: [
|
|
420
|
-
{
|
|
421
|
-
type: "text" as const,
|
|
422
|
-
text: "Web search was cancelled.",
|
|
423
|
-
},
|
|
424
|
-
],
|
|
425
|
-
details: { cancelled: true },
|
|
426
|
-
};
|
|
422
|
+
return abortFail();
|
|
427
423
|
}
|
|
428
|
-
return
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
? connectionErr.message
|
|
436
|
-
: String(connectionErr)),
|
|
437
|
-
},
|
|
438
|
-
],
|
|
439
|
-
details: { error: true, connectionError: true },
|
|
440
|
-
};
|
|
424
|
+
return fail(
|
|
425
|
+
"Web search connection failed: " +
|
|
426
|
+
(connectionErr instanceof Error
|
|
427
|
+
? connectionErr.message
|
|
428
|
+
: String(connectionErr)),
|
|
429
|
+
{ connectionError: true },
|
|
430
|
+
);
|
|
441
431
|
}
|
|
442
432
|
|
|
443
|
-
clearTimeout(timeoutId);
|
|
444
|
-
|
|
445
433
|
// ── Layer 2: HTTP error handling ──
|
|
446
434
|
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
|
-
};
|
|
435
|
+
return fail(
|
|
436
|
+
`SearXNG error: HTTP ${response.status} ${response.statusText}`,
|
|
437
|
+
{ status: response.status },
|
|
438
|
+
);
|
|
456
439
|
}
|
|
457
440
|
|
|
458
441
|
// ── Layer 3: JSON parse error handling ──
|
|
@@ -463,20 +446,16 @@ export const webSearchTool = defineTool({
|
|
|
463
446
|
? (JSON.parse(text) as SearXNGResponse)
|
|
464
447
|
: { results: [], answers: [], suggestions: [] };
|
|
465
448
|
} catch (parseErr) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
},
|
|
477
|
-
],
|
|
478
|
-
details: { error: true, parseError: true },
|
|
479
|
-
};
|
|
449
|
+
// Abort can surface here if the body read stalls past the timeout.
|
|
450
|
+
if (parseErr instanceof DOMException && parseErr.name === "AbortError") {
|
|
451
|
+
return abortFail();
|
|
452
|
+
}
|
|
453
|
+
return fail(
|
|
454
|
+
"Web search returned unexpected response format. " +
|
|
455
|
+
"SearXNG may be misconfigured. Error: " +
|
|
456
|
+
(parseErr instanceof Error ? parseErr.message : String(parseErr)),
|
|
457
|
+
{ parseError: true },
|
|
458
|
+
);
|
|
480
459
|
}
|
|
481
460
|
|
|
482
461
|
// ── Deduplicate results by URL ──
|
|
@@ -492,8 +471,8 @@ export const webSearchTool = defineTool({
|
|
|
492
471
|
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
|
493
472
|
);
|
|
494
473
|
|
|
495
|
-
// Slice to requested count
|
|
496
|
-
const results = sortedResults.slice(0,
|
|
474
|
+
// Slice to requested count (schema caps `count` at 100, enforced by runtime validation)
|
|
475
|
+
const results = sortedResults.slice(0, count);
|
|
497
476
|
|
|
498
477
|
// Render answer blocks (before empty-results check so answers show even with zero web results)
|
|
499
478
|
const renderedAnswers = (data.answers ?? []).slice(0, 3);
|
|
@@ -551,8 +530,7 @@ export const webSearchTool = defineTool({
|
|
|
551
530
|
|
|
552
531
|
// Suggestions section
|
|
553
532
|
if (data.suggestions?.length) {
|
|
554
|
-
|
|
555
|
-
output += `Suggestions: ${data.suggestions.slice(0, suggestionCount).join(", ")}`;
|
|
533
|
+
output += `Suggestions: ${data.suggestions.slice(0, 3).join(", ")}`;
|
|
556
534
|
}
|
|
557
535
|
|
|
558
536
|
return {
|
|
@@ -573,20 +551,17 @@ export const webSearchTool = defineTool({
|
|
|
573
551
|
},
|
|
574
552
|
};
|
|
575
553
|
} catch (unexpectedErr) {
|
|
554
|
+
return fail(
|
|
555
|
+
"An unexpected error occurred during web search: " +
|
|
556
|
+
(unexpectedErr instanceof Error
|
|
557
|
+
? unexpectedErr.message
|
|
558
|
+
: String(unexpectedErr)),
|
|
559
|
+
{ unexpectedError: true },
|
|
560
|
+
);
|
|
561
|
+
} finally {
|
|
562
|
+
// Timer stays armed through the body read, so `timeout` bounds the
|
|
563
|
+
// whole request, not just the response headers.
|
|
576
564
|
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
|
-
};
|
|
590
565
|
}
|
|
591
566
|
},
|
|
592
567
|
|
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";
|