@herbertgao/pi-extensions 2026.9.2 → 2026.9.4

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 (255) hide show
  1. package/README.md +4 -2
  2. package/THIRD_PARTY_NOTICES.md +49 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +3 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +16 -11
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +20 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +12 -7
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +98 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@herbertgao/sol-pi/LICENSE +19 -0
  10. package/node_modules/@herbertgao/sol-pi/README.md +159 -0
  11. package/node_modules/@herbertgao/sol-pi/SECURITY.md +26 -0
  12. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +19 -0
  13. package/node_modules/@herbertgao/sol-pi/agents-install.md +150 -0
  14. package/node_modules/@herbertgao/sol-pi/assets/sol-pi-hero.png +0 -0
  15. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +69 -0
  16. package/node_modules/@herbertgao/sol-pi/docs/configuration.md +75 -0
  17. package/node_modules/@herbertgao/sol-pi/package.json +76 -0
  18. package/node_modules/@herbertgao/sol-pi/scripts/check-pi-compat.mjs +32 -0
  19. package/node_modules/@herbertgao/sol-pi/scripts/check-sol-pi-config.mjs +120 -0
  20. package/node_modules/@herbertgao/sol-pi/sol-pi.example.json +10 -0
  21. package/node_modules/@herbertgao/sol-pi/src/sol-pi/config.ts +135 -0
  22. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/file-queue.ts +74 -0
  23. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/index.ts +185 -0
  24. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/then-run.ts +128 -0
  25. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/archive.ts +53 -0
  26. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/candidate.ts +101 -0
  27. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/config.ts +71 -0
  28. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/index.ts +220 -0
  29. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/journal.ts +25 -0
  30. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/provider.ts +164 -0
  31. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/receipt.ts +177 -0
  32. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/index.ts +227 -0
  33. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/ledger.ts +20 -0
  34. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/observation.ts +252 -0
  35. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/economics.ts +237 -0
  36. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +455 -0
  37. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/index.ts +49 -0
  38. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/plan.ts +79 -0
  39. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/state.ts +208 -0
  40. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/tools.ts +100 -0
  41. package/node_modules/@herbertgao/sol-pi/src/sol-pi/index.ts +42 -0
  42. package/node_modules/@herbertgao/sol-pi/src/sol-pi/runtime-paths.ts +17 -0
  43. package/node_modules/@herbertgao/sol-pi/src/sol-pi/tui.ts +71 -0
  44. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -2
  45. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  46. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  47. package/node_modules/@narumitw/pi-btw/src/btw.ts +13 -2
  48. package/node_modules/pi-antigravity/LICENSE +21 -0
  49. package/node_modules/pi-antigravity/README.md +194 -0
  50. package/node_modules/pi-antigravity/package.json +67 -0
  51. package/node_modules/pi-antigravity/src/auth/index.ts +14 -0
  52. package/node_modules/pi-antigravity/src/auth/oauth.ts +442 -0
  53. package/node_modules/pi-antigravity/src/client/client.ts +561 -0
  54. package/node_modules/pi-antigravity/src/client/index.ts +1 -0
  55. package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  56. package/node_modules/pi-antigravity/src/diagnostics/index.ts +1 -0
  57. package/node_modules/pi-antigravity/src/image/image.ts +336 -0
  58. package/node_modules/pi-antigravity/src/image/index.ts +1 -0
  59. package/node_modules/pi-antigravity/src/index.ts +280 -0
  60. package/node_modules/pi-antigravity/src/models/discovery.ts +154 -0
  61. package/node_modules/pi-antigravity/src/models/grouping.ts +424 -0
  62. package/node_modules/pi-antigravity/src/models/index.ts +3 -0
  63. package/node_modules/pi-antigravity/src/models/models.ts +500 -0
  64. package/node_modules/pi-antigravity/src/stream/index.ts +1 -0
  65. package/node_modules/pi-antigravity/src/stream/stream.ts +1460 -0
  66. package/node_modules/pi-antigravity/src/types/enums.ts +42 -0
  67. package/node_modules/pi-antigravity/src/types/index.ts +2 -0
  68. package/node_modules/pi-antigravity/src/types/types.ts +292 -0
  69. package/node_modules/pi-antigravity/src/usage/index.ts +1 -0
  70. package/node_modules/pi-antigravity/src/usage/usage.ts +371 -0
  71. package/node_modules/pi-antigravity/src/utils/http.ts +91 -0
  72. package/node_modules/pi-antigravity/src/utils/index.ts +3 -0
  73. package/node_modules/pi-antigravity/src/utils/security.ts +73 -0
  74. package/node_modules/pi-antigravity/src/utils/util.ts +132 -0
  75. package/node_modules/pi-antigravity/tsconfig.json +21 -0
  76. package/node_modules/pi-lens/CHANGELOG.md +176 -0
  77. package/node_modules/pi-lens/README.md +13 -8
  78. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +3 -1
  79. package/node_modules/pi-lens/dist/clients/analysed-root.js +1 -0
  80. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +1 -1
  81. package/node_modules/pi-lens/dist/clients/biome-client.js +13 -2
  82. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +2 -0
  83. package/node_modules/pi-lens/dist/clients/cache-observability.js +122 -1
  84. package/node_modules/pi-lens/dist/clients/complexity-client.js +5 -0
  85. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/config-resolve.js +6 -3
  87. package/node_modules/pi-lens/dist/clients/config-schema.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/config-warn.js +2 -2
  89. package/node_modules/pi-lens/dist/clients/dead-code-client.js +8 -1
  90. package/node_modules/pi-lens/dist/clients/dependency-checker.js +5 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +38 -15
  92. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +1 -1
  93. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +5 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +2 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +2 -3
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -3
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +2 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +2 -1
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +2 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +2 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +2 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +2 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +2 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +3 -1
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +2 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +2 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +2 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +2 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +19 -1
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +3 -2
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +2 -1
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +2 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +2 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +2 -1
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +2 -1
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +2 -2
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -1
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -1
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +2 -2
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +3 -2
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +2 -1
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +2 -1
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +2 -1
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +2 -1
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +2 -1
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +4 -3
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +2 -1
  143. package/node_modules/pi-lens/dist/clients/effective-config.js +12 -2
  144. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -0
  145. package/node_modules/pi-lens/dist/clients/file-role.js +18 -1
  146. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +4 -0
  147. package/node_modules/pi-lens/dist/clients/formatters.js +54 -188
  148. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -0
  149. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +42 -6
  150. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +48 -7
  151. package/node_modules/pi-lens/dist/clients/installer/index.js +196 -52
  152. package/node_modules/pi-lens/dist/clients/jscpd-client.js +9 -1
  153. package/node_modules/pi-lens/dist/clients/knip-client.js +9 -2
  154. package/node_modules/pi-lens/dist/clients/language-profile.js +12 -2
  155. package/node_modules/pi-lens/dist/clients/latency-logger.js +2 -0
  156. package/node_modules/pi-lens/dist/clients/lens-config.js +4 -0
  157. package/node_modules/pi-lens/dist/clients/lsp/config.js +36 -7
  158. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +24 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +78 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/index.js +281 -45
  161. package/node_modules/pi-lens/dist/clients/lsp/server.js +62 -15
  162. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +37 -4
  163. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +3 -0
  164. package/node_modules/pi-lens/dist/clients/mcp/session.js +2 -0
  165. package/node_modules/pi-lens/dist/clients/opengrep-client.js +2 -0
  166. package/node_modules/pi-lens/dist/clients/package-manager.js +2 -1
  167. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +8 -3
  168. package/node_modules/pi-lens/dist/clients/pipeline.js +3 -2
  169. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +38 -10
  170. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  171. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  172. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +2 -0
  173. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +2 -0
  174. package/node_modules/pi-lens/dist/clients/ruff-client.js +18 -9
  175. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +9 -0
  176. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +5 -0
  177. package/node_modules/pi-lens/dist/clients/runtime-session.js +13 -4
  178. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +4 -1
  179. package/node_modules/pi-lens/dist/clients/runtime-turn.js +48 -1
  180. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -2
  181. package/node_modules/pi-lens/dist/clients/session-event-guard.js +69 -1
  182. package/node_modules/pi-lens/dist/clients/sg-runner.js +2 -1
  183. package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +108 -0
  184. package/node_modules/pi-lens/dist/clients/test-runner-client.js +291 -76
  185. package/node_modules/pi-lens/dist/clients/tool-config.js +199 -0
  186. package/node_modules/pi-lens/dist/clients/tool-cwd.js +299 -0
  187. package/node_modules/pi-lens/dist/clients/tool-probe.js +53 -0
  188. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +42 -0
  189. package/node_modules/pi-lens/dist/clients/trivy-client.js +9 -1
  190. package/node_modules/pi-lens/dist/clients/turn-context.js +52 -0
  191. package/node_modules/pi-lens/dist/clients/widget-state.js +4 -3
  192. package/node_modules/pi-lens/dist/index.js +21969 -20005
  193. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -2
  194. package/node_modules/pi-lens/dist/mcp/server.js +135 -159
  195. package/node_modules/pi-lens/dist/tools/activate-tools.js +10 -7
  196. package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +2 -13
  197. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +8 -13
  198. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +83 -39
  199. package/node_modules/pi-lens/dist/tools/effective-config.js +4 -3
  200. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +1 -10
  201. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +353 -85
  202. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +21 -110
  203. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +20 -44
  204. package/node_modules/pi-lens/dist/tools/module-report.js +5 -9
  205. package/node_modules/pi-lens/dist/tools/project-report.js +9 -17
  206. package/node_modules/pi-lens/dist/tools/render-compact.js +280 -1
  207. package/node_modules/pi-lens/dist/tools/symbol-search.js +2 -2
  208. package/node_modules/pi-lens/docs/agent-guide.md +3 -4
  209. package/node_modules/pi-lens/docs/agent-tools.md +46 -22
  210. package/node_modules/pi-lens/docs/configuration.md +9 -0
  211. package/node_modules/pi-lens/docs/environment-variables.md +0 -5
  212. package/node_modules/pi-lens/docs/features.md +5 -5
  213. package/node_modules/pi-lens/docs/globalconfig.md +11 -1
  214. package/node_modules/pi-lens/docs/mcp.md +13 -1
  215. package/node_modules/pi-lens/docs/pi-lens-fixer.md +71 -0
  216. package/node_modules/pi-lens/docs/pi-lens-investigator.md +15 -0
  217. package/node_modules/pi-lens/docs/pi-lens-monitor.md +88 -0
  218. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +35 -0
  219. package/node_modules/pi-lens/docs/pi-lens-subagent.md +12 -4
  220. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  221. package/node_modules/pi-lens/docs/real-harness.md +46 -0
  222. package/node_modules/pi-lens/docs/release-qa-baseline.md +5 -1
  223. package/node_modules/pi-lens/docs/servercapabilities.md +1 -6
  224. package/node_modules/pi-lens/docs/settings.md +37 -2
  225. package/node_modules/pi-lens/docs/tools_improvement2.md +4 -4
  226. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +1 -1
  227. package/node_modules/pi-lens/docs/usage.md +13 -3
  228. package/node_modules/pi-lens/package.json +10 -1
  229. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml +7 -3
  230. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +8 -6
  231. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +19 -9
  232. package/node_modules/pi-web-access/CHANGELOG.md +29 -0
  233. package/node_modules/pi-web-access/README.md +33 -22
  234. package/node_modules/pi-web-access/abortable.ts +17 -0
  235. package/node_modules/pi-web-access/crawl4ai.ts +204 -0
  236. package/node_modules/pi-web-access/credential-source.ts +1 -0
  237. package/node_modules/pi-web-access/curator-page.ts +20 -2
  238. package/node_modules/pi-web-access/curator-run.ts +44 -0
  239. package/node_modules/pi-web-access/curator-server.ts +3 -1
  240. package/node_modules/pi-web-access/duckduckgo.ts +1 -1
  241. package/node_modules/pi-web-access/extract.ts +57 -12
  242. package/node_modules/pi-web-access/fetch-params.ts +1 -1
  243. package/node_modules/pi-web-access/gemini-search.ts +9 -5
  244. package/node_modules/pi-web-access/index.ts +25 -18
  245. package/node_modules/pi-web-access/openai-search.ts +35 -13
  246. package/node_modules/pi-web-access/package.json +2 -2
  247. package/node_modules/pi-web-access/page-query.ts +5 -2
  248. package/node_modules/pi-web-access/query-rewrite.ts +5 -2
  249. package/node_modules/pi-web-access/serpapi.ts +220 -0
  250. package/node_modules/pi-web-access/ssrf-protection.ts +5 -1
  251. package/node_modules/pi-web-access/storage.ts +10 -0
  252. package/node_modules/pi-web-access/summary-review.ts +34 -21
  253. package/node_modules/pi-web-access/utils.ts +6 -4
  254. package/package.json +11 -5
  255. package/node_modules/pi-lens/dist/tools/ast-dump.js +0 -103
