pi-lean-search 0.2.4 → 0.3.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.
Files changed (2) hide show
  1. package/index.ts +134 -64
  2. package/package.json +2 -1
package/index.ts CHANGED
@@ -4,28 +4,49 @@
4
4
  * Registers the `web-search` tool and a `/searxng-status` diagnostic command.
5
5
  * Manages the `search` status bar slot with health-colored glyphs.
6
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.
7
+ * Owns the `search.web` toolset (co-activated off `portal.web`):
8
+ * ● searxng (accent/blue) — healthy and reachable
9
+ * ● searxng (warning/yellow) — server up but pipeline degraded
10
+ * ● searxng (error/red) — unreachable
11
+ * ○ searxng — search tools off
15
12
  */
16
13
 
17
14
  import type {
18
15
  ExtensionAPI,
19
16
  ExtensionContext,
17
+ ThemeColor,
20
18
  } from "@earendil-works/pi-coding-agent";
19
+ import { defineToolset, TOOLSET_EVENTS } from "pi-tool-masking";
20
+ import type { ToolsetSpec, ToolsetChangedEvent } from "pi-tool-masking";
21
21
  import { readSearxngUrl } from "./search-config.js";
22
22
  import { webSearchTool } from "./web-search-tool.js";
23
23
 
24
- // ─── Module-level state ───────────────────────────────────────────
24
+ // ─── Toolset spec ────────────────────────────────────────────────
25
+
26
+ const SEARCH_WEB_SPEC: ToolsetSpec = {
27
+ id: "search.web",
28
+ names: new Set(["web-search"]),
29
+ persistKey: "toolset-state:search.web",
30
+ defaultEnabled: true,
31
+ };
32
+
33
+ // ─── Module-level state ──────────────────────────────────────────
25
34
 
26
35
  /** Cached SearXNG URL (read once at startup, stable mid-session). */
27
36
  let _searxngUrl: string | undefined;
28
37
 
38
+ /** Last known search.web enabled state (updated by library events). */
39
+ let _lastSearchEnabled = true;
40
+
41
+ /** Last known health state: true=healthy, false=unreachable, null=unconfigured. */
42
+ let _lastHealth: boolean | null = null;
43
+
44
+ /** Whether the search pipeline is degraded (server up, aggregation broken). */
45
+ let _lastDegraded = false;
46
+
47
+ /** Cached ExtensionContext for event-driven glyph rendering. */
48
+ let _lastCtx: ExtensionContext | null = null;
49
+
29
50
  // ─── Health probes ───────────────────────────────────────────────
30
51
 
31
52
  /**
@@ -95,7 +116,7 @@ async function checkSearchReachable(
95
116
  }
96
117
  }
97
118
 
98
- // ─── Status slot helpers ──────────────────────────────────────────
119
+ // ─── Status slot helpers ─────────────────────────────────────────
99
120
 
100
121
  /**
101
122
  * Normalize the SearXNG URL for display (strip protocol prefix for brevity).
@@ -105,60 +126,65 @@ function displayUrl(url: string): string {
105
126
  }
106
127
 
107
128
  /**
108
- * Set the `search` status slot with a health-colored glyph.
109
- *
110
- * Coloring:
111
- * - Healthy: accent (blue) — same as browser on
112
- * - Degraded: warning (yellow/gold) — server up but pipeline broken
113
- * - Unhealthy: error (red) — unreachable or unconfigured
129
+ * Render the `search` status slot based on toolset state + health.
114
130
  *
115
- * Portal writes `○ searxng` on /web off; search overrides here on
116
- * session_start or /searxng-status.
131
+ * search.web off → ○ searxng
132
+ * search.web on + healthy → ● searxng (accent/blue)
133
+ * search.web on + degraded → ● searxng (warning/yellow)
134
+ * search.web on + unreachable → ● searxng (error/red)
135
+ * unconfigured → (clear slot)
117
136
  */
