pi-lean-search 0.5.0 → 0.7.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/index.ts +64 -18
- package/package.json +2 -2
- package/web-search-tool.ts +22 -13
package/index.ts
CHANGED
|
@@ -19,9 +19,12 @@ import type {
|
|
|
19
19
|
import {
|
|
20
20
|
defineToolset,
|
|
21
21
|
TOOLSET_EVENTS,
|
|
22
|
-
getDefaultResolutionMode,
|
|
23
22
|
} from "pi-tool-masking";
|
|
24
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
ToolsetSpec,
|
|
25
|
+
ToolsetChangedEvent,
|
|
26
|
+
BranchReader,
|
|
27
|
+
} from "pi-tool-masking";
|
|
25
28
|
import { readSearxngUrl } from "./search-config.js";
|
|
26
29
|
import { webSearchTool, normalizeBaseUrl } from "./web-search-tool.js";
|
|
27
30
|
|
|
@@ -51,6 +54,21 @@ let _lastDegraded = false;
|
|
|
51
54
|
/** Cached ExtensionContext for event-driven glyph rendering. */
|
|
52
55
|
let _lastCtx: ExtensionContext | null = null;
|
|
53
56
|
|
|
57
|
+
/** Live sessionManager reference for the co-activation mirror — stored at
|
|
58
|
+
* session_start; the library invokes getBranch() fresh per apply, so the
|
|
59
|
+
* stored reference stays correct across cascades and external writes. */
|
|
60
|
+
let _sessionManager: BranchReader | null = null;
|
|
61
|
+
|
|
62
|
+
/** Unsubscribe for the co-activation mirror (registered inside session_start,
|
|
63
|
+
* torn down in session_shutdown so re-fires don't stack listeners). */
|
|
64
|
+
let _offMirror: (() => void) | null = null;
|
|
65
|
+
|
|
66
|
+
/** Unsubscribers for the factory-time glyph-sync listeners — pi re-invokes
|
|
67
|
+
* the cached extension factory per load pass, so these are re-registered
|
|
68
|
+
* each time and torn down in the reset block / session_shutdown to avoid
|
|
69
|
+
* stacking duplicate listeners on the shared event bus. */
|
|
70
|
+
let _offSync: (() => void)[] = [];
|
|
71
|
+
|
|
54
72
|
// ─── Health probes ───────────────────────────────────────────────
|
|
55
73
|
|
|
56
74
|
/**
|
|
@@ -170,6 +188,8 @@ function _resetStateForTest(): void {
|
|
|
170
188
|
_lastHealth = null;
|
|
171
189
|
_lastDegraded = false;
|
|
172
190
|
_lastCtx = null;
|
|
191
|
+
_sessionManager = null;
|
|
192
|
+
_offMirror = null;
|
|
173
193
|
}
|
|
174
194
|
|
|
175
195
|
export { _resetStateForTest };
|
|
@@ -177,8 +197,16 @@ export { _resetStateForTest };
|
|
|
177
197
|
// ─── Extension entry point ───────────────────────────────────────
|
|
178
198
|
|
|
179
199
|
export default function (pi: ExtensionAPI) {
|
|
180
|
-
//
|
|
181
|
-
|
|
200
|
+
// --- Ensure idempotent re-invocation ----------------------------
|
|
201
|
+
// pi reuses the cached extension factory on /resume (same cwd),
|
|
202
|
+
// which re-invokes this function with the same module-level
|
|
203
|
+
// singletons. Reset the mirror state so the second load doesn't
|
|
204
|
+
// skip re-registration behind a stale _offMirror.
|
|
205
|
+
_offMirror?.();
|
|
206
|
+
_offMirror = null;
|
|
207
|
+
_sessionManager = null;
|
|
208
|
+
for (const off of _offSync) off();
|
|
209
|
+
_offSync = [];
|
|
182
210
|
|
|
183
211
|
// ── Register the web-search tool ─────────────────────────
|
|
184
212
|
pi.registerTool(webSearchTool);
|
|
@@ -188,23 +216,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
216
|
const searchToolset = defineToolset(pi, SEARCH_WEB_SPEC);
|
|
189
217
|
|
|
190
218
|
// ── Co-activation: mirror pi-lean-dimension.web changed events ─
|
|
191
|
-
// Listen on changed ONLY, not restored.
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
// — including one a stale library `doRestore` emits during resume — must
|
|
196
|
-
// not disable search or write a focus-indistinguishable {enabled} entry.
|
|
197
|
-
pi.events.on(TOOLSET_EVENTS.changed, (data: unknown) => {
|
|
219
|
+
// Listen on changed ONLY, not restored. Registered inside session_start
|
|
220
|
+
// (after _sessionManager is stored) so the reader always exists when the
|
|
221
|
+
// mirror runs — there is no reader-less window.
|
|
222
|
+
const mirrorWebChanged = (data: unknown) => {
|
|
198
223
|
const event = data as ToolsetChangedEvent;
|
|
199
|
-
if (event.id
|
|
200
|
-
|
|
224
|
+
if (event.id !== "pi-lean-dimension.web") return;
|
|
225
|
+
// Catch AllowlistModeError by name, never `instanceof` — the handle
|
|
226
|
+
// may come from a different library copy via the shared registry;
|
|
227
|
+
// refusal means "nothing changed" — skip co-activation.
|
|
228
|
+
try {
|
|
201
229
|
if (event.enabled) {
|
|
202
|
-
searchToolset.enable(pi);
|
|
230
|
+
searchToolset.enable(pi, _sessionManager!);
|
|
203
231
|
} else {
|
|
204
|
-
searchToolset.disable(pi);
|
|
232
|
+
searchToolset.disable(pi, _sessionManager!);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if ((err as { name?: string } | undefined)?.name !== "AllowlistModeError") {
|
|
236
|
+
throw err;
|
|
205
237
|
}
|
|
206
238
|
}
|
|
207
|
-
}
|
|
239
|
+
};
|
|
208
240
|
|
|
209
241
|
// ── Keep cached state in sync with library events ────────
|
|
210
242
|
const syncSearchState = (data: unknown) => {
|
|
@@ -214,13 +246,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
246
|
if (_lastCtx) renderSearchGlyph(_lastCtx);
|
|
215
247
|
}
|
|
216
248
|
};
|
|
217
|
-
|
|
218
|
-
|
|
249
|
+
_offSync = [
|
|
250
|
+
pi.events.on(TOOLSET_EVENTS.changed, syncSearchState),
|
|
251
|
+
pi.events.on(TOOLSET_EVENTS.restored, syncSearchState),
|
|
252
|
+
];
|
|
219
253
|
|
|
220
254
|
// ── Session start: health probe + glyph ──────────────────
|
|
221
255
|
pi.on("session_start", async (event, ctx) => {
|
|
256
|
+
_sessionManager = ctx.sessionManager;
|
|
222
257
|
_lastCtx = ctx;
|
|
223
258
|
|
|
259
|
+
// Register the co-activation mirror once, after the reader is stored
|
|
260
|
+
// (dedup across re-fires of session_start for the same instance).
|
|
261
|
+
if (!_offMirror) {
|
|
262
|
+
_offMirror = pi.events.on(TOOLSET_EVENTS.changed, mirrorWebChanged);
|
|
263
|
+
}
|
|
264
|
+
|
|
224
265
|
// Re-read config in case it changed between sessions
|
|
225
266
|
_searxngUrl = readSearxngUrl();
|
|
226
267
|
|
|
@@ -274,7 +315,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
315
|
|
|
275
316
|
// ── Session shutdown: clean up ───────────────────────────
|
|
276
317
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
318
|
+
_offMirror?.();
|
|
319
|
+
_offMirror = null;
|
|
320
|
+
for (const off of _offSync) off();
|
|
321
|
+
_offSync = [];
|
|
277
322
|
_lastCtx = null;
|
|
323
|
+
_sessionManager = null;
|
|
278
324
|
try {
|
|
279
325
|
ctx?.ui?.setStatus?.("search", "");
|
|
280
326
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"test": "vitest run"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"pi-tool-masking": "^
|
|
39
|
+
"pi-tool-masking": "^2.0.1"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@earendil-works/pi-ai": "*",
|
package/web-search-tool.ts
CHANGED
|
@@ -393,6 +393,19 @@ export const webSearchTool = defineTool({
|
|
|
393
393
|
controller.abort();
|
|
394
394
|
}, timeoutSeconds * 1000);
|
|
395
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
|
+
|
|
396
409
|
try {
|
|
397
410
|
// ── Layer 1: Connection-level error handling ──
|
|
398
411
|
let response: Response;
|
|
@@ -402,20 +415,11 @@ export const webSearchTool = defineTool({
|
|
|
402
415
|
headers: { Accept: "application/json" },
|
|
403
416
|
});
|
|
404
417
|
} catch (connectionErr) {
|
|
405
|
-
clearTimeout(timeoutId);
|
|
406
418
|
if (
|
|
407
419
|
connectionErr instanceof DOMException &&
|
|
408
420
|
connectionErr.name === "AbortError"
|
|
409
421
|
) {
|
|
410
|
-
|
|
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
|
-
}
|
|
418
|
-
return fail("Web search was cancelled.", { cancelled: true });
|
|
422
|
+
return abortFail();
|
|
419
423
|
}
|
|
420
424
|
return fail(
|
|
421
425
|
"Web search connection failed: " +
|
|
@@ -426,8 +430,6 @@ export const webSearchTool = defineTool({
|
|
|
426
430
|
);
|
|
427
431
|
}
|
|
428
432
|
|
|
429
|
-
clearTimeout(timeoutId);
|
|
430
|
-
|
|
431
433
|
// ── Layer 2: HTTP error handling ──
|
|
432
434
|
if (!response.ok) {
|
|
433
435
|
return fail(
|
|
@@ -444,6 +446,10 @@ export const webSearchTool = defineTool({
|
|
|
444
446
|
? (JSON.parse(text) as SearXNGResponse)
|
|
445
447
|
: { results: [], answers: [], suggestions: [] };
|
|
446
448
|
} catch (parseErr) {
|
|
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
|
+
}
|
|
447
453
|
return fail(
|
|
448
454
|
"Web search returned unexpected response format. " +
|
|
449
455
|
"SearXNG may be misconfigured. Error: " +
|
|
@@ -545,7 +551,6 @@ export const webSearchTool = defineTool({
|
|
|
545
551
|
},
|
|
546
552
|
};
|
|
547
553
|
} catch (unexpectedErr) {
|
|
548
|
-
clearTimeout(timeoutId);
|
|
549
554
|
return fail(
|
|
550
555
|
"An unexpected error occurred during web search: " +
|
|
551
556
|
(unexpectedErr instanceof Error
|
|
@@ -553,6 +558,10 @@ export const webSearchTool = defineTool({
|
|
|
553
558
|
: String(unexpectedErr)),
|
|
554
559
|
{ unexpectedError: true },
|
|
555
560
|
);
|
|
561
|
+
} finally {
|
|
562
|
+
// Timer stays armed through the body read, so `timeout` bounds the
|
|
563
|
+
// whole request, not just the response headers.
|
|
564
|
+
clearTimeout(timeoutId);
|
|
556
565
|
}
|
|
557
566
|
},
|
|
558
567
|
|