@@ -2,7 +2,7 @@ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-
2
2
  import { Box, Text, truncateToWidth, type KeyId } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import pLimit from "p-limit";
5
- import { StringEnum, type ImageContent, type TextContent } from "@earendil-works/pi-ai/compat";
5
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai/compat";
6
6
  import type { ExtractedContent, ExtractOptions } from "./extract.ts";
7
7
  import { normalizeFetchContentParams } from "./fetch-params.ts";
8
8
  import { resolveAuthFetchProfile, type AuthFetchProfile } from "./auth-fetch.ts";
@@ -69,11 +69,13 @@ import { isMistralAvailable } from "./mistral-search.ts";
69
69
  import { isKimiSearchAvailable } from "./kimi-search.ts";
70
70
  import { isBrightDataAvailable } from "./brightdata.ts";
71
71
  import { isSerpBaseAvailable } from "./serpbase.ts";
72
+ import { isSerpApiAvailable } from "./serpapi.ts";
72
73
  import { isSerperAvailable } from "./serper.ts";
73
74
  import { isValyuAvailable } from "./valyu.ts";
74
75
  import { isXcrawlAvailable } from "./xcrawl.ts";
75
76
  import { buildSearchErrorPlan, type SearchErrorDetails, type SearchErrorPlan } from "./render-search-error.ts";
