pi-web-kit 0.1.1 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.3] - 2026-05-10
10
+
11
+ ### Added
12
+
13
+ - Install/update telemetry ping to `mocito.dev`, gated by Pi telemetry/offline settings and deduplicated per package version.
14
+
15
+ ## [0.1.2] - 2026-05-09
16
+
17
+ ### Changed
18
+
19
+ - Deduplicated `mapFetchResults` between extension and fallback provider; extension now imports the shared implementation.
20
+ - Replaced `existsSync` + `readFileSync` with a single `readFileSync` in a try/catch to eliminate TOCTOU race in config loading.
21
+ - Replaced nested ternary for `requireKey` env-var names with a lookup object.
22
+ - Replaced magic number `10` with `DEFAULT_NUM_RESULTS` in Exa, Exa MCP, and Firecrawl providers; replaced `10` with `MAX_URL_COUNT` in TinyFish.
23
+ - Fixed `urlsMatch` to compute `canonicalWebUrl` once per URL instead of twice.
24
+ - Moved `nextId` JSON-RPC counter from module scope to `ExaMcpProvider` instance to avoid ID conflicts across concurrent instances.
25
+ - Removed unused `_query` parameter from internal `normalizeSearch` in Exa MCP provider.
26
+ - Replaced O(n) completed-count recomputation in progress tracking with O(1) increment.
27
+ - Cached `buildCacheKey` results in `fetchWithCache` to avoid redundant URL parsing per fetched page.
28
+ - Replaced nested ternary chains in `providerScope` and `renderProgress` with lookup objects.
29
+
9
30
  ## [0.1.1] - 2026-05-09
10
31
 
11
32
  ### Fixed
package/README.md CHANGED
@@ -98,6 +98,8 @@ Resolution order: defaults < environment variables < global config < project con
98
98
  ### Environment variables
99
99
 
