pi-lean-search 0.1.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 ADDED
@@ -0,0 +1,55 @@
1
+ # pi-lean-search
2
+
3
+ > SearXNG search tool for Pi. Part of the [pi-lean-dimension](https://github.com/coreyryanhanson/pi-lean-dimension) web-tools suite.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ pi install npm:pi-lean-portal # recommended: browser + /web toggle
9
+ pi install npm:pi-lean-search # adds web-search to /web on|off
10
+ ```
11
+
12
+ Or get the full suite in one command:
13
+
14
+ ```bash
15
+ pi install npm:pi-lean-dimension # portal + search, requires SearXNG server
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ | Command / Tool | Description |
21
+ |---|---|
22
+ | `web-search` tool | Search the web via your SearXNG instance. Agents use this automatically. |
23
+ | `/searxng-status` | Test and diagnose the SearXNG connection. |
24
+
25
+ The `web-search` tool is automatically included in `/web on` / `/web off` toggling
26
+ when `pi-lean-portal` is also installed. The status bar shows a `● searxng` glyph
27
+ (colored accent/blue when healthy, yellow when degraded, red when unreachable).
28
+
29
+ ## Configuration
30
+
31
+ Set the URL of your SearXNG instance in your Pi settings file:
32
+
33
+ **`~/.pi/agent/settings.json`** (global) or **`.pi/settings.json`** (project-local):
34
+
35
+ ```json
36
+ {
37
+ "searxng": {
38
+ "url": "http://localhost:8888"
39
+ }
40
+ }
41
+ ```
42
+
43
+ - **Self-hosted SearXNG:** Run your own instance ([docs](https://docs.searxng.org/)).
44
+ - No URL configured? The tool returns a setup message on its first call — no errors, no broken prompts.
45
+
46
+ ## Graceful degradation
47
+
48
+ If SearXNG is unreachable or unconfigured, the `web-search` tool returns a clear
49
+ message pointing you toward setup instructions. It never throws or breaks the agent.
50
+
51
+ ## Tests
52
+
53
+ ```bash
54
+ npx vitest run packages/pi-lean-search/
55
+ ```
package/index.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * pi-lean-search — SearXNG search extension for Pi.
3
+ *
4
+ * Registers the `web-search` tool and a `/searxng-status` diagnostic command.
5
+ * Manages the `search` status bar slot with health-colored glyphs.
6
+ *
7
+ * On session_start, probes SearXNG reachability and updates the search slot:
8
+ * ● searxng (accent/blue) — healthy and reachable
9
+ * ● searxng (warning/yellow) — server up but pipeline degraded
10
+ * ● searxng (error/red) — unreachable
11
+ *
12
+ * Portal owns the `search` slot's "off" state: when `/web off` is called,
13
+ * portal writes `○ searxng` (open circle). Search overrides with the
14
+ * health-colored glyph on session_start.
15
+ */
16
+
17
+ import type {
18
+ ExtensionAPI,
19
+ ExtensionContext,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { readSearxngUrl } from "./search-config.js";
22
+ import { webSearchTool } from "./web-search-tool.js";
23
+
24
+ // ─── Module-level state ───────────────────────────────────────────
25
+
26
+ /** Cached SearXNG URL (read once at startup, stable mid-session). */
27
+ let _searxngUrl: string | undefined;
28
+
29
+ // ─── Health probes ───────────────────────────────────────────────
30
+
31
+ /**
32
+ * Lightweight server probe — fetches the SearXNG root HTML page.
33
+ * Fast (~ms) because it only needs the HTTP response, not the full
34
+ * aggregation pipeline.
35
+ */
36
+ async function checkServerReachable(
37
+ url: string,
38
+ signal?: AbortSignal,
39
+ ): Promise<boolean> {
40
+ try {
41
+ const controller = new AbortController();
42
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
43
+
44
+ let res: Response;
45
+ try {
46
+ const mergedSignal = signal
47
+ ? combineAbortSignals(signal, controller.signal)
48
+ : (controller.signal as AbortSignal);
49
+ res = await fetch(url, { signal: mergedSignal });
50
+ } finally {
51
+ clearTimeout(timeoutId);
52
+ }
53
+
54
+ return res.ok && res.status === 200;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Full-pipeline probe — verifies the search API actually works end-to-end.
62
+ * Triggers upstream engine aggregation, so it takes longer (5s timeout).
63
+ */
64
+ async function checkSearchReachable(
65
+ url: string,
66
+ signal?: AbortSignal,
67
+ ): Promise<boolean> {
68
+ const normalized = url.replace(/\/+$/, "");
69
+ const searchUrl = `${normalized}/search?q=ping&format=json`;
70
+
71
+ try {
72
+ const controller = new AbortController();
73
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
74
+
75
+ let res: Response;
76
+ try {
77
+ const mergedSignal = signal
78
+ ? combineAbortSignals(signal, controller.signal)
79
+ : (controller.signal as AbortSignal);
80
+ res = await fetch(searchUrl, {
81
+ signal: mergedSignal,
82
+ headers: { Accept: "application/json" },
83
+ });
84
+ } finally {
85
+ clearTimeout(timeoutId);
86
+ }
87
+
88
+ if (!res.ok || res.status !== 200) return false;
89
+ const text = await res.text();
90
+ if (!text) return false;
91
+ JSON.parse(text);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Combine two AbortSignals into one that aborts when either aborts.
100
+ */
101
+ function combineAbortSignals(...signals: AbortSignal[]): AbortSignal {
102
+ const controller = new AbortController();
103
+ for (const sig of signals) {
104
+ if (sig.aborted) {
105
+ controller.abort(sig.reason);
106
+ return controller.signal;
107
+ }
108
+ sig.addEventListener("abort", () => controller.abort(sig.reason), {
109
+ once: true,
110
+ });
111
+ }
112
+ return controller.signal;
113
+ }
114
+
115
+ // ─── Status slot helpers ──────────────────────────────────────────
116
+
117
+ /**
118
+ * Normalize the SearXNG URL for display (strip protocol prefix for brevity).
119
+ */
120
+ function displayUrl(url: string): string {
121
+ return url.replace(/^https?:\/\//, "");
122
+ }
123
+
124
+ /**
125
+ * Set the `search` status slot with a health-colored glyph.
126
+ *
127
+ * Coloring:
128
+ * - Healthy: accent (blue) — same as browser on
129
+ * - Degraded: warning (yellow/gold) — server up but pipeline broken
130
+ * - Unhealthy: error (red) — unreachable or unconfigured
131
+ *
132
+ * Portal writes `○ searxng` on /web off; search overrides here on
133
+ * session_start or /searxng-status.
134
+ */
135
+ function setSearchStatus(
136
+ ctx: ExtensionContext,
137
+ healthy: boolean | null,
138
+ degraded: boolean,
139
+ ): void {
140
+ if (healthy === null) {
141
+ // Unconfigured — no glyph
142
+ try {
143
+ ctx.ui.setStatus("search", "");
144
+ } catch {
145
+ // ctx.ui may be unavailable during shutdown
146
+ }
147
+ return;
148
+ }
149
+
150
+ if (!healthy) {
151
+ // Unreachable
152
+ try {
153
+ ctx.ui.setStatus("search", ctx.ui.theme.fg("error", "●") + " searxng");
154
+ } catch {
155
+ // ignore
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (degraded) {
161
+ // Server up but pipeline broken
162
+ try {
163
+ ctx.ui.setStatus("search", ctx.ui.theme.fg("warning", "●") + " searxng");
164
+ } catch {
165
+ // ignore
166
+ }
167
+ return;
168
+ }
169
+
170
+ // Healthy
171
+ try {
172
+ ctx.ui.setStatus("search", ctx.ui.theme.fg("accent", "●") + " searxng");
173
+ } catch {
174
+ // ignore
175
+ }
176
+ }
177
+
178
+ // ─── Extension entry point ────────────────────────────────────────
179
+
180
+ export default function (pi: ExtensionAPI) {
181
+ // Read config at startup
182
+ _searxngUrl = readSearxngUrl();
183
+
184
+ // ── Register the web-search tool ─────────────────────────
185
+ pi.registerTool(webSearchTool);
186
+
187
+ // ── Session start: health probe + status update ──────────
188
+ pi.on("session_start", async (_event, ctx) => {
189
+ // Re-read config in case it changed between sessions
190
+ _searxngUrl = readSearxngUrl();
191
+
192
+ if (!_searxngUrl) {
193
+ // Unconfigured — clear the slot
194
+ setSearchStatus(ctx, null, false);
195
+ return;
196
+ }
197
+
198
+ // Check if web-search tools are currently active (portal
199
+ // restores toggle state before search's session_start fires).
200
+ const activeTools = pi.getActiveTools();
201
+ if (!activeTools.includes("web-search")) {
202
+ // Tools are off — don't override portal's ○ searxng
203
+ return;
204
+ }
205
+
206
+ const reachable = await checkServerReachable(
207
+ _searxngUrl,
208
+ ctx.signal ?? undefined,
209
+ );
210
+
211
+ if (reachable) {
212
+ setSearchStatus(ctx, true, false);
213
+ ctx.ui.notify(
214
+ `🔍 SearXNG at ${displayUrl(_searxngUrl)} is available`,
215
+ "info",
216
+ );
217
+ } else {
218
+ setSearchStatus(ctx, false, false);
219
+ ctx.ui.notify(
220
+ `⚠ SearXNG at ${displayUrl(_searxngUrl)} is unreachable. ` +
221
+ "Web search will degrade gracefully with error messages.",
222
+ "warning",
223
+ );
224
+ }
225
+ });
226
+
227
+ // ── Session shutdown: clean up ───────────────────────────
228
+ pi.on("session_shutdown", async (_event, ctx) => {
229
+ try {
230
+ ctx?.ui?.setStatus?.("search", "");
231
+ } catch {
232
+ // ctx.ui may not be available during shutdown
233
+ }
234
+ });
235
+
236
+ // ── Manual status check command ──────────────────────────
237
+ pi.registerCommand("searxng-status", {
238
+ description:
239
+ "Test the full SearXNG search pipeline (server + aggregation) " +
240
+ "and update the status bar. Use when web-search returns " +
241
+ "errors or when you've just started/restarted SearXNG.",
242
+ handler: async (_args, ctx) => {
243
+ if (!_searxngUrl) {
244
+ ctx.ui.notify(
245
+ "❌ SearXNG is not configured. " +
246
+ "Set `searxng.url` in your Pi settings.json.",
247
+ "error",
248
+ );
249
+ setSearchStatus(ctx, false, false);
250
+ return;
251
+ }
252
+
253
+ // Full pipeline probe (slower, triggers aggregation)
254
+ const searchOk = await checkSearchReachable(
255
+ _searxngUrl,
256
+ ctx.signal ?? undefined,
257
+ );
258
+
259
+ if (searchOk) {
260
+ setSearchStatus(ctx, true, false);
261
+ ctx.ui.notify(
262
+ `✅ SearXNG search pipeline is working at ${displayUrl(_searxngUrl)}`,
263
+ "info",
264
+ );
265
+ return;
266
+ }
267
+
268
+ // Distinguish: server down vs pipeline broken
269
+ const serverOk = await checkServerReachable(
270
+ _searxngUrl,
271
+ ctx.signal ?? undefined,
272
+ );
273
+
274
+ if (serverOk) {
275
+ setSearchStatus(ctx, true, true); // degraded
276
+ ctx.ui.notify(
277
+ "⚠ SearXNG server is up but the search pipeline " +
278
+ "may be broken (aggregation failed). " +
279
+ "Try /searxng-status again after a moment.",
280
+ "warning",
281
+ );
282
+ } else {
283
+ setSearchStatus(ctx, false, false);
284
+ ctx.ui.notify(
285
+ `❌ SearXNG at ${displayUrl(_searxngUrl)} is not responding. ` +
286
+ "Check that the SearXNG service is running.",
287
+ "error",
288
+ );
289
+ }
290
+ },
291
+ });
292
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-lean-search",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension. SearXNG search tool for Pi. Pairs with pi-lean-portal's /web toggle (soft peer — search-only installs are valid).",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "searxng",
9
+ "search",
10
+ "web"
11
+ ],
12
+ "license": "AGPL-3.0-only",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/coreyryanhanson/pi-lean-dimension.git",
16
+ "directory": "packages/pi-lean-search"
17
+ },
18
+ "type": "module",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "LICENSE",
25
+ "web-search-tool.ts",
26
+ "search-config.ts",
27
+ "verify-ship-manifest.ts",
28
+ "ship-manifest.test.ts",
29
+ "README.md"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "prepack": "cp ../../LICENSE ./LICENSE",
38
+ "test": "vitest run"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*",
44
+ "pi-lean-portal": "*",
45
+ "typebox": "*"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "pi-lean-portal": {
49
+ "optional": true
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Config reader for pi-lean-search.
3
+ *
4
+ * Reads `searxng.url` from Pi's merged settings.json files
5
+ * (global ~/.pi/agent/settings.json + project-local .pi/settings.json).
6
+ *
7
+ * The expected shape in settings.json:
8
+ * ```json
9
+ * { "searxng": { "url": "http://localhost:8888" } }
10
+ * ```
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ // ─── Config paths ─────────────────────────────────────────────────
18
+
19
+ /** Global pi settings path. */
20
+ const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
21
+
22
+ /** Project-local pi settings path (relative to cwd). */
23
+ const PROJECT_SETTINGS_PATH = ".pi/settings.json";
24
+
25
+ // ─── Reader ───────────────────────────────────────────────────────
26
+
27
+ 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
+ }
39
+ }
40
+
41
+ /**
42
+ * Read the configured SearXNG URL from merged Pi settings.
43
+ *
44
+ * Looks up `searxng.url` in:
45
+ * 1. `~/.pi/agent/settings.json` (global)
46
+ * 2. `.pi/settings.json` (project-local, overrides global)
47
+ *
48
+ * Returns the URL string if configured, or `undefined` if absent
49
+ * (caller should degrade gracefully — the tool returns a setup
50
+ * message when no URL is configured).
51
+ */
52
+ 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;
63
+ }
@@ -0,0 +1,12 @@
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
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Ship-manifest verification helper.
3
+ *
4
+ * Walks a package directory for production `.ts` files and compares them to
5
+ * the `files` array in `package.json`. Used by `ship-manifest.test.ts` files
6
+ * across the monorepo so every published package can prove its npm tarball
7
+ * actually contains the modules it imports at runtime.
8
+ *
9
+ * Ported from pi-lean-portal's verify-ship-manifest.ts (rpiv-mono pattern).
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { dirname, relative, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const SKIP_DIRS = new Set(["node_modules", "docs", "__tests__"]);
17
+ const SKIP_FILES = new Set(["test-fixtures.ts"]);
18
+ /** Entries that won't exist on disk at rest but are valid (generated at pack time, e.g. by prepack). */
19
+ const SKIP_STALE = new Set(["LICENSE"]);
20
+
21
+ export interface ShipManifestResult {
22
+ declared: readonly string[];
23
+ onDisk: readonly string[];
24
+ missing: readonly string[];
25
+ stale: readonly string[];
26
+ }
27
+
28
+ export function verifyShipManifest(
29
+ packageDirOrUrl: string,
30
+ ): ShipManifestResult {
31
+ const packageDir = packageDirOrUrl.startsWith("file:")
32
+ ? dirname(fileURLToPath(packageDirOrUrl))
33
+ : packageDirOrUrl;
34
+ const pkgRaw = readFileSync(resolve(packageDir, "package.json"), "utf8");
35
+ const pkg = JSON.parse(pkgRaw) as { files?: string[] };
36
+ const declared = pkg.files ?? [];
37
+ const exactFiles = new Set<string>();
38
+ const dirPrefixes: string[] = [];
39
+ for (const entry of declared) {
40
+ // Negation patterns (`!foo/`) are npm `files`-array syntax for excluding
41
+ // sub-paths within an included directory. They have no on-disk counterpart;
42
+ // skip them for existence checks and staleness detection.
43
+ if (entry.startsWith("!")) continue;
44
+ if (entry.endsWith("/")) dirPrefixes.push(entry);
45
+ else if (isDirOnDisk(packageDir, entry)) dirPrefixes.push(`${entry}/`);
46
+ else exactFiles.add(entry);
47
+ }
48
+
49
+ const onDisk = walkProductionTs(packageDir, packageDir);
50
+ const missing = onDisk.filter((f) => !isCovered(f, exactFiles, dirPrefixes));
51
+ const stale = declared.filter(
52
+ (entry) =>
53
+ !entry.startsWith("!") &&
54
+ !SKIP_STALE.has(entry) &&
55
+ !existsSync(resolve(packageDir, entry)),
56
+ );
57
+
58
+ return { declared, onDisk, missing, stale };
59
+ }
60
+
61
+ function isDirOnDisk(packageDir: string, entry: string): boolean {
62
+ try {
63
+ return statSync(resolve(packageDir, entry)).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function isCovered(
70
+ file: string,
71
+ exactFiles: Set<string>,
72
+ dirPrefixes: readonly string[],
73
+ ): boolean {
74
+ if (exactFiles.has(file)) return true;
75
+ for (const prefix of dirPrefixes) {
76
+ if (file.startsWith(prefix)) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ function walkProductionTs(root: string, dir: string): string[] {
82
+ const out: string[] = [];
83
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
84
+ if (entry.name.startsWith(".")) continue;
85
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
86
+ const abs = resolve(dir, entry.name);
87
+ if (entry.isDirectory()) {
88
+ out.push(...walkProductionTs(root, abs));
89
+ continue;
90
+ }
91
+ if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
92
+ if (entry.name.endsWith(".test.ts") || SKIP_FILES.has(entry.name)) continue;
93
+ out.push(relative(root, abs));
94
+ }
95
+ return out;
96
+ }