@herbertgao/pi-extensions 2026.9.12 → 2026.9.14

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 (90) hide show
  1. package/README.md +4 -4
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  5. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +14 -0
  6. package/node_modules/@herbertgao/pi-subagents/package.json +4 -4
  7. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
  8. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
  9. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  10. package/node_modules/@herbertgao/sol-pi/package.json +5 -5
  11. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +2 -0
  12. package/node_modules/@narumitw/pi-btw/README.md +21 -4
  13. package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
  14. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  15. package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
  16. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  17. package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
  18. package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
  19. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
  20. package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
  21. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  22. package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
  23. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
  24. package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
  25. package/node_modules/pi-mcp-adapter/CHANGELOG.md +26 -0
  26. package/node_modules/pi-mcp-adapter/README.md +39 -9
  27. package/node_modules/pi-mcp-adapter/cli.js +33 -22
  28. package/node_modules/pi-mcp-adapter/commands.ts +3 -3
  29. package/node_modules/pi-mcp-adapter/config.ts +9 -1
  30. package/node_modules/pi-mcp-adapter/dist/config.js +9 -1
  31. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  32. package/node_modules/pi-mcp-adapter/dist/jev-client.d.ts +1 -2
  33. package/node_modules/pi-mcp-adapter/dist/jev-client.js +59 -32
  34. package/node_modules/pi-mcp-adapter/dist/jev-client.js.map +1 -1
  35. package/node_modules/pi-mcp-adapter/dist/jev-contracts.d.ts +1 -1
  36. package/node_modules/pi-mcp-adapter/dist/jev-key-store.d.ts +33 -4
  37. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js +148 -28
  38. package/node_modules/pi-mcp-adapter/dist/jev-key-store.js.map +1 -1
  39. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js +1 -2
  40. package/node_modules/pi-mcp-adapter/dist/mcp-auth-flow.js.map +1 -1
  41. package/node_modules/pi-mcp-adapter/dist/types.d.ts +7 -1
  42. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  43. package/node_modules/pi-mcp-adapter/index.ts +51 -38
  44. package/node_modules/pi-mcp-adapter/jev-client.ts +58 -31
  45. package/node_modules/pi-mcp-adapter/jev-contracts.ts +2 -2
  46. package/node_modules/pi-mcp-adapter/jev-key-store.ts +144 -31
  47. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  48. package/node_modules/pi-mcp-adapter/mcp-code.ts +6 -6
  49. package/node_modules/pi-mcp-adapter/package.json +5 -5
  50. package/node_modules/pi-mcp-adapter/semantic-search.ts +3 -3
  51. package/node_modules/pi-mcp-adapter/types.ts +7 -1
  52. package/node_modules/pi-multi-account/CHANGELOG.md +29 -0
  53. package/node_modules/pi-multi-account/README.md +39 -11
  54. package/node_modules/pi-multi-account/index.ts +371 -96
  55. package/node_modules/pi-multi-account/package.json +5 -4
  56. package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
  57. package/node_modules/pi-multi-account/usage.ts +31 -2
  58. package/node_modules/pi-typesafe/README.md +3 -1
  59. package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
  60. package/node_modules/pi-typesafe/dist/auth.js +13 -7
  61. package/node_modules/pi-typesafe/dist/backends.d.ts +41 -0
  62. package/node_modules/pi-typesafe/dist/backends.js +59 -0
  63. package/node_modules/pi-typesafe/dist/client.d.ts +4 -10
  64. package/node_modules/pi-typesafe/dist/client.js +72 -44
  65. package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
  66. package/node_modules/pi-typesafe/dist/credentials.js +15 -8
  67. package/node_modules/pi-typesafe/dist/errors.d.ts +10 -2
  68. package/node_modules/pi-typesafe/dist/errors.js +28 -7
  69. package/node_modules/pi-typesafe/dist/extension.js +6 -2
  70. package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
  71. package/node_modules/pi-typesafe/dist/index.js +1 -1
  72. package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
  73. package/node_modules/pi-typesafe/dist/login.js +17 -8
  74. package/node_modules/pi-typesafe/dist/schema.js +19 -13
  75. package/node_modules/pi-typesafe/package.json +4 -4
  76. package/node_modules/pi-web-access/CHANGELOG.md +22 -0
  77. package/node_modules/pi-web-access/README.md +21 -8
  78. package/node_modules/pi-web-access/baizhi.ts +124 -0
  79. package/node_modules/pi-web-access/crawl4ai.ts +2 -10
  80. package/node_modules/pi-web-access/curator-page.ts +3 -1
  81. package/node_modules/pi-web-access/curator-server.ts +1 -0
  82. package/node_modules/pi-web-access/dist/index.js +540 -220
  83. package/node_modules/pi-web-access/firecrawl.ts +2 -10
  84. package/node_modules/pi-web-access/gemini-search.ts +5 -10
  85. package/node_modules/pi-web-access/index.ts +134 -23
  86. package/node_modules/pi-web-access/package.json +5 -1
  87. package/node_modules/pi-web-access/storage.ts +1 -0
  88. package/node_modules/pi-web-access/tool-activation.ts +134 -0
  89. package/node_modules/pi-web-access/utils.ts +9 -1
  90. package/package.json +14 -14
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import net from "node:net";
3
2
  import { activityMonitor } from "./activity.ts";
4
3
  import { normalizeDomain } from "./domain-filter-normalization.ts";
5
4
  import { redactCredential, resolveCredential } from "./credential-source.ts";
@@ -8,7 +7,7 @@ import type { SearchOptions, SearchResponse } from "./perplexity.ts";
8
7
  import { formatSearchResultsAsAnswer } from "./search-answer-formatting.ts";
