pi-lean-dimension 0.5.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 +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +4 -4
|
@@ -15,7 +15,7 @@ import type { PythonBridgeConfig } from "./backends/python-adapter.js";
|
|
|
15
15
|
import { sessionManager } from "./core/shared/session-manager.js";
|
|
16
16
|
import { removeAllSnapshotFiles } from "./core/shared/snapshot-cache.js";
|
|
17
17
|
import initBrowserToggle from "./browser-toggle.js";
|
|
18
|
-
import { updateFooterStatus,
|
|
18
|
+
import { updateFooterStatus, setLastCtx } from "./tools/utils.js";
|
|
19
19
|
import { deleteSessionKey, resetTaskIds } from "./core/shared/task-id.js";
|
|
20
20
|
import { resetToggleModuleState } from "./browser-toggle.js";
|
|
21
21
|
import { registerGuideProvider } from "./core/guides.js";
|
|
@@ -214,23 +214,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
214
|
// --- Register commands ------------------------------------------
|
|
215
215
|
initBrowserToggle(pi);
|
|
216
216
|
|
|
217
|
-
// --- Profile change callback for TUI status updates ------------
|
|
218
|
-
router.setOnProfileChange((taskId, profileName, profileMode) => {
|
|
219
|
-
// Update TUI status bar on any profile lifecycle event
|
|
220
|
-
const lastCtx = getLastCtx();
|
|
221
|
-
if (lastCtx) {
|
|
222
|
-
updateFooterStatus(lastCtx);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Debug logging when BROWSER_DEBUG is set
|
|
226
|
-
if (process.env.BROWSER_DEBUG) {
|
|
227
|
-
const parts = [`[browser] profile_changed: task=${taskId}`];
|
|
228
|
-
if (profileName) parts.push(`profile=${profileName}`);
|
|
229
|
-
if (profileMode) parts.push(`mode=${profileMode}`);
|
|
230
|
-
console.error(parts.join(" "));
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
|
|
234
217
|
// --- Startup ----------------------------------------------------
|
|
235
218
|
pi.on("session_start", async (_event, ctx) => {
|
|
236
219
|
const pluginNames = pluginRegistry.available().join(", ");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-portal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Interactive Playwright web browsing for Pi. Chromium and Firefox built in; author stealth backends on either engine. Snapshots and screenshots cache to disk, saving context. Configurable profiles/cookies. Agents write site guides, auto-matched by domain.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"browser-profile.ts",
|
|
33
33
|
"browser-cookies.ts",
|
|
34
34
|
"browser-status.ts",
|
|
35
|
+
"browser-install.ts",
|
|
35
36
|
"LICENSE",
|
|
36
37
|
"backends/",
|
|
37
38
|
"!backends/python-base/.venv/",
|
|
@@ -137,9 +137,13 @@ export const browserNavigateTool = defineTool({
|
|
|
137
137
|
// If the browser executable is not installed, notify the user with install instructions.
|
|
138
138
|
const isInstallError =
|
|
139
139
|
result.error && result.error.toLowerCase().includes("not installed");
|
|
140
|
-
|
|
140
|
+
// Python backends (chromium-py, firefox-py, stealth -py backends) report a
|
|
141
|
+
// missing venv Playwright wheel — their error already carries the accurate
|
|
142
|
+
// pip install hint, and /web install cannot fix that, so stay quiet.
|
|
143
|
+
const isPythonBackend = result.backendUsed?.endsWith("-py") ?? false;
|
|
144
|
+
if (isInstallError && !isPythonBackend) {
|
|
141
145
|
ctx?.ui?.notify(
|
|
142
|
-
"Browser not installed. Run
|
|
146
|
+
"Browser not installed. Run /web install to install it.",
|
|
143
147
|
"warning",
|
|
144
148
|
);
|
|
145
149
|
}
|
|
@@ -29,10 +29,9 @@ export function contentText(
|
|
|
29
29
|
// ─── Status bar ─────────────────────────────────────────────────
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Retained reference to the most recent extension context
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* session_shutdown.
|
|
32
|
+
* Retained reference to the most recent extension context, so event
|
|
33
|
+
* handlers that fire outside a tool/ctx scope (e.g. browser-toggle's
|
|
34
|
+
* toolset changed/restored listeners) can still repaint the status bar.
|
|
36
35
|
*/
|
|
37
36
|
let _lastCtx: {
|
|
38
37
|
ui: {
|
|
@@ -62,12 +61,12 @@ export function updateFooterStatus(ctx: {
|
|
|
62
61
|
ctx.ui.setStatus("browser", `${dot} ${body}`);
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/** @internal —
|
|
64
|
+
/** @internal — read by browser-toggle's event-driven status repaint */
|
|
66
65
|
export function getLastCtx(): typeof _lastCtx {
|
|
67
66
|
return _lastCtx;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
/** @internal —
|
|
69
|
+
/** @internal — updated on session_start/session_tree, cleared on shutdown */
|
|
71
70
|
export function setLastCtx(ctx: typeof _lastCtx): void {
|
|
72
71
|
_lastCtx = ctx;
|
|
73
72
|
}
|
|
@@ -47,7 +47,7 @@ Search owns the `search` status bar slot, shown only when `pi-lean-search` is in
|
|
|
47
47
|
- `○ searxng` — search tools off
|
|
48
48
|
- *(no slot)* — unconfigured: no `searxng.url` in settings; a one-time warning notify on Pi process boot (`session_start` `reason: "startup"` only, not `/new`/`/resume`/`/fork`) points at the setting
|
|
49
49
|
|
|
50
|
-
Search probes SearXNG reachability on `session_start` and `/searxng-status` and sets the glyph color.
|
|
50
|
+
Search probes SearXNG reachability on `session_start` and `/searxng-status` and sets the glyph color. Search itself writes the `○ searxng` off state: its `/web off` co-activation mirror (see "Peer relationship") disables the `pi-lean-dimension.search` toolset, and the resulting `changed` event re-renders the glyph; the health-colored glyph returns on the next probe. (The `browser` slot is owned by `pi-lean-portal`; see that package's `AGENTS.md`.)
|
|
51
51
|
|
|
52
52
|
## Graceful degradation
|
|
53
53
|
|
|
@@ -64,5 +64,7 @@ notify at Pi startup (boot only, not `/new`/`/resume`/`/fork`) points at the
|
|
|
64
64
|
`pi-lean-search` declares `pi-lean-portal` as a **soft peer**
|
|
65
65
|
(`peerDependencies` + `peerDependenciesMeta.optional: true`). Search-only
|
|
66
66
|
installs are valid — the tool works standalone, it just doesn't get a `/web`
|
|
67
|
-
toggle.
|
|
68
|
-
`/web on|off`
|
|
67
|
+
toggle. Co-activation is search-owned: search listens on `pi-tool-masking`'s
|
|
68
|
+
`TOOLSET_EVENTS.changed` and mirrors `pi-lean-dimension.web`, so `/web on|off`
|
|
69
|
+
enables/disables search's own toolset when both are installed (portal has no
|
|
70
|
+
reference to `web-search`).
|
|
@@ -23,7 +23,6 @@ function mockSearchPi(initialActive?: string[]) {
|
|
|
23
23
|
let active = initialActive ?? ["web-search"];
|
|
24
24
|
const eventEmitter = new EventEmitter();
|
|
25
25
|
const handlers = new Map<string, Array<(...args: any[]) => void>>();
|
|
26
|
-
const entryCalls: Array<{ customType: string; data: unknown }> = [];
|
|
27
26
|
|
|
28
27
|
// A minimal mock of the ExtensionAPI. We intentionally don't mock
|
|
29
28
|
// _everything_ — only the methods the extension code actually calls.
|
|
@@ -33,9 +32,7 @@ function mockSearchPi(initialActive?: string[]) {
|
|
|
33
32
|
setActiveTools: vi.fn((names: string[]) => {
|
|
34
33
|
active = [...names];
|
|
35
34
|
}),
|
|
36
|
-
appendEntry: vi.fn(
|
|
37
|
-
entryCalls.push({ customType, data });
|
|
38
|
-
}),
|
|
35
|
+
appendEntry: vi.fn(),
|
|
39
36
|
registerCommand: vi.fn(),
|
|
40
37
|
registerTool: vi.fn(),
|
|
41
38
|
on: vi.fn(<T>(event: string, handler: (event: T, ctx: any) => void) => {
|
|
@@ -54,7 +51,7 @@ function mockSearchPi(initialActive?: string[]) {
|
|
|
54
51
|
},
|
|
55
52
|
} as unknown as ExtensionAPI;
|
|
56
53
|
|
|
57
|
-
return { pi, events: eventEmitter, handlers
|
|
54
|
+
return { pi, events: eventEmitter, handlers };
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
// ─── Config reader (mocked fs) ──────────────────────────────────
|
|
@@ -109,18 +106,6 @@ describe("readSearxngUrl", () => {
|
|
|
109
106
|
expect(readSearxngUrl()).toBeUndefined();
|
|
110
107
|
});
|
|
111
108
|
|
|
112
|
-
it("returns the URL when searxng.url is set (global)", () => {
|
|
113
|
-
vi.mocked(readFileSync).mockImplementation((path: unknown) => {
|
|
114
|
-
if (typeof path === "string" && path.includes(".pi/agent/settings")) {
|
|
115
|
-
return JSON.stringify({
|
|
116
|
-
searxng: { url: "http://localhost:8888" },
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
return JSON.stringify({});
|
|
120
|
-
});
|
|
121
|
-
expect(readSearxngUrl()).toBe("http://localhost:8888");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
109
|
it("project settings override global settings", () => {
|
|
125
110
|
vi.mocked(readFileSync).mockImplementation((path: unknown) => {
|
|
126
111
|
if (typeof path === "string" && path.includes(".pi/agent/settings")) {
|
|
@@ -158,8 +143,8 @@ describe("readSearxngUrl", () => {
|
|
|
158
143
|
// ─── Tool definition structural checks ──────────────────────────
|
|
159
144
|
|
|
160
145
|
describe("webSearchTool", () => {
|
|
161
|
-
// The exact name is
|
|
162
|
-
//
|
|
146
|
+
// The exact name is load-bearing: SEARCH_WEB_SPEC.names uses Set.has()
|
|
147
|
+
// membership to mask/restore it with the /web co-activation toggle.
|
|
163
148
|
it("has the correct name", () => {
|
|
164
149
|
expect(webSearchTool.name).toBe("web-search");
|
|
165
150
|
});
|
|
@@ -172,46 +157,33 @@ describe("webSearchTool", () => {
|
|
|
172
157
|
});
|
|
173
158
|
|
|
174
159
|
describe("renderResult answer badge", () => {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
content: [{ text: "" }],
|
|
182
|
-
details: { answerCount: 2, query: "weather" },
|
|
183
|
-
} as any,
|
|
184
|
-
{ expanded: false, isPartial: false },
|
|
185
|
-
mockTheme,
|
|
160
|
+
const theme = { fg: (_style: string, text: string) => text } as any;
|
|
161
|
+
const render = (details: unknown, expanded = false) =>
|
|
162
|
+
(webSearchTool.renderResult as any)(
|
|
163
|
+
{ content: [{ text: "" }], details },
|
|
164
|
+
{ expanded, isPartial: false },
|
|
165
|
+
theme,
|
|
186
166
|
{} as any,
|
|
187
|
-
) as
|
|
167
|
+
) as { text: string };
|
|
168
|
+
|
|
169
|
+
it("shows 💡 badge when answerCount > 0 and no results", () => {
|
|
170
|
+
const result = render({ answerCount: 2, query: "weather" });
|
|
188
171
|
expect(result.text).toContain("💡");
|
|
189
172
|
expect(result.text).toContain("2 answer(s)");
|
|
190
173
|
expect(result.text).toContain('"weather"');
|
|
191
174
|
});
|
|
192
175
|
|
|
193
176
|
it("shows 💡 badge alongside 🔍 when both answers and results present", () => {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
results: [
|
|
205
|
-
{ title: "A", url: "http://a.com", engine: "google" },
|
|
206
|
-
{ title: "B", url: "http://b.com", engine: "google" },
|
|
207
|
-
{ title: "C", url: "http://c.com", engine: "google" },
|
|
208
|
-
],
|
|
209
|
-
},
|
|
210
|
-
} as any,
|
|
211
|
-
{ expanded: false, isPartial: false },
|
|
212
|
-
mockTheme,
|
|
213
|
-
{} as any,
|
|
214
|
-
) as any;
|
|
177
|
+
const result = render({
|
|
178
|
+
answerCount: 1,
|
|
179
|
+
resultCount: 3,
|
|
180
|
+
query: "test",
|
|
181
|
+
results: [
|
|
182
|
+
{ title: "A", url: "http://a.com", engine: "google" },
|
|
183
|
+
{ title: "B", url: "http://b.com", engine: "google" },
|
|
184
|
+
{ title: "C", url: "http://c.com", engine: "google" },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
215
187
|
expect(result.text).toContain("💡");
|
|
216
188
|
expect(result.text).toContain("1 answer(s)");
|
|
217
189
|
expect(result.text).toContain("🔍");
|
|
@@ -219,29 +191,21 @@ describe("webSearchTool", () => {
|
|
|
219
191
|
});
|
|
220
192
|
|
|
221
193
|
it("shows answer text above results when expanded", () => {
|
|
222
|
-
const
|
|
223
|
-
fg: (_style: string, text: string) => text,
|
|
224
|
-
} as any;
|
|
225
|
-
const result = (webSearchTool.renderResult as any)(
|
|
194
|
+
const result = render(
|
|
226
195
|
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} as any,
|
|
241
|
-
{ expanded: true, isPartial: false },
|
|
242
|
-
mockTheme,
|
|
243
|
-
{} as any,
|
|
244
|
-
) as any;
|
|
196
|
+
answerCount: 1,
|
|
197
|
+
resultCount: 1,
|
|
198
|
+
query: "weather berlin",
|
|
199
|
+
answers: [
|
|
200
|
+
{
|
|
201
|
+
template: "answer/weather.html",
|
|
202
|
+
text: "Berlin: 14°C, light rain",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
results: [{ title: "A", url: "http://a.com" }],
|
|
206
|
+
},
|
|
207
|
+
true,
|
|
208
|
+
);
|
|
245
209
|
const answerIdx = result.text.indexOf("Berlin: 14°C, light rain");
|
|
246
210
|
const resultIdx = result.text.indexOf("A");
|
|
247
211
|
expect(answerIdx).toBeGreaterThan(-1);
|
|
@@ -250,22 +214,14 @@ describe("webSearchTool", () => {
|
|
|
250
214
|
});
|
|
251
215
|
|
|
252
216
|
it("shows answer text when expanded with no results", () => {
|
|
253
|
-
const
|
|
254
|
-
fg: (_style: string, text: string) => text,
|
|
255
|
-
} as any;
|
|
256
|
-
const result = (webSearchTool.renderResult as any)(
|
|
217
|
+
const result = render(
|
|
257
218
|
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
} as any,
|
|
265
|
-
{ expanded: true, isPartial: false },
|
|
266
|
-
mockTheme,
|
|
267
|
-
{} as any,
|
|
268
|
-
) as any;
|
|
219
|
+
answerCount: 1,
|
|
220
|
+
query: "avg 1 2 3",
|
|
221
|
+
answers: [{ template: "answer/legacy.html", text: "2" }],
|
|
222
|
+
},
|
|
223
|
+
true,
|
|
224
|
+
);
|
|
269
225
|
expect(result.text).toContain("💡");
|
|
270
226
|
expect(result.text).toContain("2");
|
|
271
227
|
});
|
|
@@ -309,11 +265,6 @@ describe("buildSearchUrl", () => {
|
|
|
309
265
|
expect(second).toContain("pageno=2");
|
|
310
266
|
});
|
|
311
267
|
|
|
312
|
-
it("omits empty-string optional params", () => {
|
|
313
|
-
const url = buildSearchUrl("http://localhost:8888", "test", baseOptions);
|
|
314
|
-
expect(url).toBe("http://localhost:8888/search?format=json&q=test");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
268
|
it("passes through optional params when set", () => {
|
|
318
269
|
const url = buildSearchUrl("http://localhost:8888", "test", {
|
|
319
270
|
...baseOptions,
|
|
@@ -357,6 +308,60 @@ describe("execute answer rendering", () => {
|
|
|
357
308
|
}),
|
|
358
309
|
);
|
|
359
310
|
|
|
311
|
+
// Headers arrive at once; the body read only settles on abort.
|
|
312
|
+
const mockStallingFetch = () =>
|
|
313
|
+
vi.stubGlobal(
|
|
314
|
+
"fetch",
|
|
315
|
+
vi.fn().mockImplementation((_url: string, init: { signal?: AbortSignal }) =>
|
|
316
|
+
Promise.resolve({
|
|
317
|
+
ok: true,
|
|
318
|
+
status: 200,
|
|
319
|
+
text: () =>
|
|
320
|
+
new Promise((_resolve, reject) => {
|
|
321
|
+
init.signal?.addEventListener(
|
|
322
|
+
"abort",
|
|
323
|
+
() =>
|
|
324
|
+
reject(new DOMException("The operation was aborted", "AbortError")),
|
|
325
|
+
{ once: true },
|
|
326
|
+
);
|
|
327
|
+
}),
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const runSearch = (params: Record<string, unknown>) =>
|
|
333
|
+
webSearchTool.execute("call", params as any, undefined, undefined, {} as any);
|
|
334
|
+
|
|
335
|
+
it("degrades gracefully when searxng.url is unconfigured", async () => {
|
|
336
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
|
|
337
|
+
const fetchMock = vi.fn();
|
|
338
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
339
|
+
|
|
340
|
+
const r = (await runSearch({ query: "anything" })) as any;
|
|
341
|
+
expect(r.details.unconfigured).toBe(true);
|
|
342
|
+
expect(r.content[0].text).toContain("Web search is not configured");
|
|
343
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("times out when the body stalls after the headers arrive", async () => {
|
|
347
|
+
vi.useFakeTimers();
|
|
348
|
+
try {
|
|
349
|
+
mockStallingFetch();
|
|
350
|
+
|
|
351
|
+
const pending = runSearch({ query: "stalled", timeout: 1 });
|
|
352
|
+
|
|
353
|
+
// Fire the abort timer deterministically instead of waiting on it.
|
|
354
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
355
|
+
const result = await pending;
|
|
356
|
+
|
|
357
|
+
const r = result as any;
|
|
358
|
+
expect(r.details.timedOut).toBe(true);
|
|
359
|
+
expect(r.content[0].text).toContain("timed out after 1s");
|
|
360
|
+
} finally {
|
|
361
|
+
vi.useRealTimers();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
360
365
|
it("legacy answer renders + bypasses empty-results early return", async () => {
|
|
361
366
|
mockFetch({
|
|
362
367
|
results: [],
|
|
@@ -364,13 +369,7 @@ describe("execute answer rendering", () => {
|
|
|
364
369
|
suggestions: [],
|
|
365
370
|
});
|
|
366
371
|
|
|
367
|
-
const result = await
|
|
368
|
-
"call-1",
|
|
369
|
-
{ query: "614.24" },
|
|
370
|
-
undefined,
|
|
371
|
-
undefined,
|
|
372
|
-
{} as any,
|
|
373
|
-
);
|
|
372
|
+
const result = await runSearch({ query: "614.24" });
|
|
374
373
|
const r = result as any;
|
|
375
374
|
expect(r.content[0].text).toContain("614.24");
|
|
376
375
|
expect(r.content[0].text).not.toContain("No web search results found");
|
|
@@ -399,13 +398,7 @@ describe("execute answer rendering", () => {
|
|
|
399
398
|
suggestions: [],
|
|
400
399
|
});
|
|
401
400
|
|
|
402
|
-
const result = await
|
|
403
|
-
"call-2",
|
|
404
|
-
{ query: "weather berlin" },
|
|
405
|
-
undefined,
|
|
406
|
-
undefined,
|
|
407
|
-
{} as any,
|
|
408
|
-
);
|
|
401
|
+
const result = await runSearch({ query: "weather berlin" });
|
|
409
402
|
// Uses server-provided summary
|
|
410
403
|
const r = result as any;
|
|
411
404
|
expect(r.content[0].text).toContain("Berlin: 14°C, light rain");
|
|
@@ -413,8 +406,6 @@ describe("execute answer rendering", () => {
|
|
|
413
406
|
expect(r.content[0].text).toContain("feels like 12°C");
|
|
414
407
|
expect(r.content[0].text).toContain("humidity 80%");
|
|
415
408
|
expect(r.content[0].text).toContain("wind 18km/h");
|
|
416
|
-
// forecasts not rendered
|
|
417
|
-
expect(r.content[0].text).not.toContain("forecast");
|
|
418
409
|
// source
|
|
419
410
|
expect(r.content[0].text).toContain("open-meteo");
|
|
420
411
|
});
|
|
@@ -426,13 +417,7 @@ describe("execute answer rendering", () => {
|
|
|
426
417
|
suggestions: [],
|
|
427
418
|
});
|
|
428
419
|
|
|
429
|
-
const result = await
|
|
430
|
-
"call-3",
|
|
431
|
-
{ query: "future" },
|
|
432
|
-
undefined,
|
|
433
|
-
undefined,
|
|
434
|
-
{} as any,
|
|
435
|
-
);
|
|
420
|
+
const result = await runSearch({ query: "future" });
|
|
436
421
|
const r = result as any;
|
|
437
422
|
expect(r.content[0].text).toContain("x");
|
|
438
423
|
});
|
|
@@ -452,22 +437,13 @@ describe("execute answer rendering", () => {
|
|
|
452
437
|
suggestions: [],
|
|
453
438
|
});
|
|
454
439
|
|
|
455
|
-
const result = await
|
|
456
|
-
"call-5",
|
|
457
|
-
{ query: "answer of life" },
|
|
458
|
-
undefined,
|
|
459
|
-
undefined,
|
|
460
|
-
{} as any,
|
|
461
|
-
);
|
|
440
|
+
const result = await runSearch({ query: "answer of life" });
|
|
462
441
|
// Answer above results
|
|
463
442
|
const r = result as any;
|
|
464
443
|
const answerIdx = r.content[0].text.indexOf("42");
|
|
465
444
|
const resultIdx = r.content[0].text.indexOf("Result 1");
|
|
466
445
|
expect(answerIdx).toBeGreaterThanOrEqual(0);
|
|
467
446
|
expect(resultIdx).toBeGreaterThan(answerIdx);
|
|
468
|
-
// Both present
|
|
469
|
-
expect(r.content[0].text).toContain("42");
|
|
470
|
-
expect(r.content[0].text).toContain("Result 1");
|
|
471
447
|
});
|
|
472
448
|
});
|
|
473
449
|
|
|
@@ -530,3 +506,80 @@ describe("pi-lean-dimension.web co-activation mirror", () => {
|
|
|
530
506
|
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
531
507
|
});
|
|
532
508
|
});
|
|
509
|
+
|
|
510
|
+
// ─── session_start: health probe + status glyph ──────────────
|
|
511
|
+
|
|
512
|
+
describe("session_start glyph", () => {
|
|
513
|
+
function mockCtx() {
|
|
514
|
+
const setStatus = vi.fn();
|
|
515
|
+
const notify = vi.fn();
|
|
516
|
+
const ctx = {
|
|
517
|
+
ui: {
|
|
518
|
+
setStatus,
|
|
519
|
+
notify,
|
|
520
|
+
// Style-tagged passthrough so glyph color states are assertable.
|
|
521
|
+
theme: { fg: (c: string, t: string) => `${c}:${t}` },
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
return { ctx: ctx as any, setStatus, notify };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function fireSessionStart(reason: string) {
|
|
528
|
+
const { pi, handlers } = mockSearchPi();
|
|
529
|
+
searchExtension(pi);
|
|
530
|
+
const { ctx, setStatus, notify } = mockCtx();
|
|
531
|
+
// index.ts registers its handler after defineToolset's, so it's last.
|
|
532
|
+
await handlers.get("session_start")!.at(-1)!({ reason }, ctx);
|
|
533
|
+
return { setStatus, notify };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
afterEach(() => {
|
|
537
|
+
vi.unstubAllGlobals();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("unconfigured at startup: clears slot and warns once", async () => {
|
|
541
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
|
|
542
|
+
|
|
543
|
+
const { setStatus, notify } = await fireSessionStart("startup");
|
|
544
|
+
|
|
545
|
+
expect(setStatus).toHaveBeenCalledWith("search", "");
|
|
546
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
547
|
+
expect(notify).toHaveBeenCalledWith(
|
|
548
|
+
expect.stringContaining("not configured"),
|
|
549
|
+
"warning",
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const configuredSettings = () =>
|
|
554
|
+
vi
|
|
555
|
+
.mocked(readFileSync)
|
|
556
|
+
.mockReturnValue(
|
|
557
|
+
JSON.stringify({ searxng: { url: "http://localhost:8888" } }),
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
it("reachable at startup: healthy accent glyph + info notify", async () => {
|
|
561
|
+
configuredSettings();
|
|
562
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 }));
|
|
563
|
+
|
|
564
|
+
const { setStatus, notify } = await fireSessionStart("startup");
|
|
565
|
+
|
|
566
|
+
expect(setStatus).toHaveBeenCalledWith("search", "accent:● searxng");
|
|
567
|
+
expect(notify).toHaveBeenCalledWith(
|
|
568
|
+
expect.stringContaining("available"),
|
|
569
|
+
"info",
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("unreachable at startup: error glyph + warning notify", async () => {
|
|
574
|
+
configuredSettings();
|
|
575
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
|
576
|
+
|
|
577
|
+
const { setStatus, notify } = await fireSessionStart("startup");
|
|
578
|
+
|
|
579
|
+
expect(setStatus).toHaveBeenCalledWith("search", "error:● searxng");
|
|
580
|
+
expect(notify).toHaveBeenCalledWith(
|
|
581
|
+
expect.stringContaining("unreachable"),
|
|
582
|
+
"warning",
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
@@ -177,9 +177,6 @@ export { _resetStateForTest };
|
|
|
177
177
|
// ─── Extension entry point ───────────────────────────────────────
|
|
178
178
|
|
|
179
179
|
export default function (pi: ExtensionAPI) {
|
|
180
|
-
// Read config at startup
|
|
181
|
-
_searxngUrl = readSearxngUrl();
|
|
182
|
-
|
|
183
180
|
// ── Register the web-search tool ─────────────────────────
|
|
184
181
|
pi.registerTool(webSearchTool);
|
|
185
182
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lean-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -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
|
|