100
100
  ```bash
101
+ PI_OFFLINE=1 # disables install/update telemetry
102
+ PI_TELEMETRY=0 # disables install/update telemetry
101
103
  PI_WEB_KIT_PROVIDER_SEARCH=exa_mcp|exa|tinyfish|brave|firecrawl
102
104
  PI_WEB_KIT_PROVIDER_FETCH=exa_mcp|exa|tinyfish|markdown_new|firecrawl
103
105
  EXA_API_KEY=...
package/SECURITY.md CHANGED
@@ -23,4 +23,6 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
23
23
 
24
24
  `pi-web-kit` sends search queries and fetched URLs to the configured third-party provider. Page content returned by providers is cached in memory for the lifetime of the Pi process, subject to TTL and size limits. API keys are read from environment variables or local config files and are used only for provider requests. Do not commit API keys, tokens, or config files containing secrets.
25
25
 
26
+ On startup, the extension sends a best-effort install/update telemetry ping to `mocito.dev` once per package version unless Pi telemetry is disabled or offline mode is enabled. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, queries, fetched URLs, file paths, config values, or API keys.
27
+
26
28
  The extension validates URLs before fetch calls and only accepts `http:` and `https:` URLs without embedded credentials. This validation reduces accidental misuse but does not sandbox provider responses or the local Pi process.
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
2
5
  import { Text } from "@earendil-works/pi-tui";
3
6
  import { Type } from "typebox";
4
7
  import { fetchCache, type CachedPage } from "../src/cache.js";
@@ -6,10 +9,18 @@ import { resolveConfig } from "../src/config.js";
6
9
  import { DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_URL_COUNT, MULTI_FETCH_LIMIT } from "../src/limits.js";
7
10
  import { truncateText } from "../src/http.js";
8
11
  import { createFetchProvider, createSearchProvider } from "../src/providers/index.js";
12
+ import { mapFetchResults } from "../src/providers/fallback.js";
9
13
  import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
10
- import { canonicalWebUrl, normalizeUrlInput, urlsMatch } from "../src/urls.js";
14
+ import { canonicalWebUrl, normalizeUrlInput } from "../src/urls.js";
15
+
16
+ const PACKAGE_NAME = "pi-web-kit";
17
+ const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
18
+ const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
19
+
20
+ type InstallTelemetryState = { lastReportedVersion?: string };
11
21
 
12
22
  export default function (pi: ExtensionAPI) {
23
+ void reportInstallTelemetry();
13
24
  pi.registerFlag("web-provider-search", {
14
25
  description: "Temporary pi-web-kit search provider override (exa_mcp, exa, tinyfish, brave, firecrawl)",
15
26
  type: "string",
@@ -157,6 +168,64 @@ function buildFetchDescription(provider: FetchProviderName): string {
157
168
  return `Fetch URL content with startup provider '${provider}'. Results are cached by URL/options; use offset/limit to read long pages in chunks. Restart/reload pi after provider changes.`;
158
169
  }
159
170
 
171
+ function readJsonFile(path: string): unknown {
172
+ try {
173
+ return JSON.parse(readFileSync(path, "utf8"));
174
+ } catch {
175
+ return {};
176
+ }
177
+ }
178
+
179
+ function isTruthyEnvFlag(value: string | undefined): boolean {
180
+ if (!value) return false;
181
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
182
+ }
183
+
184
+ function isInstallTelemetryEnabled(): boolean {
185
+ if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
186
+ if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
187
+ const settings = readJsonFile(join(getAgentDir(), "settings.json")) as { enableInstallTelemetry?: unknown };
188
+ return settings.enableInstallTelemetry !== false;
189
+ }
190
+
191
+ function getPackageVersion(): string {
192
+ try {
193
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
194
+ return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
195
+ } catch {
196
+ return "0.0.0";
197
+ }
198
+ }
199
+
200
+ function getInstallTelemetryUserAgent(version: string): string {
201
+ const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
202
+ const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
203
+ return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
204
+ }
205
+
206
+ export async function reportInstallTelemetry(): Promise<void> {
207
+ try {
208
+ if (!isInstallTelemetryEnabled()) return;
209
+
210
+ const version = getPackageVersion();
211
+ const telemetryDir = join(getAgentDir(), "extensions");
212
+ const statePath = join(telemetryDir, "pi-web-kit-install.json");
213
+ const state = readJsonFile(statePath) as InstallTelemetryState;
214
+ if (state.lastReportedVersion === version) return;
215
+
216
+ await mkdir(telemetryDir, { recursive: true });
217
+ await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`);
218
+
219
+ const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
220
+ await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
221
+ headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
222
+ signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
223
+ });
224
+ } catch {
225
+ // Best-effort install telemetry: ignore settings, filesystem, and network failures.
226
+ }
227
+ }
228
+
160
229
  function normalizeQueries(params: Record<string, any>): string[] {
161
230
  const raw = Array.isArray(params.queries) ? params.queries : params.query ? [params.query] : [];
162
231
  const queries = [...new Set(raw.map((q) => String(q).trim()).filter(Boolean))];
@@ -176,9 +245,11 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
176
245
  const refresh = params.refresh === true;
177
246
 
178
247
  const pages = new Map<string, { page?: CachedPage; cached: boolean; refreshed: boolean; error?: string }>();
248
+ const cacheKeys = new Map<string, string>();
179
249
  const missing: string[] = [];
180
250
  for (const url of urls) {
181
251
  const cacheKey = buildCacheKey(providerName, url, params, config);
252
+ cacheKeys.set(url, cacheKey);
182
253
  const cached = fetchCache.get(cacheKey);
183
254
  if (cached && !refresh) {
184
255
  pages.set(url, { page: cached, cached: true, refreshed: false });
@@ -200,7 +271,7 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
200
271
  onProgress?.({ status: "error", url: requestedUrl, error });
201
272
  continue;
202
273
  }
203
- const cacheKey = buildCacheKey(providerName, requestedUrl, params, config);
274
+ const cacheKey = cacheKeys.get(requestedUrl)!;
204
275
  const page = fetchCache.set(cacheKey, {
205
276
  provider: providerName,
206
277
  cacheKey,
@@ -224,16 +295,6 @@ export async function fetchWithCache(providerName: FetchProviderName, params: Re
224
295
  }) };
225
296
  }
226
297
 
227
- function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, WebFetchResult["results"][number]> {
228
- const out = new Map<string, WebFetchResult["results"][number]>();
229
- const remaining = [...(fetched.results ?? [])];
230
- for (const url of requested) {
231
- const index = remaining.findIndex((item) => urlsMatch(item.url, url));
232
- if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
233
- }
234
- requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
235
- return out;
236
- }
237
298
 
238
299
  export function pageSlice(page: CachedPage, offset: number, limit: number, cached: boolean, refreshed: boolean) {
239
300
  const total = page.content.length;
@@ -266,7 +327,13 @@ function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<
266
327
  }
267
328
 
268
329
  function providerScope(provider: FetchProviderName, config?: any): string {
269
- const key = provider === "exa" || provider === "exa_mcp" ? config?.apiKeys?.exa : provider === "tinyfish" ? config?.apiKeys?.tinyfish : provider === "firecrawl" ? config?.apiKeys?.firecrawl : undefined;
330
+ const keyMap: Partial<Record<FetchProviderName, string | undefined>> = {
331
+ exa: config?.apiKeys?.exa,
332
+ exa_mcp: config?.apiKeys?.exa,
333
+ tinyfish: config?.apiKeys?.tinyfish,
334
+ firecrawl: config?.apiKeys?.firecrawl,
335
+ };
336
+ const key = keyMap[provider];
270
337
  return key ? `key:${String(key).slice(0, 8)}` : "default";
271
338
  }
272
339
 
@@ -294,7 +361,7 @@ function markProgressDone(progress: WebProgress, label: string, note?: string) {
294
361
  if (!item) return;
295
362
  item.status = "done";
296
363
  item.note = note;
297
- progress.completed = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
364
+ progress.completed++;
298
365
  }
299
366
 
300
367
  function markProgressError(progress: WebProgress, label: string, error?: string) {
@@ -302,7 +369,7 @@ function markProgressError(progress: WebProgress, label: string, error?: string)
302
369
  if (!item) return;
303
370
  item.status = "error";
304
371
  item.error = error;
305
- progress.completed = progress.items.filter((i) => i.status === "done" || i.status === "error").length;
372
+ progress.completed++;
306
373
  }
307
374
 
308
375
  function emitProgress(onUpdate: ((patch: any) => void) | undefined, progress: WebProgress) {
@@ -355,8 +422,14 @@ function renderProgress(progress: WebProgress, theme: any, spinner: string, expa
355
422
  const verb = isSearch ? "Searching web" : progress.total === 1 ? "Fetching page" : "Fetching pages";
356
423
  let text = `${isSearch ? "🔎" : "🌐"} ${verb}${progress.total > 1 ? ` ${progressBar(progress.completed, progress.total)} ${progress.completed}/${progress.total}` : "…"}`;
357
424
  const visible = expanded ? progress.items : progress.items.slice(0, 6);
425
+ const iconMap: Record<string, string> = {
426
+ done: theme.fg("success", "✓"),
427
+ error: theme.fg("error", "✕"),
428
+ current: theme.fg("warning", spinner),
429
+ pending: theme.fg("muted", "·"),
430
+ };
358
431
  for (const item of visible) {
359
- const icon = item.status === "done" ? theme.fg("success", "✓") : item.status === "error" ? theme.fg("error", "✕") : item.status === "current" ? theme.fg("warning", spinner) : theme.fg("muted", "·");
432
+ const icon = iconMap[item.status] ?? theme.fg("muted", "·");
360
433
  const note = item.error ? theme.fg("error", ` ${item.error}`) : item.note ? theme.fg("muted", ` ${item.note}`) : "";
361
434
  text += `\n ${icon} ${theme.fg(item.status === "pending" ? "muted" : "accent", quote(truncateMiddle(item.label, 100)))}${note}`;
362
435
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Context-efficient web search and fetch tools for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { FetchProviderName, SearchProviderName, WebKitConfig } from "./types.js";
@@ -30,7 +30,7 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
30
30
  });
31
31
  const home = env.HOME ?? homedir();
32
32
  for (const path of [join(home, ".pi/agent/pi-web-kit.json"), join(cwd, ".pi-web-kit.json")]) {
33
- if (existsSync(path)) cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig);
33
+ try { cfg = merge(cfg, JSON.parse(readFileSync(path, "utf8")) as PartialConfig); } catch (e: any) { if (e?.code !== "ENOENT") throw e; }
34
34
  }
35
35
  cfg = merge(cfg, {
36
36
  provider_search: flags.providerSearch as SearchProviderName | undefined,
@@ -63,9 +63,11 @@ export function validateFetchProvider(name: string): asserts name is FetchProvid
63
63
  if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
64
64
  }
65
65
 
66
+ const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY" } as const;
67
+
66
68
  export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl"): string {
67
69
  const key = config.apiKeys[provider];
68
- const envName = provider === "exa" ? "EXA_API_KEY" : provider === "tinyfish" ? "TINYFISH_API_KEY" : provider === "brave" ? "BRAVE_SEARCH_API_KEY" : "FIRECRAWL_API_KEY";
70
+ const envName = PROVIDER_ENV_NAMES[provider];
69
71
  if (!key) throw new Error(`${provider} provider requires ${envName} or apiKeys.${provider} in .pi-web-kit.json / ~/.pi/agent/pi-web-kit.json.`);
70
72
  return key;
71
73
  }
@@ -1,17 +1,17 @@
1
1
  import { asSnippet, fetchWithTimeout, normalizeUrls } from "../http.js";
2
+ import { DEFAULT_NUM_RESULTS } from "../limits.js";
2
3
  import { urlsMatch } from "../urls.js";
3
4
  import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
4
5
  import { applyExaFetchFallbacks } from "./fallback.js";
5
6
 
6
- let nextId = 1;
7
-
8
7
  export class ExaMcpProvider implements SearchProvider, FetchProvider {
9
8
  private sessionId?: string;
9
+ private nextId = 1;
10
10
  constructor(private config: WebKitConfig) {}
11
11
 
12
12
  async search(input: SearchInput, signal?: AbortSignal) {
13
- const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ?? 10 }, signal);
14
- return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result, input.query) };
13
+ const result = await this.callTool("web_search_exa", { query: input.query, numResults: input.numResults ?? DEFAULT_NUM_RESULTS }, signal);
14
+ return { provider: "exa_mcp" as const, query: input.query, results: normalizeSearch(result) };
15
15
  }