9
8
  import { normalizeSearchResultCount } from "./search-result-count-normalization.ts";
10
9
  import { loadSsrfConfig, validateRemoteUrl, type Lookup } from "./ssrf-protection.ts";
11
- import { getWebSearchConfigPath } from "./utils.ts";
10
+ import { getWebSearchConfigPath, isLoopbackHostname } from "./utils.ts";
12
11
 
13
12
  const CONFIG_PATH = getWebSearchConfigPath();
14
13
  const DEFAULT_API_VERSION = "v2";
@@ -188,13 +187,6 @@ function ssrfOptions(options?: FirecrawlExtractOptions | FirecrawlSearchOptions)
188
187
  };
189
188
  }
190
189
 
191
- function isLoopbackApiUrl(url: URL): boolean {
192
- const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
193
- if (hostname === "localhost" || hostname === "::1") return true;
194
- if (net.isIP(hostname) !== 4) return false;
195
- return hostname.split(".")[0] === "127";
196
- }
197
-
198
190
  function firecrawlApiSsrfOptions(
199
191
  options: FirecrawlExtractOptions | FirecrawlSearchOptions | undefined,
200
192
  allowLoopback: boolean,
@@ -218,7 +210,7 @@ async function fetchFirecrawlApi(
218
210
  init: { method: string; headers: Record<string, string>; body: string; signal: AbortSignal },
219
211
  options: FirecrawlExtractOptions | FirecrawlSearchOptions | undefined,
220
212
  ): Promise<Response> {
221
- const allowLoopback = isLoopbackApiUrl(new URL(url));
213
+ const allowLoopback = isLoopbackHostname(new URL(url).hostname);
222
214
  let current = await validateRemoteUrl(url, firecrawlApiSsrfOptions(options, allowLoopback));
223
215
  let headers = init.headers;
224
216
  for (let redirects = 0; redirects <= DEFAULT_MAX_REDIRECTS; redirects++) {
@@ -38,12 +38,13 @@ import { isSerpBaseAvailable, searchWithSerpBase } from "./serpbase.ts";
38
38
  import { isSerpApiAvailable, searchWithSerpApi } from "./serpapi.ts";
39
39
  import { isSerperAvailable, searchWithSerper } from "./serper.ts";
40
40
  import { isSerplyAvailable, searchWithSerply } from "./serply.ts";
41
+ import { isBaizhiAvailable, searchWithBaizhi } from "./baizhi.ts";
41
42
  import { isValyuAvailable, searchWithValyu } from "./valyu.ts";
42
43
  import { isKimiSearchAvailable, searchWithKimi } from "./kimi-search.ts";
43
44
  import { isMistralAvailable, searchWithMistral } from "./mistral-search.ts";
44
45
  import { getWebSearchConfigPath } from "./utils.ts";
45
46
 
46
- export const RESOLVED_SEARCH_PROVIDERS = ["openai", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "exa", "serpdive", "kagi", "ollama", "anysearch", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu", "bocha", "xcrawl"] as const;
47
+ export const RESOLVED_SEARCH_PROVIDERS = ["openai", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "exa", "serpdive", "kagi", "ollama", "anysearch", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu", "bocha", "xcrawl", "baizhi"] as const;
47
48
  export const SEARCH_PROVIDERS = ["auto", "all", ...RESOLVED_SEARCH_PROVIDERS] as const;
48
49
 
49
50
  export type ResolvedSearchProvider = typeof RESOLVED_SEARCH_PROVIDERS[number];
@@ -270,20 +271,11 @@ function isAbortError(err: unknown): boolean {
270
271
  return errorMessage(err).toLowerCase().includes("abort");
271
272
  }
272
273
 
273
- function shouldTryOpenAIInAuto(options: SearchOptions): boolean {
274
- if (options.recencyFilter) return false;
275
- if (typeof options.numResults === "number" && Number.isFinite(options.numResults) && Math.floor(options.numResults) !== 5) {
276
- return false;
277
- }
278
- return true;
279
- }
280
-
281
274
  function isOpenAICodexSelected(ctx?: ExtensionContext): boolean {
282
275
  return ctx?.model?.provider === "openai-codex";
283
276
  }
284
277
 
285
278
  async function tryOpenAIInAuto(query: string, options: FullSearchOptions, fallbackErrors: string[]): Promise<AttributedSearchResponse | null> {
286
- if (!shouldTryOpenAIInAuto(options)) return null;
287
279
  try {
288
280
  if (await isOpenAISearchAvailable(options.extensionContext)) {
289
281
  const result = await searchWithOpenAI(query, options, options.extensionContext);
@@ -411,6 +403,7 @@ async function searchWithResolvedProvider(
411
403
  if (provider === "serpapi") return { ...(await searchWithSerpApi(query, options)), provider };
412
404
  if (provider === "serper") return { ...(await searchWithSerper(query, options)), provider };
413
405
  if (provider === "serply") return { ...(await searchWithSerply(query, options)), provider };
406
+ if (provider === "baizhi") return { ...(await searchWithBaizhi(query, options)), provider };
414
407
  if (provider === "valyu") return { ...(await searchWithValyu(query, options)), provider };
415
408
  if (provider === "xcrawl") return { ...(await searchWithXCrawl(query, options)), provider };
416
409
  if (provider === "perplexity") return { ...(await searchWithPerplexity(query, options)), provider };
@@ -461,6 +454,7 @@ async function isResolvedProviderAvailable(provider: ResolvedSearchProvider, opt
461
454
  if (provider === "serpapi") return isSerpApiAvailable();
462
455
  if (provider === "serper") return isSerperAvailable();
463
456
  if (provider === "serply") return isSerplyAvailable();
457
+ if (provider === "baizhi") return isBaizhiAvailable();
464
458
  if (provider === "valyu") return isValyuAvailable();
465
459
  if (provider === "xcrawl") return isXcrawlAvailable();
466
460
  if (provider === "perplexity") return isPerplexityAvailable();
@@ -502,6 +496,7 @@ export function providerLabel(provider: ResolvedSearchProvider): string {
502
496
  if (provider === "serpapi") return "SerpApi";
503
497
  if (provider === "serper") return "Serper";
504
498
  if (provider === "serply") return "Serply";
499
+ if (provider === "baizhi") return "Baizhi";
505
500
  if (provider === "valyu") return "Valyu";
506
501
  return provider.charAt(0).toUpperCase() + provider.slice(1);
507
502
  }
@@ -72,6 +72,7 @@ import { isSerpBaseAvailable } from "./serpbase.ts";
72
72
  import { isSerpApiAvailable } from "./serpapi.ts";
73
73
  import { isSerperAvailable } from "./serper.ts";
74
74
  import { isSerplyAvailable } from "./serply.ts";
75
+ import { isBaizhiAvailable } from "./baizhi.ts";
75
76
  import { isValyuAvailable } from "./valyu.ts";
76
77
  import { isXcrawlAvailable } from "./xcrawl.ts";
77
78
  import { buildSearchErrorPlan, type SearchErrorDetails, type SearchErrorPlan } from "./render-search-error.ts";
@@ -85,6 +86,7 @@ import {
85
86
  type RecencyFilter,
86
87
  type ResearchArtifact,
87
88
  } from "./source-check.ts";
89
+ import { registerWebToolActivation, type WebActivationTool } from "./tool-activation.ts";
88
90
 
89
91
  // Match pi-ai's StringEnum without loading its compat barrel during registration.
90
92
  function StringEnum<T extends string[]>(values: T, options?: { description?: string; default?: T[number] }) {
@@ -155,6 +157,7 @@ interface WebSearchConfig {
155
157
  serpapiApiKey?: unknown;
156
158
  serperApiKey?: unknown;
157
159
  serplyApiKey?: unknown;
160
+ baizhiApiKey?: unknown;
158
161
  tinyfishApiKey?: unknown;
159
162
  valyuApiKey?: unknown;
160
163
  xaiApiKey?: unknown;
@@ -336,6 +339,7 @@ function resolveToolNames(config: WebSearchConfig): ToolNames {
336
339
  const seen = new Map<string, keyof ToolNames>();
337
340
  for (const key of registeredKeys) {
338
341
  const name = names[key];
342
+ if (name === "web_enable") throw new Error(`toolNames.${key} in ${WEB_SEARCH_CONFIG_PATH} uses reserved loader name web_enable`);
339
343
  const previous = seen.get(name);
340
344
  if (previous) throw new Error(`toolNames.${key} duplicates toolNames.${previous} in ${WEB_SEARCH_CONFIG_PATH}`);
341
345
  seen.set(name, key);
@@ -488,6 +492,7 @@ async function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderA
488
492
  serpapi: allowedProviders.has("serpapi") && isSerpApiAvailable(),
489
493
  serper: allowedProviders.has("serper") && isSerperAvailable(),
490
494
  serply: allowedProviders.has("serply") && isSerplyAvailable(),
495
+ baizhi: allowedProviders.has("baizhi") && isBaizhiAvailable(),
491
496
  valyu: allowedProviders.has("valyu") && isValyuAvailable(),
492
497
  };
493
498
  return {
@@ -621,11 +626,12 @@ interface PendingCurate {
621
626
 
622
627
 
623
628
  const DEFAULT_MAX_INLINE_CONTENT_CHARS = 30_000;
629
+ const MIN_INLINE_CONTENT_CHARS = 1_000;
624
630
  const MAX_INLINE_CONTENT_CHARS = 200_000;
625
631
 
626
632
  function getMaxInlineContentChars(config = loadConfig()): number {
627
633
  const value = config.maxInlineContentChars;
628
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
634
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < MIN_INLINE_CONTENT_CHARS) {
629
635
  return DEFAULT_MAX_INLINE_CONTENT_CHARS;
630
636
  }
631
637
  return Math.min(value, MAX_INLINE_CONTENT_CHARS);
@@ -772,15 +778,40 @@ function hasFullInlineCoverage(urls: string[], inlineContent: ExtractedContent[]
772
778
 
773
779
  function formatFullResults(queryData: QueryResultData): string {
774
780
  let output = `## Results for: "${queryData.query}"\n\n`;
781
+ const providers = queryData.providers ?? (queryData.provider ? [queryData.provider] : []);
782
+ if (providers.length > 0) output += `**Provider${providers.length === 1 ? "" : "s"}:** ${providers.join(", ")}\n\n`;
775
783
  if (queryData.answer) {
776
784
  output += `${queryData.answer}\n\n---\n\n`;
777
785
  }
778
786
  for (const r of queryData.results) {
779
- output += `### ${r.title}\n${r.url}\n\n`;
787
+ output += `### ${r.title}\n${r.url}${r.snippet ? `\n\n${r.snippet}` : ""}\n\n`;
780
788
  }
781
789
  return output;
782
790
  }
783
791
 
792
+ function boundSearchPresentation(
793
+ text: string,
794
+ guidance: string,
795
+ truncationGuidance: string,
796
+ maxChars: number,
797
+ ): { text: string; truncated: boolean; originalChars: number; returnedChars: number; omittedChars: number } {
798
+ const fullText = `${text}${guidance}`;
799
+ if (fullText.length <= maxChars) {
800
+ return { text: fullText, truncated: false, originalChars: text.length, returnedChars: text.length, omittedChars: 0 };
801
+ }
802
+ const marker = `\n\n---\n[Output truncated.]${truncationGuidance}`;
803
+ // Reserve marker space so the complete model-visible response never exceeds the configured ceiling.
804
+ const prefixLength = maxChars - marker.length;
805
+ const bounded = `${text.slice(0, prefixLength)}${marker}`;
806
+ return {
807
+ text: bounded,
808
+ truncated: true,
809
+ originalChars: text.length,
810
+ returnedChars: prefixLength,
811
+ omittedChars: text.length - prefixLength,
812
+ };
813
+ }
814
+
784
815
  function abortPendingFetches(): void {
785
816
  for (const controller of pendingFetches.values()) {
786
817
  controller.abort();
@@ -1365,6 +1396,7 @@ export default function (pi: ExtensionAPI) {
1365
1396
  const tr = opts.results.reduce((sum, r) => sum + r.results.length, 0);
1366
1397
 
1367
1398
  const hasApprovedSummary = typeof opts.approvedSummary === "string" && opts.approvedSummary.trim().length > 0;
1399
+ const maxInlineContentChars = getMaxInlineContentChars(initConfig);
1368
1400
  let output = "";
1369
1401
  if (hasApprovedSummary) {
1370
1402
  output = opts.approvedSummary!.trim();
@@ -1372,6 +1404,13 @@ export default function (pi: ExtensionAPI) {
1372
1404
  if (opts.curated) {
1373
1405
  output += "[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\n\n";
1374
1406
  }
1407
+ const providerNames = opts.results.map(result => {
1408
+ const providers = result.providers ?? (result.provider ? [result.provider] : []);
1409
+ return providers.join(", ") || "unknown";
1410
+ });
1411
+ output += opts.results.length === 1
1412
+ ? `**Provider:** ${providerNames[0]}\n\n`
1413
+ : `**Providers used:** ${providerNames.map((name, index) => `Query ${index + 1}: ${name}`).join("; ")}\n\n`;
1375
1414
  const duplicateQueries = opts.curated ? duplicateQuerySet(opts.results) : new Set<string>();
1376
1415
  for (const { query, answer, results, error, provider } of opts.results) {
1377
1416
  if (opts.queryList.length > 1) {
@@ -1395,26 +1434,37 @@ export default function (pi: ExtensionAPI) {
1395
1434
  urls: opts.inlineContent,
1396
1435
  } satisfies StoredSearchData & { type: "fetch"; urls: ExtractedContent[] };
1397
1436
  pi.appendEntry("web-search-results", storeFetchedContentResult(fetchId, data));
1398
- if (!hasApprovedSummary) {
1399
- output += `---\nFull content for ${opts.inlineContent.length} sources available [${fetchId}].`;
1400
- }
1401
1437
  } else if (opts.includeContent) {
1402
1438
  fetchId = startBackgroundFetch(opts.urls, opts.proxy);
1403
- if (fetchId && !hasApprovedSummary) {
1404
- output += `---\nContent fetching in background [${fetchId}]. Will notify when ready.`;
1405
- }
1406
1439
  }
1407
1440
 
1408
1441
  const searchId = storeAndPublishSearch(opts.results);
1409
1442
  const isBackgroundFetch = fetchId !== null && !hasInlineReady;
1410
- // The model only sees `content`, not `details`: surface the id it needs to
1411
- // call get_search_content, the same way fetch_content does.
1412
- if (getSearchContentEnabled && !hasApprovedSummary) {
1413
- output += `\n---\nResults stored as responseId "${searchId}". Use ${toolNames.getSearchContent}({ responseId: "${searchId}", queryIndex: 0 }) to retrieve them.`;
1414
- }
1443
+ const unboundedPresentation = output.trim();
1444
+ const buildGuidance = (forTruncation: boolean): string => {
1445
+ let value = "";
1446
+ if (hasInlineReady && opts.inlineContent && fetchId) {
1447
+ value += `\n---\nFull content for ${opts.inlineContent.length} sources is ready as responseId "${fetchId}". `;
1448
+ value += getSearchContentEnabled
1449
+ ? `Use ${toolNames.getSearchContent}({ responseId: "${fetchId}", urlIndex: 0, offset: 0, limit: ${maxInlineContentChars} }) to retrieve the first bounded page.`
1450
+ : forTruncation ? `Enable ${toolNames.getSearchContent} to retrieve the full stored content.` : "";
1451
+ } else if (isBackgroundFetch && fetchId) {
1452
+ value += `\n---\nContent fetching in background as responseId "${fetchId}". Will notify when ready.`;
1453
+ }
1454
+ if (getSearchContentEnabled || forTruncation) {
1455
+ value += `\n---\nFull search results are stored as responseId "${searchId}". `;
1456
+ value += getSearchContentEnabled
1457
+ ? `Use ${toolNames.getSearchContent}({ responseId: "${searchId}", queryIndex: 0, offset: 0, limit: ${maxInlineContentChars} }) to retrieve the first bounded page${opts.results.length > 1 ? `; repeat with queryIndex 1 through ${opts.results.length - 1}` : ""}.`
1458
+ : `Enable ${toolNames.getSearchContent} to retrieve the full stored results.`;
1459
+ }
1460
+ return value;
1461
+ };
1462
+ const presentation = hasApprovedSummary
1463
+ ? { text: unboundedPresentation, truncated: false, originalChars: unboundedPresentation.length, returnedChars: unboundedPresentation.length, omittedChars: 0 }
1464
+ : boundSearchPresentation(unboundedPresentation, buildGuidance(false), buildGuidance(true), maxInlineContentChars);
1415
1465
 
1416
1466
  return {
1417
- content: [{ type: "text", text: output.trim() }],
1467
+ content: [{ type: "text", text: presentation.text }],
1418
1468
  details: {
1419
1469
  queries: opts.queryList,
1420
1470
  queryCount: opts.queryList.length,
@@ -1424,6 +1474,14 @@ export default function (pi: ExtensionAPI) {
1424
1474
  fetchId,
1425
1475
  fetchUrls: isBackgroundFetch ? opts.urls : undefined,
1426
1476
  searchId,
1477
+ queryProviders: opts.results.map(result => ({
1478
+ query: result.query,
1479
+ providers: result.providers ?? (result.provider ? [result.provider] : []),
1480
+ })),
1481
+ truncated: presentation.truncated,
1482
+ originalChars: presentation.originalChars,
1483
+ returnedChars: presentation.returnedChars,
1484
+ omittedChars: presentation.omittedChars,
1427
1485
  ...(opts.curated ? {
1428
1486
  curated: true,
1429
1487
  curatedFrom: opts.curatedFrom,
@@ -1775,7 +1833,7 @@ export default function (pi: ExtensionAPI) {
1775
1833
  name: toolNames.webSearch,
1776
1834
  label: "Web Search",
1777
1835
  description:
1778
- `Search the web with ${allowedSearchProviders.map(providerLabel).join(", ")}. Provider arrays run simultaneously; ${allPolicyDescription}. By default, returns source-linked search results or provider answers. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query. When includeContent is true, full page content is fetched in the background. Searches return without the interactive browser curator by default; set workflow to "summary-review" to open the curator with an auto-generated summary draft or "auto-summary" to generate a summary without the browser curator. The configured provider is used when provider is omitted or set to auto; omit provider unless explicitly overriding it.`,
1836
+ `Search the web with ${allowedSearchProviders.map(providerLabel).join(", ")}. Provider arrays run simultaneously; ${allPolicyDescription}. The default workflow is none: it returns bounded source-linked search results or provider answers without a curator or generated summary, identifies the providers used, and stores full results for retrieval by responseId. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query. When includeContent is true, full page content is fetched in the background. Set workflow to "summary-review" to open the curator with an auto-generated summary draft or "auto-summary" to generate a summary without the browser curator. The configured provider is used when provider is omitted or set to auto; omit provider unless explicitly overriding it.`,
1779
1837
  promptSnippet:
1780
1838
  "Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage. Omit provider unless explicitly overriding the configured default.",
1781
1839
  parameters: Type.Object({
@@ -2030,7 +2088,7 @@ export default function (pi: ExtensionAPI) {
2030
2088
  });
2031
2089
 
2032
2090
  try {
2033
- const { answer, results, inlineContent, provider } = await search(query, {
2091
+ const { answer, results, inlineContent, provider, providerResponses } = await search(query, {
2034
2092
  provider: resolvedProvider,
2035
2093
  numResults: params.numResults,
2036
2094
  recencyFilter,
@@ -2040,7 +2098,8 @@ export default function (pi: ExtensionAPI) {
2040
2098
  extensionContext: ctx,
2041
2099
  });
2042
2100
 
2043
- return { result: { query, answer, results, error: null, provider } satisfies QueryResultData, inlineContent };
2101
+ const providers = providerResponses?.map(response => response.provider) ?? [provider];
2102
+ return { result: { query, answer, results, error: null, provider, providers } satisfies QueryResultData, inlineContent };
2044
2103
  } catch (err) {
2045
2104
  if (signal?.aborted || isAbortError(err)) throw err;
2046
2105
  const message = err instanceof Error ? err.message : String(err);
@@ -2830,7 +2889,7 @@ export default function (pi: ExtensionAPI) {
2830
2889
  pi.registerTool({
2831
2890
  name: toolNames.getSearchContent,
2832
2891
  label: "Get Search Content",
2833
- description: `Retrieve bounded content slices or find matching passages in a previous ${storedContentSources} call.`,
2892
+ description: `Retrieve bounded pages of full stored search results or fetched content, or find matching passages, from a previous ${storedContentSources} call.`,
2834
2893
  promptSnippet:
2835
2894
  `Use after ${storedContentSources} to retrieve stored content via responseId. Use findText to locate passages without paging through the full content.`,
2836
2895
  parameters: Type.Object({
@@ -2839,8 +2898,8 @@ export default function (pi: ExtensionAPI) {
2839
2898
  queryIndex: Type.Optional(Type.Integer({ minimum: 0, description: "Get content for query at index" })),
2840
2899
  url: Type.Optional(Type.String({ description: "Get content for this URL" })),
2841
2900
  urlIndex: Type.Optional(Type.Integer({ minimum: 0, description: "Get content for URL at index" })),
2842
- offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset for fetched URL content slices (default 0). Ignored when findText is supplied." })),
2843
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxInlineContentChars, description: "Maximum characters to return for fetched URL content slices (default and max are set by maxInlineContentChars). Ignored when findText is supplied." })),
2901
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Character offset in stored search or fetched URL content (default 0). Ignored when findText is supplied." })),
2902
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxInlineContentChars, description: "Requested maximum stored-content characters (default and max use maxInlineContentChars). Search-page continuation guidance shares the global output cap and may reduce returnedChars. Ignored when findText is supplied." })),
2844
2903
  findText: Type.Optional(Type.Union([
2845
2904
  Type.String({ minLength: 1, maxLength: 500 }),
2846
2905
  Type.Array(Type.String({ minLength: 1, maxLength: 500 }), { minItems: 1, maxItems: 10 }),
@@ -2961,7 +3020,7 @@ export default function (pi: ExtensionAPI) {
2961
3020
  const { text, ...findDetails } = found;
2962
3021
  return {
2963
3022
  content: [{ type: "text", text }],
2964
- details: { query: queryData.query, resultCount: queryData.results.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
3023
+ details: { responseId: params.responseId, query: queryData.query, resultCount: queryData.results.length, contentLength: fullResults.length, findMode: params.findMode ?? "case-insensitive", ...findDetails },
2965
3024
  };
2966
3025
  } catch (err) {
2967
3026
  const error = err instanceof Error ? err.message : String(err);
@@ -2972,9 +3031,53 @@ export default function (pi: ExtensionAPI) {
2972
3031
  }
2973
3032
  }
2974
3033
 
3034
+ const offset = params.offset ?? 0;
3035
+ const limit = params.limit ?? maxInlineContentChars;
3036
+ if (!Number.isInteger(offset) || offset < 0) {
3037
+ return {
3038
+ content: [{ type: "text", text: `Invalid offset: received ${formatInputValue(offset)} for query ${formatInputValue(queryData.query)}; offset must be a non-negative integer. Use 0 or a larger integer.` }],
3039
+ details: { error: "Invalid offset", offset },
3040
+ };
3041
+ }
3042
+ if (!Number.isInteger(limit) || limit <= 0 || limit > maxInlineContentChars) {
3043
+ return {
3044
+ content: [{ type: "text", text: `Invalid limit: received ${formatInputValue(limit)} for query ${formatInputValue(queryData.query)}; limit must be an integer from 1 to ${maxInlineContentChars}. Use a value in that range.` }],
3045
+ details: { error: "Invalid limit", limit, maxLimit: maxInlineContentChars },
3046
+ };
3047
+ }
3048
+ if (offset > fullResults.length) {
3049
+ return {
3050
+ content: [{ type: "text", text: `Offset ${offset} is out of range for query ${formatInputValue(queryData.query)} in responseId ${formatInputValue(params.responseId)}. Received offset ${offset}; valid range is 0-${fullResults.length}. Use an offset within that range.` }],
3051
+ details: { error: "Offset out of range", offset, contentLength: fullResults.length },
3052
+ };
3053
+ }
3054
+ const queryIndex = data.queries.indexOf(queryData);
3055
+ let returnedChars = Math.min(limit, fullResults.length - offset);
3056
+ let endOffset = offset + returnedChars;
3057
+ let continuation = "";
3058
+ while (endOffset < fullResults.length) {
3059
+ continuation = `\n\n---\nShowing chars ${offset}-${endOffset} of ${fullResults.length}. Use ${toolNames.getSearchContent}({ responseId: "${params.responseId}", queryIndex: ${queryIndex}, offset: ${endOffset}, limit: ${limit} }) for the next slice.`;
3060
+ const overflow = returnedChars + continuation.length - maxInlineContentChars;
3061
+ if (overflow <= 0) break;
3062
+ returnedChars -= overflow;
3063
+ endOffset = offset + returnedChars;
3064
+ }
3065
+ const resultSlice = fullResults.slice(offset, endOffset);
3066
+ const hasMore = endOffset < fullResults.length;
3067
+ const text = `${resultSlice}${hasMore ? continuation : ""}`;
2975
3068
  return {
2976
- content: [{ type: "text", text: fullResults }],
2977
- details: { query: queryData.query, resultCount: queryData.results.length },
3069
+ content: [{ type: "text", text }],
3070
+ details: {
3071
+ responseId: params.responseId,
3072
+ query: queryData.query,
3073
+ resultCount: queryData.results.length,
3074
+ contentLength: fullResults.length,
3075
+ offset,
3076
+ limit,
3077
+ returnedChars,
3078
+ nextOffset: hasMore ? endOffset : null,
3079
+ truncated: hasMore,
3080
+ },
2978
3081
  };
2979
3082
  }
2980
3083
 
@@ -3161,6 +3264,14 @@ export default function (pi: ExtensionAPI) {
3161
3264
  });
3162
3265
  }
3163
3266
 
3267
+ const activationTools: WebActivationTool[] = [
3268
+ ...(webSearchEnabled ? [{ name: toolNames.webSearch, capability: "search" as const }] : []),
3269
+ ...(sourceCheckEnabled ? [{ name: toolNames.sourceCheck, capability: "source-check" as const }] : []),
3270
+ ...(fetchContentEnabled ? [{ name: toolNames.fetchContent, capability: "fetch" as const }] : []),
3271
+ ...(getSearchContentEnabled ? [{ name: toolNames.getSearchContent, capability: "stored-content" as const }] : []),
3272
+ ];
3273
+ registerWebToolActivation(pi, activationTools);
3274
+
3164
3275
  if (isCommandEnabled(initConfig, "websearch")) pi.registerCommand("websearch", {
3165
3276
  description: "Open web search curator",
3166
3277
  handler: async (args, ctx) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-access",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Web search, URL fetching, GitHub repo cloning, PDF extraction, YouTube video understanding, and local video analysis for Pi coding agent. Supports OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Crawl4AI, Jina, SERPdive, Kagi, Ollama, AnySearch, Bright Data SERP, SerpBase, SerpApi, Serply, SearXNG, Exa, Perplexity, Gemini, Kimi, and Mistral.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -45,11 +45,15 @@
45
45
  "pi-web-fetch-demo.mp4"
46
46
  ],
47
47
  "devDependencies": {
48
+ "@earendil-works/pi-ai": "0.86.1",
49
+ "@earendil-works/pi-coding-agent": "0.86.1",
50
+ "@earendil-works/pi-tui": "0.86.1",
48
51
  "@types/turndown": "^5.0.6",
49
52
  "esbuild": "^0.28.1",
50
53
  "typescript": "^7.0.2"
51
54
  },
52
55
  "dependencies": {
56
+ "@modelcontextprotocol/sdk": "1.27.1",
53
57
  "@mozilla/readability": "^0.6.0",
54
58
  "defuddle": "0.19.3",
55
59
  "linkedom": "^0.16.0",
@@ -36,6 +36,7 @@ export interface QueryResultData {
36
36
  results: SearchResult[];
37
37
  error: string | null;
38
38
  provider?: string;
39
+ providers?: string[];
39
40
  }
40
41
 
41
42
  interface FetchCacheRef {
@@ -0,0 +1,134 @@
1
+ import { buildSessionContext, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { dirname, join } from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Type } from "typebox";
6
+
7
+ export type WebCapability = "search" | "source-check" | "fetch" | "stored-content";
8
+
9
+ export interface WebActivationTool {
10
+ name: string;
11
+ capability: WebCapability;
12
+ }
13
+
14
+ const LOADER_NAME = "web_enable";
15
+ const CAPABILITY_LABELS: Record<WebCapability, string> = {
16
+ search: "web search",
17
+ "source-check": "source checking",
18
+ fetch: "content fetching",
19
+ "stored-content": "stored-result retrieval",
20
+ };
21
+
22
+ function supportsDynamicTools(pi: ExtensionAPI): boolean {
23
+ if (typeof pi.getAllTools !== "function" || typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return false;
24
+ try {
25
+ const packagePath = join(dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), "..", "package.json");
26
+ const [major, minor, patch] = JSON.parse(readFileSync(packagePath, "utf8")).version.split(".").map(Number);
27
+ return major > 0 || minor > 86 || minor === 86 && patch >= 1;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function hasToolDeclarations(messages: unknown[]): boolean {
34
+ return messages.some(message => message && typeof message === "object" && (
35
+ "toolsAdded" in message || "toolsRemoved" in message
36
+ ));
37
+ }
38
+
39
+ async function currentTranscriptToolNames(messages: unknown[]): Promise<string[]> {
40
+ const moduleName = "@earendil-works/pi-ai/utils/transcript";
41
+ const { getCurrentTools } = await import(moduleName);
42
+ return getCurrentTools(messages).map((tool: { name: string }) => tool.name);
43
+ }
44
+
45
+ export function registerWebToolActivation(pi: ExtensionAPI, tools: ReadonlyArray<WebActivationTool>): void {
46
+ if (tools.length === 0) return;
47
+ if (!supportsDynamicTools(pi)) {
48
+ console.warn("[pi-web-access] Dynamic tool activation requires Pi 0.86.1 or newer; web tools remain eagerly available.");
49
+ return;
50
+ }
51
+ const names = tools.map(tool => tool.name);
52
+ const capabilities = tools.map(tool => CAPABILITY_LABELS[tool.capability]).join(", ");
53
+
54
+ const parameters = Type.Object({}, { additionalProperties: false });
55
+ pi.registerTool<typeof parameters, Record<string, unknown>>({
56
+ name: LOADER_NAME,
57
+ label: "Enable Web Access",
58
+ description: "Enable configured pi-web-access tools for web research and content retrieval. Does not search or fetch. Enabled tools are available on the next model request; disabled capabilities remain unavailable.",
59
+ promptSnippet: `pi-web-access is configured for ${capabilities}. Call web_enable to activate these tools; use them on the next model request.`,
60
+ parameters,
61
+ async execute() {
62
+ const registered = new Set(pi.getAllTools().map(tool => tool.name));
63
+ const unavailable = names.filter(name => !registered.has(name));
64
+ if (unavailable.length > 0) {
65
+ return {
66
+ isError: true,
67
+ content: [{ type: "text" as const, text: `Cannot enable unavailable tools: ${unavailable.join(", ")}.` }],
68
+ details: { unavailable },
69
+ };
70
+ }
71
+
72
+ try {
73
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...names])]);
74
+ } catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ return {
77
+ isError: true,
78
+ content: [{ type: "text" as const, text: `Activation failed: ${message}.` }],
79
+ details: { error: message },
80
+ };
81
+ }
82
+
83
+ const active = new Set(pi.getActiveTools());
84
+ const missing = names.filter(name => !active.has(name));
85
+ return missing.length > 0
86
+ ? {
87
+ isError: true,
88
+ content: [{ type: "text" as const, text: `Tools still inactive after activation: ${missing.join(", ")}.` }],
89
+ details: { missing },
90
+ }
91
+ : {
92
+ content: [{ type: "text" as const, text: `Enabled: ${names.join(", ")}.` }],
93
+ details: { enabled: names },
94
+ };
95
+ },
96
+ });
97
+
98
+ function loaderAvailable(): boolean {
99
+ return pi.getAllTools().some(tool => tool.name === LOADER_NAME);
100
+ }
101
+
102
+ let warned = false;
103
+ async function selectFromSession(ctx: ExtensionContext): Promise<void> {
104
+ if (!loaderAvailable()) return;
105
+ try {
106
+ const messages = buildSessionContext(ctx.sessionManager.getBranch()).messages;
107
+ const recorded = hasToolDeclarations(messages)
108
+ ? new Set(await currentTranscriptToolNames(messages))
109
+ : messages.length > 0
110
+ ? new Set(names)
111
+ : new Set<string>();
112
+ const heavy = new Set(names);
113
+ const active = pi.getActiveTools().filter(name => !heavy.has(name));
114
+ for (const name of names) if (recorded.has(name)) active.push(name);
115
+ pi.setActiveTools([...new Set([...active, LOADER_NAME])]);
116
+ } catch (error) {
117
+ if (!warned) {
118
+ warned = true;
119
+ console.warn(`[pi-web-access] Keeping web tools eagerly available because activation setup failed: ${error instanceof Error ? error.message : String(error)}`);
120
+ }
121
+ }
122
+ }
123
+
124
+ pi.on("session_start", (_event, ctx) => selectFromSession(ctx));
125
+ pi.on("session_tree", (_event, ctx) => selectFromSession(ctx));
126
+ pi.on("before_agent_start", () => {
127
+ if (!loaderAvailable() || pi.getActiveTools().includes(LOADER_NAME)) return;
128
+ try {
129
+ pi.setActiveTools([...pi.getActiveTools(), LOADER_NAME]);
130
+ } catch {
131
+ // Best effort: preserve the current selection if Pi rejects the update.
132
+ }
133
+ });
134
+ }
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { isIP } from "node:net";
5
6
  import { homedir, hostname, tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
8
 
@@ -43,6 +44,13 @@ interface ApiBaseUrlOptions {
43
44
  environmentValue: string | undefined;
44
45
  }
45
46
 
47
+ export function isLoopbackHostname(hostnameValue: string): boolean {
48
+ const normalized = hostnameValue.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
49
+ return normalized === "localhost"
50
+ || normalized === "::1"
51
+ || (isIP(normalized) === 4 && normalized.split(".", 1)[0] === "127");
52
+ }
53
+
46
54
  export function resolveApiBaseUrl(options: ApiBaseUrlOptions): string {
47
55
  const fromEnvironment = options.environmentValue !== undefined;
48
56
  const value = fromEnvironment ? options.environmentValue : options.configuredValue;
@@ -61,7 +69,7 @@ export function resolveApiBaseUrl(options: ApiBaseUrlOptions): string {
61
69
  } catch {
62
70
  throw new Error(`${source} must be an absolute HTTP(S) URL`);
63
71
  }
64
- if (url.protocol !== "https:") {
72
+ if (url.protocol !== "https:" && (url.protocol !== "http:" || !isLoopbackHostname(url.hostname))) {
65
73
  throw new Error(`${source} must be an absolute HTTPS URL`);
66
74
  }
67
75
  if (url.username || url.password) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herbertgao/pi-extensions",
3
- "version": "2026.9.12",
3
+ "version": "2026.9.14",
4
4
  "description": "Aggregate installer for HerbertGao-maintained Pi extensions.",
5
5
  "keywords": [
6
6
  "extensions",
@@ -38,14 +38,14 @@
38
38
  "@ast-grep/napi": "^0.45.0",
39
39
  "@bufbuild/protobuf": "^2.14.0",
40
40
  "@dietrichgebert/ponytail": "4.10.0",
41
- "@earendil-works/pi-ai": "^0.85.1",
42
- "@earendil-works/pi-coding-agent": "^0.85.1",
43
- "@earendil-works/pi-tui": "^0.85.1",
44
- "@herbertgao/pi-bark": "0.1.2",
45
- "@herbertgao/pi-cc-extensions": "0.9.4",
46
- "@herbertgao/pi-subagents": "0.18.1",
47
- "@herbertgao/resume-from": "0.2.2",
48
- "@herbertgao/sol-pi": "0.3.1",
41
+ "@earendil-works/pi-ai": "^0.87.1",
42
+ "@earendil-works/pi-coding-agent": "^0.87.1",
43
+ "@earendil-works/pi-tui": "^0.87.1",
44
+ "@herbertgao/pi-bark": "0.1.3",
45
+ "@herbertgao/pi-cc-extensions": "0.9.5",
46
+ "@herbertgao/pi-subagents": "0.18.3",
47
+ "@herbertgao/resume-from": "0.2.3",
48
+ "@herbertgao/sol-pi": "0.3.2",
49
49
  "@juicesharp/rpiv-ask-user-question": "2.11.0",
50
50
  "@juicesharp/rpiv-config": "^2.11.0",
51
51
  "@modelcontextprotocol/client": "2.0.0",
@@ -55,7 +55,7 @@
55
55
  "@modelcontextprotocol/sdk": "^1.29.0",
56
56
  "@mozilla/readability": "^0.6.0",
57
57
  "@napi-rs/keyring": "^1.3.0",
58
- "@narumitw/pi-btw": "0.60.3",
58
+ "@narumitw/pi-btw": "0.61.0",
59
59
  "@narumitw/pi-caffeinate": "0.49.8",
60
60
  "@narumitw/pi-tui-kit": "^0.64.0",
61
61
  "@noble/ed25519": "^3.1.0",
@@ -90,10 +90,10 @@
90
90
  "pi-footer": "0.5.1",
91
91
  "pi-jev-auto-mode": "0.4.1",
92
92
  "pi-lens": "4.2.1",
93
- "pi-mcp-adapter": "2.36.0",
94
- "pi-multi-account": "1.22.0",
95
- "pi-typesafe": "0.6.1",
96
- "pi-web-access": "0.30.0",
93
+ "pi-mcp-adapter": "2.37.0",
94
+ "pi-multi-account": "1.23.1",
95
+ "pi-typesafe": "0.7.4",
96
+ "pi-web-access": "0.31.0",
97
97
  "pidusage": "^4.0.1",
98
98
  "promise.try": "^2.0.1",
99
99
  "proper-lockfile": "4.1.2",