76
77
  import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns, splitThinkingSuffix } from "./summary-model-scope.ts";
78
+ import { registerCuratorRunLifecycle, resolveWebSearchWorkflow, type WebSearchWorkflow } from "./curator-run.ts";
77
79
  import {
78
80
  buildResearchArtifact,
79
81
  withClaimAssessment,
@@ -83,6 +85,16 @@ import {
83
85
  type ResearchArtifact,
84
86
  } from "./source-check.ts";
85
87
 
88
+ // Match pi-ai's StringEnum without loading its compat barrel during registration.
89
+ function StringEnum<T extends string[]>(values: T, options?: { description?: string; default?: T[number] }) {
90
+ return Type.Unsafe<T[number]>({
91
+ type: "string",
92
+ enum: values,
93
+ ...(options?.description && { description: options.description }),
94
+ ...(options?.default && { default: options.default }),
95
+ });
96
+ }
97
+
86
98
  type ExtensionTheme = ExtensionContext["ui"]["theme"];
87
99
 
88
100
  const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
@@ -139,6 +151,7 @@ interface WebSearchConfig {
139
151
  kagiApiKey?: unknown;
140
152
  ollamaApiKey?: unknown;
141
153
  serpbaseApiKey?: unknown;
154
+ serpapiApiKey?: unknown;
142
155
  serperApiKey?: unknown;
143
156
  tinyfishApiKey?: unknown;
144
157
  valyuApiKey?: unknown;
@@ -170,7 +183,6 @@ interface WebSearchConfig {
170
183
  };
171
184
  }
172
185
 
173
- type WebSearchWorkflow = "none" | "summary-review" | "auto-summary";
174
186
  type CuratorWorkflow = "summary-review";
175
187
  export type CuratorProvider = Exclude<SearchProvider, "auto">;
176
188
  type SummaryWorkflow = "summary-review" | "auto-summary";
@@ -347,14 +359,6 @@ function normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {
347
359
  return Math.min(normalized, MAX_CURATOR_TIMEOUT_SECONDS);
348
360
  }
349
361
 
350
- function resolveWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {
351
- const normalized = typeof input === "string" ? input.trim().toLowerCase() : "";
352
- if (normalized === "auto-summary") return "auto-summary";
353
- if (!hasUI) return "none";
354
- if (normalized === "none") return "none";
355
- return "summary-review";
356
- }
357
-
358
362
  function normalizeQueryList(queryList: unknown[]): string[] {
359
363
  const normalized: string[] = [];
360
364
  for (const query of queryList) {
@@ -445,6 +449,7 @@ async function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderA
445
449
  mistral: isMistralAvailable(),
446
450
  brightdata: isBrightDataAvailable(),
447
451
  serpbase: isSerpBaseAvailable(),
452
+ serpapi: isSerpApiAvailable(),
448
453
  serper: isSerperAvailable(),
449
454
  valyu: isValyuAvailable(),
450
455
  };
@@ -1058,6 +1063,7 @@ function handleSessionChange(ctx: ExtensionContext): void {
1058
1063
 
1059
1064
  export default function (pi: ExtensionAPI) {
1060
1065
  const initConfig = loadConfigForExtensionInit();
1066
+ const curatorRunState = registerCuratorRunLifecycle(pi);
1061
1067
  installGlobalProxyFetch();
1062
1068
  const toolNames = resolveToolNames(initConfig);
1063
1069
  const webSearchEnabled = isToolEnabled(initConfig, "webSearch");
@@ -1559,6 +1565,7 @@ export default function (pi: ExtensionAPI) {
1559
1565
  },
1560
1566
  onSubmit(payload) {
1561
1567
  if (pendingCurates.get(callId) !== pc) return;
1568
+ if (payload.autoApproveRemainingSearches) curatorRunState.approveRemainingSearches();
1562
1569
  searchAbort.abort();
1563
1570
  const filtered = payload.selectedQueryIndices.length > 0
1564
1571
  ? filterByQueryIndices(payload.selectedQueryIndices, pc.searchResults)
@@ -1783,7 +1790,7 @@ export default function (pi: ExtensionAPI) {
1783
1790
  name: toolNames.webSearch,
1784
1791
  label: "Web Search",
1785
1792
  description:
1786
- `Search the web using OpenAI, Brave, Parallel, Parallel MCP, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, SearXNG, DuckDuckGo, Exa, Perplexity, Gemini, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, or Serper. Pass a provider array to search only those providers simultaneously, or use provider "all" to search every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, and Serper. Returns an AI-synthesized answer with source citations. OpenAI search uses a Codex subscription or OpenAI API key; Kimi search uses a Kimi Code Plan authenticated through /login kimi-coding; xAI search uses a SuperGrok/X Premium subscription or xAI API key; Mistral search uses a Mistral API key. Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, and Serper are available only when explicitly selected. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to "none" to skip curation or "auto-summary" for a model-generated summary without the browser curator. The configured provider is used when provider is omitted or set to auto; omit provider unless explicitly overriding it. Without a configured provider, SearXNG is preferred first for local/private search. When the active Pi model is openai-codex, Codex-backed OpenAI search is preferred next. Otherwise Exa is preferred before OpenAI, then Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, Perplexity, Gemini API, or Gemini Web.`,
1793
+ `Search the web using OpenAI, Brave, Parallel, Parallel MCP, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, SearXNG, DuckDuckGo, Exa, Perplexity, Gemini, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, or Serper. Pass a provider array to search only those providers simultaneously, or use provider "all" to search every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, and Serper. Returns an AI-synthesized answer with source citations. OpenAI search uses a Codex subscription or OpenAI API key; Kimi search uses a Kimi Code Plan authenticated through /login kimi-coding; xAI search uses a SuperGrok/X Premium subscription or xAI API key; Mistral search uses a Mistral API key. Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, and Serper are available only when explicitly selected. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to "none" to skip curation or "auto-summary" for a model-generated summary without the browser curator. The configured provider is used when provider is omitted or set to auto; omit provider unless explicitly overriding it. Without a configured provider, SearXNG is preferred first for local/private search. When the active Pi model is openai-codex, Codex-backed OpenAI search is preferred next. Otherwise Exa is preferred before OpenAI, then Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Jina, SERPdive, Kagi, Bocha, Ollama, Perplexity, Gemini API, or Gemini Web.`,
1787
1794
  promptSnippet:
1788
1795
  "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.",
1789
1796
  parameters: Type.Object({
@@ -1795,14 +1802,14 @@ export default function (pi: ExtensionAPI) {
1795
1802
  StringEnum(["day", "week", "month", "year"], { description: "Filter by recency" }),
1796
1803
  ),
1797
1804
  domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })),
1798
- provider: Type.Optional(searchProviderSchema("Search provider or non-empty list of providers to search simultaneously; use all to search every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, and Serper, omit this field to use the configured provider, or use auto when none is configured")),
1805
+ provider: Type.Optional(searchProviderSchema("Search provider or non-empty list of providers to search simultaneously; use all to search every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, and Serper, omit this field to use the configured provider, or use auto when none is configured")),
1799
1806
  workflow: Type.Optional(
1800
1807
  StringEnum(["none", "summary-review", "auto-summary"], {
1801
1808
  description: "Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default), auto-summary = generate summary without opening curator",
1802
1809
  }),
1803
1810
  ),
1804
1811
  proxy: Type.Optional(Type.String({
1805
- description: "http(s) proxy URL (e.g. http://host:port) used for every outbound request in this call (search APIs and content fetches). Node fetch ignores HTTP(S)_PROXY env vars, so set this (or `proxy` in web-search.json) when direct access is blocked; empty string forces direct access.",
1812
+ description: "http(s) or socks proxy URL (e.g. http://host:port or socks5h://host:port) used for every outbound request in this call (search APIs and content fetches). Node fetch ignores HTTP(S)_PROXY env vars, so set this (or `proxy` in web-search.json) when direct access is blocked; empty string forces direct access.",
1806
1813
  })),
1807
1814
  }),
1808
1815
 
@@ -1813,7 +1820,7 @@ export default function (pi: ExtensionAPI) {
1813
1820
  : (params.query !== undefined ? expandQueryString(params.query) : []);
1814
1821
  const queryList = normalizeQueryList(rawQueryList);
1815
1822
  const configWorkflow = loadConfigForExtensionInit().workflow;
1816
- const workflow = resolveWorkflow(params.workflow ?? configWorkflow, ctx?.hasUI !== false);
1823
+ const workflow = curatorRunState.resolve(params.workflow, configWorkflow, ctx?.hasUI !== false);
1817
1824
  const shouldCurate = workflow === "summary-review";
1818
1825
  const recencyFilter = normalizeRecencyFilter(params.recencyFilter);
1819
1826
 
@@ -2389,9 +2396,9 @@ export default function (pi: ExtensionAPI) {
2389
2396
  fetchContent: Type.Optional(Type.Boolean({ description: "Fetch up to 5 result pages for exact passage extraction." })),
2390
2397
  recencyFilter: Type.Optional(StringEnum(["day", "week", "month", "year"], { description: "Filter by recency." })),
2391
2398
  domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains; prefix with - to exclude." })),
2392
- provider: Type.Optional(searchProviderSchema("Search provider or non-empty list of providers to search simultaneously; all searches every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, and Serper")),
2399
+ provider: Type.Optional(searchProviderSchema("Search provider or non-empty list of providers to search simultaneously; all searches every eligible provider except Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, Valyu, xAI, Mistral, Bright Data, SerpBase, SerpApi, and Serper")),
2393
2400
  proxy: Type.Optional(Type.String({
2394
- description: "http(s) proxy URL (e.g. http://host:port) used for every outbound request in this call (search APIs and result-page fetches). Empty string forces direct access.",
2401
+ description: "http(s) or socks proxy URL (e.g. http://host:port or socks5h://host:port) used for every outbound request in this call (search APIs and result-page fetches). Empty string forces direct access.",
2395
2402
  })),
2396
2403
  }),
2397
2404
  async execute(_callId, params, signal, _onUpdate, ctx) {
@@ -2512,7 +2519,7 @@ export default function (pi: ExtensionAPI) {
2512
2519
  description: "Opt into an authFetch profile for local browser-cookie fetching. Use a profile name, or true only when exactly one profile exists.",
2513
2520
  })),
2514
2521
  proxy: Type.Optional(Type.String({
2515
- description: "http(s) proxy URL (e.g. http://host:port) used for this fetch. Needed when the target is unreachable directly; localhost and NO_PROXY hosts always bypass the proxy. Empty string forces direct access.",
2522
+ description: "http(s) or socks proxy URL (e.g. http://host:port or socks5h://host:port) used for this fetch. Needed when the target is unreachable directly; localhost and NO_PROXY hosts always bypass the proxy. Empty string forces direct access.",
2516
2523
  })),
2517
2524
  }),