16
16
 
17
17
  async fetch(input: FetchInput, signal?: AbortSignal) {
@@ -44,7 +44,7 @@ export class ExaMcpProvider implements SearchProvider, FetchProvider {
44
44
  const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json, text/event-stream" };
45
45
  if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
46
46
  if (this.config.apiKeys.exa) headers["x-api-key"] = this.config.apiKeys.exa;
47
- const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: nextId++, method, params };
47
+ const body = notification ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id: this.nextId++, method, params };
48
48
  const res = await fetchWithTimeout("https://mcp.exa.ai/mcp", { method: "POST", headers, body: JSON.stringify(body), signal, timeoutMs: 45_000 });
49
49
  const text = await res.text();
50
50
  if (!res.ok) throw new Error(`Exa MCP ${method} failed: ${res.status} ${res.statusText}: ${text.slice(0, 1000)}`);
@@ -70,7 +70,7 @@ function textFromContent(result: any): string {
70
70
  return typeof content === "string" ? content : JSON.stringify(content);
71
71
  }
72
72
 
73
- function normalizeSearch(result: any, _query: string) {
73
+ function normalizeSearch(result: any) {
74
74
  const structured = result?.structuredContent ?? result?.result ?? result;
75
75
  const list = structured.results ?? structured.data ?? structured.items;
76
76
  if (Array.isArray(list)) return list.map((r: any, i: number) => ({ title: r.title, url: r.url, snippet: asSnippet(r.snippet ?? r.text ?? r.summary ?? r.highlights), siteName: r.siteName, position: i + 1 })).filter((r: any) => r.url);
@@ -1,4 +1,5 @@
1
1
  import { asSnippet, normalizeUrls, requestJson } from "../http.js";
2
+ import { DEFAULT_NUM_RESULTS } from "../limits.js";
2
3
  import { urlsMatch } from "../urls.js";
3
4
  import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
4
5
  import { requireKey } from "../config.js";
@@ -11,7 +12,7 @@ export class ExaProvider implements SearchProvider, FetchProvider {
11
12
  async search(input: SearchInput, signal?: AbortSignal) {
12
13
  const body = {
13
14
  query: input.query,
14
- numResults: input.numResults ?? 10,
15
+ numResults: input.numResults ?? DEFAULT_NUM_RESULTS,
15
16
  contents: input.contents ?? { highlights: true },
16
17
  includeDomains: input.includeDomains,
17
18
  excludeDomains: input.excludeDomains,
@@ -62,13 +62,14 @@ function fallbackProviders(config: WebKitConfig): Array<{ name: FetchProviderNam
62
62
  return providers;
63
63
  }
64
64
 
65
- function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
65
+ export function mapFetchResults(requested: string[], fetched: WebFetchResult): Map<string, FetchPage> {
66
66
  const out = new Map<string, FetchPage>();
67
67
  const remaining = [...(fetched.results ?? [])];
68
68
  for (const url of requested) {
69
69
  const index = remaining.findIndex((item) => urlsMatch(item.url, url));
70
70
  if (index >= 0) out.set(url, remaining.splice(index, 1)[0]);
71
71
  }
72
+ // providers may not echo back the canonical URL; fall back to positional match
72
73
  requested.forEach((url, index) => { if (!out.has(url) && fetched.results?.[index]) out.set(url, fetched.results[index]); });
73
74
  return out;
74
75
  }
@@ -1,5 +1,5 @@
1
1
  import { asSnippet, mapConcurrent, normalizeUrls, requestJson } from "../http.js";
2
- import { FETCH_CONCURRENCY } from "../limits.js";
2
+ import { DEFAULT_NUM_RESULTS, FETCH_CONCURRENCY } from "../limits.js";
3
3
  import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
4
4
  import { requireKey } from "../config.js";
5
5
 
@@ -13,7 +13,7 @@ export class FirecrawlProvider implements SearchProvider, FetchProvider {
13
13
  method: "POST", headers: this.headers(), signal, timeoutMs: 45000,
14
14
  body: JSON.stringify({
15
15
  query: input.query,
16
- limit: input.numResults ?? 10,
16
+ limit: input.numResults ?? DEFAULT_NUM_RESULTS,
17
17
  location: input.location,
18
18
  country: input.country,
19
19
  includeDomains: input.includeDomains,
@@ -1,4 +1,5 @@
1
1
  import { asSnippet, normalizeUrls, requestJson } from "../http.js";
2
+ import { MAX_URL_COUNT } from "../limits.js";
2
3
  import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebKitConfig } from "../types.js";
3
4
  import { requireKey } from "../config.js";
4
5
 
@@ -20,7 +21,7 @@ export class TinyFishProvider implements SearchProvider, FetchProvider {
20
21
 
21
22
  async fetch(input: FetchInput, signal?: AbortSignal) {
22
23
  const urls = normalizeUrls(input);
23
- if (urls.length > 10) throw new Error("TinyFish fetch supports a maximum of 10 URLs per request.");
24
+ if (urls.length > MAX_URL_COUNT) throw new Error(`TinyFish fetch supports a maximum of ${MAX_URL_COUNT} URLs per request.`);
24
25
  const data = await requestJson<any>("https://api.fetch.tinyfish.ai", {
25
26
  method: "POST",
26
27
  headers: { "content-type": "application/json", "X-API-Key": this.key },
package/src/urls.ts CHANGED
@@ -31,9 +31,11 @@ export function normalizeUrlInput(input: { url?: string; urls?: string[] }, maxC
31
31
  export function urlsMatch(a: string | undefined, b: string | undefined): boolean {
32
32
  if (!a || !b) return false;
33
33
  try {
34
- if (canonicalWebUrl(a) === canonicalWebUrl(b)) return true;
34
+ const ca = canonicalWebUrl(a);
35
+ const cb = canonicalWebUrl(b);
36
+ if (ca === cb) return true;
35
37
  const trimSlash = (s: string) => s.endsWith("/") ? s.slice(0, -1) : s;
36
- return trimSlash(canonicalWebUrl(a)) === trimSlash(canonicalWebUrl(b));
38
+ return trimSlash(ca) === trimSlash(cb);
37
39
  } catch {
38
40
  return a === b;
39
41
  }