118
- function setSearchStatus(
119
- ctx: ExtensionContext,
120
- healthy: boolean | null,
121
- degraded: boolean,
122
- ): void {
123
- if (healthy === null) {
124
- // Unconfigured — no glyph
137
+ function renderSearchGlyph(ctx: {
138
+ ui: {
139
+ setStatus: (key: string, label: string) => void;
140
+ theme: { fg: (c: ThemeColor, t: string) => string };
141
+ };
142
+ }): void {
143
+ const safeSetStatus = (text: string) => {
125
144
  try {
126
- ctx.ui.setStatus("search", "");
145
+ ctx.ui.setStatus("search", text);
127
146
  } catch {
128
- // ctx.ui may be unavailable during shutdown
147
+ /* ctx.ui may be unavailable */
129
148
  }
149
+ };
150
+
151
+ // Unconfigured — clear slot entirely
152
+ if (_lastHealth === null) {
153
+ safeSetStatus("");
130
154
  return;
131
155
  }
132
156
 
133
- if (!healthy) {
134
- // Unreachable
135
- try {
136
- ctx.ui.setStatus("search", ctx.ui.theme.fg("error", "●") + " searxng");
137
- } catch {
138
- // ignore
139
- }
157
+ // search.web disabled — show off state
158
+ if (!_lastSearchEnabled) {
159
+ safeSetStatus("○ searxng");
140
160
  return;
141
161
  }
142
162
 
143
- if (degraded) {
144
- // Server up but pipeline broken
145
- try {
146
- ctx.ui.setStatus("search", ctx.ui.theme.fg("warning", "●") + " searxng");
147
- } catch {
148
- // ignore
149
- }
163
+ // search.web enabled — show health-colored glyph
164
+ if (!_lastHealth) {
165
+ safeSetStatus(ctx.ui.theme.fg("error", "●") + " searxng");
150
166
  return;
151
167
  }
152
-
153
- // Healthy
154
- try {
155
- ctx.ui.setStatus("search", ctx.ui.theme.fg("accent", "●") + " searxng");
156
- } catch {
157
- // ignore
168
+ if (_lastDegraded) {
169
+ safeSetStatus(ctx.ui.theme.fg("warning", "●") + " searxng");
170
+ return;
158
171
  }
172
+ safeSetStatus(ctx.ui.theme.fg("accent", "●") + " searxng");
159
173
  }
160
174
 
161
- // ─── Extension entry point ────────────────────────────────────────
175
+ // ─── Test helpers ─────────────────────────────────────────────────
176
+
177
+ /** @internal Reset cached state to defaults (test helper). */
178
+ function _resetStateForTest(): void {
179
+ _lastSearchEnabled = true;
180
+ _lastHealth = null;
181
+ _lastDegraded = false;
182
+ _lastCtx = null;
183
+ }
184
+
185
+ export { _resetStateForTest };
186
+
187
+ // ─── Extension entry point ───────────────────────────────────────
162
188
 
163
189
  export default function (pi: ExtensionAPI) {
164
190
  // Read config at startup
@@ -167,48 +193,83 @@ export default function (pi: ExtensionAPI) {
167
193
  // ── Register the web-search tool ─────────────────────────
168
194
  pi.registerTool(webSearchTool);
169
195
 
170
- // ── Session start: health probe + status update ──────────
196
+ // ── Define the search.web toolset ────────────────────────
197
+ // Registers restore handler on session_start / session_tree.
198
+ const searchToolset = defineToolset(pi, SEARCH_WEB_SPEC);
199
+
200
+ // ── Co-activation: mirror portal.web changed events ──────
201
+ // Listen on changed ONLY, not restored (§10.1).
202
+ pi.events.on(TOOLSET_EVENTS.changed, (data: unknown) => {
203
+ const event = data as ToolsetChangedEvent;
204
+ if (event.id === "portal.web") {
205
+ if (event.enabled) {
206
+ searchToolset.enable(pi);
207
+ } else {
208
+ searchToolset.disable(pi);
209
+ }
210
+ }
211
+ });
212
+
213
+ // ── Keep cached state in sync with library events ────────
214
+ const syncSearchState = (data: unknown) => {
215
+ const event = data as ToolsetChangedEvent;
216
+ if (event.id === "search.web") {
217
+ _lastSearchEnabled = event.enabled;
218
+ if (_lastCtx) renderSearchGlyph(_lastCtx);
219
+ }
220
+ };
221
+ pi.events.on(TOOLSET_EVENTS.changed, syncSearchState);
222
+ pi.events.on(TOOLSET_EVENTS.restored, syncSearchState);
223
+
224
+ // ── Session start: health probe + glyph ──────────────────
171
225
  pi.on("session_start", async (_event, ctx) => {
226
+ _lastCtx = ctx;
227
+
172
228
  // Re-read config in case it changed between sessions
173
229
  _searxngUrl = readSearxngUrl();
174
230
 
175
231
  if (!_searxngUrl) {
176
- // Unconfigured — clear the slot
177
- setSearchStatus(ctx, null, false);
232
+ _lastHealth = null;
233
+ renderSearchGlyph(ctx);
178
234
  return;
179
235
  }
180
236
 
181
- // Check if web-search tools are currently active (portal
182
- // restores toggle state before search's session_start fires).
183
- const activeTools = pi.getActiveTools();
184
- if (!activeTools.includes("web-search")) {
185
- // Tools are off — don't override portal's ○ searxng
186
- return;
187
- }
237
+ // Note: by now the library's restore handler has already
238
+ // fired (registered by defineToolset), so _lastSearchEnabled
239
+ // reflects the restored toolset state.
188
240
 
189
241
  const reachable = await checkServerReachable(
190
242
  _searxngUrl,
191
243
  ctx.signal ?? undefined,
192
244
  );
245
+ _lastHealth = reachable;
246
+ _lastDegraded = false;
193
247
 
194
248
  if (reachable) {
195
- setSearchStatus(ctx, true, false);
196
249
  ctx.ui.notify(
197
250
  `🔍 SearXNG at ${displayUrl(_searxngUrl)} is available`,
198
251
  "info",
199
252
  );
200
253
  } else {
201
- setSearchStatus(ctx, false, false);
202
254
  ctx.ui.notify(
203
255
  `⚠ SearXNG at ${displayUrl(_searxngUrl)} is unreachable. ` +
204
256
  "Web search will degrade gracefully with error messages.",
205
257
  "warning",
206
258
  );
207
259
  }
260
+
261
+ renderSearchGlyph(ctx);
262
+ });
263
+
264
+ // ── Session tree: re-render with cached state ────────────
265
+ pi.on("session_tree", async (_event, ctx) => {
266
+ _lastCtx = ctx;
267
+ renderSearchGlyph(ctx);
208
268
  });
209
269
 
210
270
  // ── Session shutdown: clean up ───────────────────────────
211
271
  pi.on("session_shutdown", async (_event, ctx) => {
272
+ _lastCtx = null;
212
273
  try {
213
274
  ctx?.ui?.setStatus?.("search", "");
214
275
  } catch {
@@ -223,13 +284,16 @@ export default function (pi: ExtensionAPI) {
223
284
  "and update the status bar. Use when web-search returns " +
224
285
  "errors or when you've just started/restarted SearXNG.",
225
286
  handler: async (_args, ctx) => {
287
+ _lastCtx = ctx;
288
+
226
289
  if (!_searxngUrl) {
227
290
  ctx.ui.notify(
228
291
  "❌ SearXNG is not configured. " +
229
292
  "Set `searxng.url` in your Pi settings.json.",
230
293
  "error",
231
294
  );
232
- setSearchStatus(ctx, false, false);
295
+ _lastHealth = false;
296
+ renderSearchGlyph(ctx);
233
297
  return;
234
298
  }
235
299
 
@@ -240,7 +304,9 @@ export default function (pi: ExtensionAPI) {
240
304
  );
241
305
 
242
306
  if (searchOk) {
243
- setSearchStatus(ctx, true, false);
307
+ _lastHealth = true;
308
+ _lastDegraded = false;
309
+ renderSearchGlyph(ctx);
244
310
  ctx.ui.notify(
245
311
  `✅ SearXNG search pipeline is working at ${displayUrl(_searxngUrl)}`,
246
312
  "info",
@@ -255,7 +321,9 @@ export default function (pi: ExtensionAPI) {
255
321
  );
256
322
 
257
323
  if (serverOk) {
258
- setSearchStatus(ctx, true, true); // degraded
324
+ _lastHealth = true;
325
+ _lastDegraded = true;
326
+ renderSearchGlyph(ctx);
259
327
  ctx.ui.notify(
260
328
  "⚠ SearXNG server is up but the search pipeline " +
261
329
  "may be broken (aggregation failed). " +
@@ -263,7 +331,9 @@ export default function (pi: ExtensionAPI) {
263
331
  "warning",
264
332
  );
265
333
  } else {
266
- setSearchStatus(ctx, false, false);
334
+ _lastHealth = false;
335
+ _lastDegraded = false;
336
+ renderSearchGlyph(ctx);
267
337
  ctx.ui.notify(
268
338
  `❌ SearXNG at ${displayUrl(_searxngUrl)} is not responding. ` +
269
339
  "Check that the SearXNG service is running.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension. SearXNG search tool for Pi — pairs with pi-lean-portal's /web toggle; search-only installs are valid, or add it to a portal install for the full web-tools suite.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,6 +42,7 @@
42
42
  "@earendil-works/pi-coding-agent": "*",
43
43
  "@earendil-works/pi-tui": "*",
44
44
  "pi-lean-portal": "*",
45
+ "pi-tool-masking": "^1.0.0",
45
46
  "typebox": "*"
46
47
  },
47
48
  "peerDependenciesMeta": {