2518
2525
 
@@ -3424,7 +3431,7 @@ export default function (pi: ExtensionAPI) {
3424
3431
 
3425
3432
  let newWorkflow: WebSearchWorkflow;
3426
3433
  if (arg.length === 0) {
3427
- const current = resolveWorkflow(loadConfigForExtensionInit().workflow, true);
3434
+ const current = resolveWebSearchWorkflow(loadConfigForExtensionInit().workflow, true);
3428
3435
  newWorkflow = current === "none" ? "summary-review" : "none";
3429
3436
  } else if (arg === "on") {
3430
3437
  newWorkflow = "summary-review";
@@ -41,6 +41,8 @@ interface WebSearchConfig {
41
41
 
42
42
  type ProviderHeaders = Record<string, string | null>;
43
43
 
44
+ class CustomOpenAIBaseUrlError extends Error {}
45
+
44
46
  interface OpenAIAuth {
45
47
  provider: string;
46
48
  apiKey: string;
@@ -215,7 +217,7 @@ function toRequestHeaders(headers: ProviderHeaders): Record<string, string> {
215
217
  return requestHeaders;
216
218
  }
217
219
 
218
- async function resolvePiAuth(ctx: ExtensionContext, responsesUrl: string, providers: readonly string[], modelOverride?: string): Promise<OpenAIAuth | undefined> {
220
+ async function resolvePiAuth(ctx: ExtensionContext, responsesUrl: string, providers: readonly string[], modelOverride?: string, hasExplicitResponsesUrl = false): Promise<OpenAIAuth | undefined> {
219
221
  let models: ReturnType<typeof ctx.modelRegistry.getAll>;
220
222
  try {
221
223
  models = ctx.modelRegistry.getAll();
@@ -225,19 +227,34 @@ async function resolvePiAuth(ctx: ExtensionContext, responsesUrl: string, provid
225
227
  for (const provider of providers) {
226
228
  const preferred = pickSearchModel(models.filter((model) => model.provider === provider));
227
229
  if (!preferred) continue;
230
+ let resolved: Awaited<ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>>;
228
231
  try {
229
- const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(preferred);
230
- if (resolved.ok && resolved.apiKey) {
231
- return {
232
- provider,
233
- apiKey: resolved.apiKey,
234
- model: modelOverride ?? preferred.id,
235
- headers: resolved.headers ?? {},
236
- responsesUrl,
237
- };
238
- }
232
+ resolved = await ctx.modelRegistry.getApiKeyAndHeaders(preferred);
239
233
  } catch {
234
+ continue;
235
+ }
236
+ if (!resolved.ok || !resolved.apiKey) continue;
237
+ // Auth can override the model's base URL. Do not guess Responses/web_search
238
+ // support from a gateway URL, or pair its credential with the official API.
239
+ const baseUrl = resolved.baseUrl ?? preferred.baseUrl;
240
+ const useCodexEndpoint = provider === "openai-codex" || isCodexJwt(resolved.apiKey);
241
+ if (!hasExplicitResponsesUrl && !useCodexEndpoint && baseUrl !== undefined) {
242
+ let isOfficial = false;
243
+ try {
244
+ isOfficial = new URL(baseUrl).toString().replace(/\/+$/u, "") === "https://api.openai.com/v1";
245
+ } catch {
246
+ }
247
+ if (!isOfficial) {
248
+ throw new CustomOpenAIBaseUrlError(`OpenAI web search cannot reuse Pi credentials with a custom baseUrl by default. Set openaiResponsesUrl in ${CONFIG_PATH} to the full Responses endpoint for this credential.`);
249
+ }
240
250
  }
251
+ return {
252
+ provider,
253
+ apiKey: resolved.apiKey,
254
+ model: modelOverride ?? preferred.id,
255
+ headers: resolved.headers ?? {},
256
+ responsesUrl,
257
+ };
241
258
  }
242
259
  return undefined;
243
260
  }
@@ -248,7 +265,7 @@ export async function resolveOpenAIAuth(ctx?: ExtensionContext, signal?: AbortSi
248
265
  const modelOverride = resolveConfiguredSearchModel(config.openaiSearchModel);
249
266
  const providers = resolveConfiguredSearchProviders(config.openaiSearchProviders);
250
267
  if (ctx) {
251
- const auth = await resolvePiAuth(ctx, responsesUrl, providers, modelOverride);
268
+ const auth = await resolvePiAuth(ctx, responsesUrl, providers, modelOverride, config.openaiResponsesUrl !== undefined);
252
269
  if (auth) return auth;
253
270
  }
254
271
 
@@ -273,7 +290,12 @@ export async function isOpenAISearchAvailable(ctx?: ExtensionContext): Promise<b
273
290
  const config = loadConfig();
274
291
  const responsesUrl = resolveConfiguredResponsesUrl(config.openaiResponsesUrl);
275
292
  const providers = resolveConfiguredSearchProviders(config.openaiSearchProviders);
276
- if (ctx && await resolvePiAuth(ctx, responsesUrl, providers)) return true;
293
+ try {
294
+ if (ctx && await resolvePiAuth(ctx, responsesUrl, providers, undefined, config.openaiResponsesUrl !== undefined)) return true;
295
+ } catch (err) {
296
+ if (err instanceof CustomOpenAIBaseUrlError) return false;
297
+ throw err;
298
+ }
277
299
  return hasCredentialSource({
278
300
  provider: "OpenAI",
279
301
  configuredValue: config.openaiApiKey,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-web-access",
3
- "version": "0.28.0",
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, Jina, SERPdive, Kagi, Ollama, AnySearch, Bright Data SERP, SerpBase, SearXNG, Exa, Perplexity, Gemini, Kimi, and Mistral.",
3
+ "version": "0.29.0",
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, Jina, SERPdive, Kagi, Ollama, AnySearch, Bright Data SERP, SerpBase, SerpApi, SearXNG, Exa, Perplexity, Gemini, Kimi, and Mistral.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "node --test",
@@ -1,8 +1,9 @@
1
- import { complete, type Api, type Message, type Model } from "@earendil-works/pi-ai/compat";
1
+ import type { complete, Api, Message, Model } from "@earendil-works/pi-ai/compat";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.ts";
5
5
  import { getWebSearchConfigPath } from "./utils.ts";
6
+ import { awaitWithAbort } from "./abortable.ts";
6
7
 
7
8
  const OUTPUT_TOKENS = 2_000;
8
9
  const INPUT_CONTEXT_FRACTION = 0.6;
@@ -111,7 +112,9 @@ export async function answerFromPage(
111
112
  if (!auth.ok || !auth.apiKey) throw new Error(`No API key available for answer model ${model.provider}/${model.id}`);
112
113
  const registry = ctx.modelRegistry as typeof ctx.modelRegistry & { complete?: typeof complete };
113
114
  const usesRegistryComplete = typeof registry.complete === "function";
114
- const completeFn = usesRegistryComplete ? registry.complete!.bind(registry) : complete;
115
+ if (signal?.aborted) throw new Error("Aborted");
116
+ const completeFn = usesRegistryComplete ? registry.complete!.bind(registry) : (await awaitWithAbort(import("@earendil-works/pi-ai/compat"), signal)).complete;
117
+ if (signal?.aborted) throw new Error("Aborted");
115
118
 
116
119
  const contextTokens = model.contextWindow > 0 ? model.contextWindow : FALLBACK_CONTEXT_TOKENS;
117
120
  const maximumInputTokens = Math.max(1, Math.min(
@@ -1,5 +1,6 @@
1
- import { complete, type Api, type Model, type ProviderHeaders } from "@earendil-works/pi-ai/compat";
1
+ import type { complete, Api, Model, ProviderHeaders } from "@earendil-works/pi-ai/compat";
2
2
  import type { SummaryGenerationContext } from "./summary-review.ts";
3
+ import { awaitWithAbort } from "./abortable.ts";
3
4
  import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.ts";
4
5
 
5
6
  async function resolveFirstAvailableModel(
@@ -28,7 +29,9 @@ export async function rewriteSearchQuery(
28
29
  ]);
29
30
  const registry = ctx.modelRegistry as typeof ctx.modelRegistry & { complete?: typeof complete };
30
31
  const usesRegistryComplete = typeof registry.complete === "function";
31
- const completeFn = usesRegistryComplete ? registry.complete!.bind(registry) : complete;
32
+ if (signal.aborted) throw new Error("Aborted");
33
+ const completeFn = usesRegistryComplete ? registry.complete!.bind(registry) : (await awaitWithAbort(import("@earendil-works/pi-ai/compat"), signal)).complete;
34
+ if (signal.aborted) throw new Error("Aborted");
32
35
  const response = await completeFn(
33
36
  model,
34
37
  {
@@ -0,0 +1,220 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.ts";
3
+ import type { SearchOptions, SearchResponse } from "./perplexity.ts";
4
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
5
+ import { getWebSearchConfigPath } from "./utils.ts";
6
+
7
+ const SERPAPI_SEARCH_URL = "https://serpapi.com/search.json";
8
+ const CONFIG_PATH = getWebSearchConfigPath();
9
+ const SEARCH_TIMEOUT_MS = 60_000;
10
+ const RECENCY_TBS: Record<NonNullable<SearchOptions["recencyFilter"]>, string> = {
11
+ day: "qdr:d",
12
+ week: "qdr:w",
13
+ month: "qdr:m",
14
+ year: "qdr:y",
15
+ };
16
+
17
+ interface WebSearchConfig {
18
+ serpapiApiKey?: unknown;
19
+ }
20
+
21
+ interface SerpApiOrganicResult {
22
+ title?: unknown;
23
+ link?: unknown;
24
+ snippet?: unknown;
25
+ }
26
+
27
+ let cachedConfig: WebSearchConfig | null = null;
28
+
29
+ function loadConfig(): WebSearchConfig {
30
+ if (cachedConfig) return cachedConfig;
31
+ if (!existsSync(CONFIG_PATH)) {
32
+ cachedConfig = {};
33
+ return cachedConfig;
34
+ }
35
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
36
+ let parsed: unknown;
37
+ try {
38
+ parsed = JSON.parse(raw);
39
+ } catch (err) {
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
42
+ }
43
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
44
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
45
+ }
46
+ cachedConfig = parsed as WebSearchConfig;
47
+ return cachedConfig;
48
+ }
49
+
50
+ async function getApiKey(signal?: AbortSignal): Promise<string | null> {
51
+ return resolveCredential({
52
+ provider: "SerpApi",
53
+ configuredValue: loadConfig().serpapiApiKey,
54
+ environmentValue: process.env.SERPAPI_KEY,
55
+ signal,
56
+ });
57
+ }
58
+
59
+ async function requireApiKey(signal?: AbortSignal): Promise<string> {
60
+ const apiKey = await getApiKey(signal);
61
+ if (!apiKey) {
62
+ throw new Error(
63
+ "SerpApi API key not found. Either:\n" +
64
+ ` 1. Create ${CONFIG_PATH} with { "serpapiApiKey": "your-key" }\n` +
65
+ " 2. Set SERPAPI_KEY environment variable\n" +
66
+ "Get a key at https://serpapi.com/manage-api-key",
67
+ );
68
+ }
69
+ return apiKey;
70
+ }
71
+
72
+ function normalizeCount(value: number | undefined): number {
73
+ if (typeof value !== "number" || !Number.isFinite(value)) return 5;
74
+ return Math.max(1, Math.min(Math.floor(value), 20));
75
+ }
76
+
77
+ interface DomainFilters {
78
+ include: string[];
79
+ exclude: string[];
80
+ }
81
+
82
+ function normalizeDomain(value: string): string | null {
83
+ let input = value.trim().toLowerCase();
84
+ if (!input) return null;
85
+ if (input.startsWith("-")) input = input.slice(1).trim();
86
+ if (!input) return null;
87
+ try {
88
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
89
+ input = parsed.hostname;
90
+ } catch {
91
+ input = input.split("/")[0]?.split(":")[0] ?? "";
92
+ }
93
+ input = input.replace(/^\.+|\.+$/g, "");
94
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
95
+ }
96
+
97
+ function parseDomainFilter(domainFilter: string[] | undefined): DomainFilters {
98
+ const filters: DomainFilters = { include: [], exclude: [] };
99
+ for (const raw of domainFilter ?? []) {
100
+ const domain = normalizeDomain(raw);
101
+ if (!domain) continue;
102
+ const target = raw.trim().startsWith("-") ? filters.exclude : filters.include;
103
+ if (!target.includes(domain)) target.push(domain);
104
+ }
105
+ return filters;
106
+ }
107
+
108
+ function passesDomainFilters(url: string, filters: DomainFilters): boolean {
109
+ if (filters.include.length === 0 && filters.exclude.length === 0) return true;
110
+ let hostname: string;
111
+ try {
112
+ hostname = new URL(url).hostname.toLowerCase();
113
+ } catch {
114
+ return false;
115
+ }
116
+ const matches = (domain: string) => hostname === domain || hostname.endsWith(`.${domain}`);
117
+ if (filters.exclude.some(matches)) return false;
118
+ return filters.include.length === 0 || filters.include.some(matches);
119
+ }
120
+
121
+ function buildQuery(query: string, filters: DomainFilters): string {
122
+ const parts = [query];
123
+ if (filters.include.length === 1) parts.push(`site:${filters.include[0]}`);
124
+ if (filters.include.length > 1) parts.push(`(${filters.include.map(domain => `site:${domain}`).join(" OR ")})`);
125
+ for (const domain of filters.exclude) parts.push(`-site:${domain}`);
126
+ return parts.join(" ");
127
+ }
128
+
129
+ function errorMessage(err: unknown): string {
130
+ return err instanceof Error ? err.message : String(err);
131
+ }
132
+
133
+ function invalidResponse(message: string): Error {
134
+ return new Error(`SerpApi returned invalid response: ${message}`);
135
+ }
136
+
137
+ function parseResponse(value: unknown): SerpApiOrganicResult[] {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("expected an object envelope");
139
+ const envelope = value as Record<string, unknown>;
140
+ if (typeof envelope.error === "string" && envelope.error.trim()) throw invalidResponse(envelope.error.trim());
141
+ if (!Array.isArray(envelope.organic_results)) throw invalidResponse("expected organic_results array");
142
+ return envelope.organic_results as SerpApiOrganicResult[];
143
+ }
144
+
145
+ function buildAnswer(results: SearchResponse["results"]): string {
146
+ return results.map((result) => result.snippet
147
+ ? `${result.snippet}\nSource: ${result.title} (${result.url})`
148
+ : `Source: ${result.title} (${result.url})`).join("\n\n");
149
+ }
150
+
151
+ export function isSerpApiAvailable(): boolean {
152
+ return hasCredentialSource({ provider: "SerpApi", configuredValue: loadConfig().serpapiApiKey, environmentValue: process.env.SERPAPI_KEY });
153
+ }
154
+
155
+ export async function searchWithSerpApi(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
156
+ const apiKey = await requireApiKey(options.signal);
157
+ const numResults = normalizeCount(options.numResults);
158
+ const filters = parseDomainFilter(options.domainFilter);
159
+ const requestCount = options.domainFilter?.length ? Math.min(20, numResults + 5) : numResults;
160
+ const url = new URL(SERPAPI_SEARCH_URL);
161
+ url.searchParams.set("engine", "google");
162
+ url.searchParams.set("q", buildQuery(query, filters));
163
+ url.searchParams.set("api_key", apiKey);
164
+ url.searchParams.set("num", String(requestCount));
165
+ if (options.recencyFilter) url.searchParams.set("tbs", RECENCY_TBS[options.recencyFilter]);
166
+ const activityId = activityMonitor.logStart({ type: "api", query });
167
+ const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
168
+ let response: Response;
169
+ let entries: SerpApiOrganicResult[];
170
+ try {
171
+ response = await fetch(url, {
172
+ headers: { Accept: "application/json" },
173
+ signal: options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal,
174
+ });
175
+ if (!response.ok) {
176
+ const errorText = await response.text();
177
+ throw new Error(`SerpApi error ${response.status}: ${redactCredential(errorText, apiKey).slice(0, 300)}`);
178
+ }
179
+ let rawData: unknown;
180
+ try {
181
+ rawData = await response.json();
182
+ } catch (err) {
183
+ if (err instanceof Error && err.name === "TimeoutError") throw err;
184
+ throw new Error(`SerpApi returned invalid JSON: ${errorMessage(err)}`);
185
+ }
186
+ entries = parseResponse(rawData);
187
+ } catch (err) {
188
+ if (options.signal?.aborted) {
189
+ activityMonitor.logComplete(activityId, 0);
190
+ throw new Error("Aborted");
191
+ }
192
+ const message = errorMessage(err);
193
+ const providerTimeout = timeoutSignal.aborted || (err instanceof Error && err.name === "TimeoutError");
194
+ const outgoing = providerTimeout
195
+ ? new Error(`SerpApi request timed out after ${Math.round(SEARCH_TIMEOUT_MS / 1000)}s`)
196
+ : (() => {
197
+ const redactedMessage = redactCredential(message, apiKey);
198
+ if (redactedMessage === message && err instanceof Error) return err;
199
+ const redactedError = new Error(redactedMessage);
200
+ if (err instanceof Error) redactedError.name = err.name;
201
+ return redactedError;
202
+ })();
203
+ activityMonitor.logError(activityId, redactCredential(errorMessage(outgoing), apiKey));
204
+ throw outgoing;
205
+ }
206
+ activityMonitor.logComplete(activityId, response.status);
207
+ const results: SearchResponse["results"] = [];
208
+ for (const entry of entries) {
209
+ if (!entry || typeof entry !== "object") continue;
210
+ if (typeof entry.link !== "string" || !entry.link) continue;
211
+ if (!passesDomainFilters(entry.link, filters)) continue;
212
+ results.push({
213
+ title: typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : `Source ${results.length + 1}`,
214
+ url: entry.link,
215
+ snippet: typeof entry.snippet === "string" ? entry.snippet : "",
216
+ });
217
+ if (results.length >= numResults) break;
218
+ }
219
+ return { answer: buildAnswer(results), results };
220
+ }
@@ -232,6 +232,7 @@ export async function fetchRemoteUrl(
232
232
  const fetchImpl = options.fetch ?? fetch;
233
233
  const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
234
234
  let current = await validateRemoteUrl(url, options);
235
+ const configuredOrigin = current.origin;
235
236
  let requestInit = init;
236
237
 
237
238
  for (let redirects = 0; redirects <= maxRedirects; redirects++) {
@@ -243,7 +244,10 @@ export async function fetchRemoteUrl(
243
244
  if (redirects === maxRedirects) throw new Error(`Too many redirects fetching ${current.toString()}`);
244
245
 
245
246
  const from = current;
246
- current = await validateRemoteUrl(new URL(location, current), options);
247
+ const next = new URL(location, current);
248
+ // allowLoopback exempts an explicitly configured endpoint, never a redirect target: a loopback
249
+ // base must not be able to pivot the request onto a different loopback origin.
250
+ current = await validateRemoteUrl(next, next.origin === configuredOrigin ? options : { ...options, allowLoopback: false });
247
251
  if (response.status === 303 || ((response.status === 301 || response.status === 302) && requestInit.method?.toUpperCase() === "POST")) {
248
252
  const { body: _body, ...nextInit } = requestInit;
249
253
  requestInit = { ...nextInit, method: "GET" };
@@ -426,8 +426,17 @@ function readCachedFetchData(data: StoredSearchData, now = Date.now()): StoredSe
426
426
  }
427
427
  }
428
428
 
429
+ function pruneExpiredFetchedResults(now: number): void {
430
+ for (const [id, data] of storedResults) {
431
+ if (data.type === "fetch" && now - data.timestamp >= CACHE_TTL_MS) {
432
+ storedResults.set(id, unavailableFetchData(data, "Cached fetched content is missing or expired"));
433
+ }
434
+ }
435
+ }
436
+
429
437
  export function pruneExpiredFetchCache(now = Date.now(), requestedLimits?: Partial<FetchCacheLimits>): void {
430
438
  const limits = cacheLimits(requestedLimits);
439
+ pruneExpiredFetchedResults(now);
431
440
  try { pruneFetchCache(now, limits); } catch {}
432
441
  }
433
442
 
@@ -436,6 +445,7 @@ export function storeResult(id: string, data: StoredSearchData): void {
436
445
  }
437
446
 
438
447
  export function storeFetchedContentResult(id: string, data: StoredSearchData & { type: "fetch"; urls: ExtractedContent[] }): StoredSearchData {
448
+ pruneExpiredFetchedResults(Date.now());
439
449
  let ref: FetchCacheRef | null = null;
440
450
  let cacheError: string | undefined;
441
451
  try {