@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
@@ -0,0 +1,204 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import net from "node:net";
3
+ import { activityMonitor } from "./activity.ts";
4
+ import { redactCredential, resolveCredential } from "./credential-source.ts";
5
+ import type { ExtractedContent, ExtractOptions } from "./extract.ts";
6
+ import { fetchRemoteUrl, loadSsrfConfig, validateRemoteUrl, type Lookup, type SsrfConfig } from "./ssrf-protection.ts";
7
+ import { getWebSearchConfigPath } from "./utils.ts";
8
+
9
+ const CONFIG_PATH = getWebSearchConfigPath();
10
+ const EXTRACT_TIMEOUT_MS = 60_000;
11
+ const MARKDOWN_FILTER = "fit";
12
+
13
+ export interface Crawl4aiExtractOptions extends Pick<ExtractOptions, "timeoutMs" | "lookup"> {
14
+ ssrf?: SsrfConfig;
15
+ }
16
+
17
+ interface Crawl4aiConfig {
18
+ crawl4aiBaseUrl?: unknown;
19
+ crawl4aiApiToken?: unknown;
20
+ }
21
+
22
+ let cachedConfig: Crawl4aiConfig | null = null;
23
+
24
+ function loadConfig(): Crawl4aiConfig {
25
+ if (cachedConfig) return cachedConfig;
26
+ if (!existsSync(CONFIG_PATH)) {
27
+ cachedConfig = {};
28
+ return cachedConfig;
29
+ }
30
+ const raw = readFileSync(CONFIG_PATH, "utf8");
31
+ let parsed: unknown;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ } catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
37
+ }
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
40
+ }
41
+ cachedConfig = parsed as Crawl4aiConfig;
42
+ return cachedConfig;
43
+ }
44
+
45
+ export function clearCrawl4aiConfigCache(): void {
46
+ cachedConfig = null;
47
+ }
48
+
49
+ function normalizeBaseUrl(value: unknown): string | null {
50
+ if (typeof value !== "string") return null;
51
+ const trimmed = value.trim();
52
+ if (!trimmed) return null;
53
+ let parsed: URL;
54
+ try {
55
+ parsed = new URL(trimmed);
56
+ } catch {
57
+ throw new Error(`Invalid Crawl4AI base URL in ${CONFIG_PATH}: expected an HTTP or HTTPS URL`);
58
+ }
59
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
60
+ throw new Error(`Invalid Crawl4AI base URL in ${CONFIG_PATH}: expected an HTTP or HTTPS URL`);
61
+ }
62
+ if (parsed.username || parsed.password) {
63
+ throw new Error(`Invalid Crawl4AI base URL in ${CONFIG_PATH}: URL credentials are not allowed`);
64
+ }
65
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
66
+ parsed.search = "";
67
+ parsed.hash = "";
68
+ return parsed.toString().replace(/\/+$/, "");
69
+ }
70
+
71
+ function getBaseUrl(): string | null {
72
+ return normalizeBaseUrl(process.env.CRAWL4AI_BASE_URL) ?? normalizeBaseUrl(loadConfig().crawl4aiBaseUrl);
73
+ }
74
+
75
+ function requireBaseUrl(): string {
76
+ const baseUrl = getBaseUrl();
77
+ if (!baseUrl) {
78
+ throw new Error(
79
+ "Crawl4AI base URL not configured. Either:\n" +
80
+ ` 1. Set crawl4aiBaseUrl in ${CONFIG_PATH}\n` +
81
+ " 2. Set CRAWL4AI_BASE_URL environment variable",
82
+ );
83
+ }
84
+ return baseUrl;
85
+ }
86
+
87
+ async function getApiToken(signal?: AbortSignal): Promise<string | null> {
88
+ return resolveCredential({
89
+ provider: "Crawl4AI",
90
+ configuredValue: loadConfig().crawl4aiApiToken,
91
+ environmentValue: process.env.CRAWL4AI_API_TOKEN,
92
+ signal,
93
+ });
94
+ }
95
+
96
+ function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
97
+ const timeout = AbortSignal.timeout(timeoutMs);
98
+ return signal ? AbortSignal.any([timeout, signal]) : timeout;
99
+ }
100
+
101
+ function errorMessage(err: unknown): string {
102
+ return err instanceof Error ? err.message : String(err);
103
+ }
104
+
105
+ function isAbortException(err: unknown): boolean {
106
+ return err instanceof DOMException && (err.name === "AbortError" || err.name === "TimeoutError");
107
+ }
108
+
109
+ function ssrfOptions(options?: Crawl4aiExtractOptions): SsrfConfig & { lookup?: Lookup } {
110
+ return {
111
+ ...(options?.ssrf ?? loadSsrfConfig()),
112
+ ...(options?.lookup ? { lookup: options.lookup } : {}),
113
+ };
114
+ }
115
+
116
+ function isLoopbackApiUrl(url: URL): boolean {
117
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
118
+ if (hostname === "localhost" || hostname === "::1") return true;
119
+ if (net.isIP(hostname) !== 4) return false;
120
+ return hostname.split(".")[0] === "127";
121
+ }
122
+
123
+ function firstHeadingTitle(markdown: string): string {
124
+ return /^[ \t]*#[ \t]+(\S.*?)[ \t\r]*$/m.exec(markdown)?.[1] ?? "";
125
+ }
126
+
127
+ export function isCrawl4aiAvailable(): boolean {
128
+ return getBaseUrl() !== null;
129
+ }
130
+
131
+ export async function extractWithCrawl4ai(
132
+ url: string,
133
+ signal?: AbortSignal,
134
+ options?: Crawl4aiExtractOptions,
135
+ ): Promise<ExtractedContent | null> {
136
+ const baseUrl = requireBaseUrl();
137
+ const ssrf = ssrfOptions(options);
138
+ await validateRemoteUrl(url, ssrf);
139
+ const token = await getApiToken(signal);
140
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
141
+ if (token) headers.Authorization = `Bearer ${token}`;
142
+ const requestUrl = new URL(`${baseUrl}/md`);
143
+ const init = {
144
+ method: "POST",
145
+ headers,
146
+ body: JSON.stringify({ url, f: MARKDOWN_FILTER }),
147
+ signal: requestSignal(options?.timeoutMs ?? EXTRACT_TIMEOUT_MS, signal),
148
+ };
149
+ let seeOther = false;
150
+ const activityId = activityMonitor.logStart({ type: "fetch", url: requestUrl.toString() });
151
+ try {
152
+ const response = await fetchRemoteUrl(requestUrl, init, {
153
+ ...ssrf,
154
+ allowLoopback: isLoopbackApiUrl(requestUrl),
155
+ onRedirect: ({ to, init: redirectInit, response }) => {
156
+ if (to.origin !== requestUrl.origin) {
157
+ throw new Error(`Crawl4AI refused cross-origin redirect to ${to.origin}`);
158
+ }
159
+ // 303 genuinely means "GET the other resource", and it is final: once a hop has turned the
160
+ // chain into a GET, no later hop may resurrect the extraction body.
161
+ if (response.status === 303) seeOther = true;
162
+ // 301/302 are the hops a reverse proxy in front of a self-hosted instance actually emits, and
163
+ // fetchRemoteUrl turns those into a bodyless GET that /md cannot serve, so replay the POST.
164
+ // 307/308 already keep the method, so their init needs no help.
165
+ const replayPost = !seeOther && (response.status === 301 || response.status === 302);
166
+ return replayPost ? init : redirectInit;
167
+ },
168
+ });
169
+ if (!response.ok) {
170
+ const text = await response.text().catch(() => "");
171
+ throw new Error(`Crawl4AI md error ${response.status}: ${redactCredential(text, token).slice(0, 300)}`);
172
+ }
173
+ let data: unknown;
174
+ try {
175
+ data = await response.json();
176
+ } catch {
177
+ // The parser message quotes the offending body, which can echo the configured token, so it never
178
+ // reaches the thrown error or the activity log.
179
+ throw new Error("Crawl4AI md returned invalid JSON");
180
+ }
181
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
182
+ throw new Error("Crawl4AI md returned an unexpected response shape");
183
+ }
184
+ const envelope = data as Record<string, unknown>;
185
+ if (envelope.success === false) {
186
+ const detail = typeof envelope.error === "string" ? envelope.error : typeof envelope.detail === "string" ? envelope.detail : "";
187
+ throw new Error(`Crawl4AI md unsuccessful: ${redactCredential(detail.trim() || "unknown error", token)}`);
188
+ }
189
+ if (envelope.success !== true) {
190
+ throw new Error("Crawl4AI md returned an unexpected response shape");
191
+ }
192
+ if (typeof envelope.markdown !== "string") {
193
+ throw new Error("Crawl4AI md returned markdown in an unexpected shape");
194
+ }
195
+ activityMonitor.logComplete(activityId, response.status);
196
+ const content = envelope.markdown.trim();
197
+ if (!content) return null;
198
+ return { url, title: firstHeadingTitle(content), content, error: null };
199
+ } catch (err) {
200
+ if (signal?.aborted || isAbortException(err)) activityMonitor.logComplete(activityId, 0);
201
+ else activityMonitor.logError(activityId, errorMessage(err));
202
+ throw err;
203
+ }
204
+ }
@@ -10,6 +10,7 @@ const COMMAND_ENVIRONMENT_NAMES = [
10
10
  "HOME",
11
11
  "USER",
12
12
  "LOGNAME",
13
+ "OP_SERVICE_ACCOUNT_TOKEN",
13
14
  "PATH",
14
15
  "LANG",
15
16
  "LC_ALL",
@@ -43,6 +43,7 @@ function buildProviderButtons(
43
43
  { value: "mistral", label: "Mistral", available: available.mistral },
44
44
  { value: "brightdata", label: "Bright Data", available: available.brightdata },
45
45
  { value: "serpbase", label: "SerpBase", available: available.serpbase },
46
+ { value: "serpapi", label: "SerpApi", available: available.serpapi },
46
47
  { value: "serper", label: "Serper", available: available.serper },
47
48
  { value: "valyu", label: "Valyu", available: available.valyu },
48
49
  ];
@@ -149,6 +150,7 @@ ${CSS}
149
150
  <button class="btn btn-secondary" id="btn-summary-regenerate">Regenerate</button>
150
151
  <button class="btn btn-secondary" id="btn-summary-preview" title="Preview rendered summary">Preview</button>
151
152
  <button class="btn btn-submit" id="btn-summary-approve">Approve</button>
153
+ <button class="btn btn-submit" id="btn-summary-approve-remaining">Approve + auto-summary remaining searches for this prompt</button>
152
154
  </div>
153
155
  </section>
154
156
  </main>
@@ -1197,6 +1199,7 @@ main {
1197
1199
  }
1198
1200
  .summary-actions {
1199
1201
  display: flex;
1202
+ flex-wrap: wrap;
1200
1203
  align-items: center;
1201
1204
  justify-content: flex-end;
1202
1205
  gap: 8px;
@@ -1469,7 +1472,7 @@ const SCRIPT = `(function() {
1469
1472
  var token = DATA.sessionToken;
1470
1473
  var timeoutSec = DATA.timeout;
1471
1474
  var queries = Array.isArray(DATA.queries) ? DATA.queries : [];
1472
- var providers = ["all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serper", "valyu"];
1475
+ var providers = ["all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "valyu"];
1473
1476
  var availProviders = DATA.availableProviders && typeof DATA.availableProviders === "object" ? DATA.availableProviders : {};
1474
1477
  var workflow = "summary-review";
1475
1478
  var initialDefaultProvider = typeof DATA.defaultProvider === "string" ? DATA.defaultProvider : "exa";
@@ -1534,6 +1537,7 @@ const SCRIPT = `(function() {
1534
1537
  var btnSummaryRegenerate = document.getElementById("btn-summary-regenerate");
1535
1538
  var btnSummaryPreview = document.getElementById("btn-summary-preview");
1536
1539
  var btnSummaryApprove = document.getElementById("btn-summary-approve");
1540
+ var btnSummaryApproveRemaining = document.getElementById("btn-summary-approve-remaining");
1537
1541
  var successOverlay = document.getElementById("success-overlay");
1538
1542
  var successText = document.getElementById("success-text");
1539
1543
  var expiredOverlay = document.getElementById("expired-overlay");
@@ -1699,6 +1703,9 @@ const SCRIPT = `(function() {
1699
1703
  if (provider === "mistral") return "Mistral";
1700
1704
  if (provider === "brightdata") return "Bright Data";
1701
1705
  if (provider === "serpbase") return "SerpBase";
1706
+ if (provider === "serpapi") return "SerpApi";
1707
+ if (provider === "serper") return "Serper";
1708
+ if (provider === "valyu") return "Valyu";
1702
1709
  return "Unknown";
1703
1710
  }
1704
1711
 
@@ -2114,6 +2121,9 @@ const SCRIPT = `(function() {
2114
2121
  if (btnSummaryApprove) {
2115
2122
  btnSummaryApprove.disabled = submitted || timerExpired || submitInFlight || stage === "generating-summary" || isRegenerating || !hasSelection || !hasDraft;
2116
2123
  }
2124
+ if (btnSummaryApproveRemaining) {
2125
+ btnSummaryApproveRemaining.disabled = submitted || timerExpired || submitInFlight || stage === "generating-summary" || isRegenerating || !hasSelection || !hasDraft;
2126
+ }
2117
2127
 
2118
2128
  applyProviderInterlocks();
2119
2129
  }
@@ -3205,7 +3215,7 @@ const SCRIPT = `(function() {
3205
3215
  requestSummary(selected);
3206
3216
  }
3207
3217
 
3208
- function doApprove() {
3218
+ function doApprove(autoApproveRemainingSearches) {
3209
3219
  if (submitted || timerExpired || submitInFlight || stage !== "summary-review") return;
3210
3220
 
3211
3221
  var selected = getSelectedIndices();
@@ -3217,6 +3227,7 @@ const SCRIPT = `(function() {
3217
3227
 
3218
3228
  var draft = getSummaryDraftText();
3219
3229
  var payload = { selected: selected };
3230
+ if (autoApproveRemainingSearches === true) payload.autoApproveRemainingSearches = true;
3220
3231
  if (draft.length > 0) {
3221
3232
  payload.summary = draft;
3222
3233
  payload.summaryMeta = normalizeSummaryMeta(summaryMeta, summaryMeta && summaryMeta.edited === true);
@@ -3455,6 +3466,13 @@ const SCRIPT = `(function() {
3455
3466
  });
3456
3467
  }
3457
3468
 
3469
+ if (btnSummaryApproveRemaining) {
3470
+ btnSummaryApproveRemaining.addEventListener("click", function() {
3471
+ doApprove(true);
3472
+ resetTimer();
3473
+ });
3474
+ }
3475
+
3458
3476
  if (summaryInput) {
3459
3477
  summaryInput.addEventListener("input", function() {
3460
3478
  if (!summaryMeta || typeof summaryMeta !== "object") {
@@ -0,0 +1,44 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type WebSearchWorkflow = "none" | "summary-review" | "auto-summary";
4
+
5
+ export function resolveWebSearchWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {
6
+ const normalized = typeof input === "string" ? input.trim().toLowerCase() : "";
7
+ if (normalized === "auto-summary") return "auto-summary";
8
+ if (!hasUI) return "none";
9
+ if (normalized === "none") return "none";
10
+ return "summary-review";
11
+ }
12
+
13
+ export class CuratorRunState {
14
+ private autoApproveRemaining = false;
15
+
16
+ approveRemainingSearches(): void {
17
+ this.autoApproveRemaining = true;
18
+ }
19
+
20
+ reset(): void {
21
+ this.autoApproveRemaining = false;
22
+ }
23
+
24
+ resolve(requestedWorkflow: unknown, configuredWorkflow: unknown, hasUI: boolean): WebSearchWorkflow {
25
+ const inherited = requestedWorkflow === undefined;
26
+ const workflow = resolveWebSearchWorkflow(
27
+ inherited ? configuredWorkflow : requestedWorkflow,
28
+ hasUI,
29
+ );
30
+ return inherited && this.autoApproveRemaining && workflow === "summary-review"
31
+ ? "auto-summary"
32
+ : workflow;
33
+ }
34
+ }
35
+
36
+ export function registerCuratorRunLifecycle(pi: Pick<ExtensionAPI, "on">): CuratorRunState {
37
+ const state = new CuratorRunState();
38
+ pi.on("before_agent_start", () => state.reset());
39
+ pi.on("agent_settled", () => state.reset());
40
+ pi.on("session_start", () => state.reset());
41
+ pi.on("session_tree", () => state.reset());
42
+ pi.on("session_shutdown", () => state.reset());
43
+ return state;
44
+ }
@@ -41,7 +41,7 @@ export interface IndexedCuratorSearchEntry extends CuratorSearchEntry {
41
41
  }
42
42
 
43
43
  export interface CuratorServerCallbacks {
44
- onSubmit: (payload: { selectedQueryIndices: number[]; summary?: string; summaryMeta?: SummaryMeta; rawResults?: boolean }) => void;
44
+ onSubmit: (payload: { selectedQueryIndices: number[]; summary?: string; summaryMeta?: SummaryMeta; rawResults?: boolean; autoApproveRemainingSearches?: boolean }) => void;
45
45
  onCancel: (reason: "user" | "timeout" | "stale") => void;
46
46
  onProviderChange: (provider: string) => void;
47
47
  onAddSearch: (query: string, provider?: string) => Promise<CuratorSearchEntry[]>;
@@ -642,12 +642,14 @@ export function startCuratorServer(
642
642
  return;
643
643
  }
644
644
  const rawResults = (body as { rawResults?: unknown }).rawResults === true;
645
+ const autoApproveRemainingSearches = (body as { autoApproveRemainingSearches?: unknown }).autoApproveRemainingSearches === true;
645
646
  sendJson(res, 200, { ok: true });
646
647
  setImmediate(() => callbacks.onSubmit({
647
648
  selectedQueryIndices: parsed.indices,
648
649
  ...(summary !== undefined ? { summary } : {}),
649
650
  ...(summaryMeta !== undefined ? { summaryMeta } : {}),
650
651
  rawResults,
652
+ ...(autoApproveRemainingSearches ? { autoApproveRemainingSearches: true } : {}),
651
653
  }));
652
654
  return;
653
655
  }
@@ -1,4 +1,3 @@
1
- import { parseHTML } from "linkedom";
2
1
  import { activityMonitor } from "./activity.ts";
3
2
  import type { SearchOptions, SearchResult, SearchResponse } from "./perplexity.ts";
4
3
 
@@ -88,6 +87,7 @@ export async function searchWithDuckDuckGo(query: string, options: SearchOptions
88
87
  throw new Error(`DuckDuckGo search error ${response.status}: ${body.slice(0, 300)}`);
89
88
  }
90
89
 
90
+ const { parseHTML } = await import("linkedom");
91
91
  const { document } = parseHTML(await response.text());
92
92
  const filters = normalizeDomainFilters(options.domainFilter);
93
93
  const results: SearchResult[] = [];
@@ -1,8 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { Readability } from "@mozilla/readability";
3
- import { resizeImage } from "@earendil-works/pi-coding-agent";
4
- import { parseHTML } from "linkedom";
5
- import TurndownService from "turndown";
2
+ import type TurndownService from "turndown";
6
3
  import pLimit from "p-limit";
7
4
  import { activityMonitor } from "./activity.ts";
8
5
  import { extractRSCContent } from "./rsc-extract.ts";
@@ -20,6 +17,7 @@ import { extractWithQuerit, isQueritAvailable } from "./querit.ts";
20
17
  import { extractWithKagi, isKagiExtractAvailable } from "./kagi.ts";
21
18
  import { extractWithOllama, isOllamaFetchAvailable } from "./ollama.ts";
22
19
  import { extractWithFirecrawl, isFirecrawlAvailable } from "./firecrawl.ts";
20
+ import { extractWithCrawl4ai, isCrawl4aiAvailable } from "./crawl4ai.ts";
23
21
  import { extractWithBrightDataUnlocker, isBrightDataUnlockerAvailable } from "./brightdata-unlocker.ts";
24
22
  import { isVideoFile, extractVideo, extractVideoFrame, getLocalVideoDuration } from "./video-extract.ts";
25
23
  import { appendDeclaredWebLinks, discoverDeclaredWebLinks, type DeclaredWebLink } from "./declared-web-links.ts";
@@ -70,10 +68,10 @@ function loadFetchTimeoutMs(): number {
70
68
  const NON_RECOVERABLE_ERRORS = ["Unsupported content type", "Response too large", "PDF extraction is disabled", "Image fetching is disabled"];
71
69
  const MIN_USEFUL_CONTENT = 500;
72
70
  const SUPPORTED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
73
- const FETCH_PROVIDERS = ["http", "firecrawl", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "parallel-mcp", "brightdata", "gemini"] as const;
71
+ const FETCH_PROVIDERS = ["http", "firecrawl", "crawl4ai", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "parallel-mcp", "brightdata", "gemini"] as const;
74
72
  type FetchProvider = typeof FETCH_PROVIDERS[number];
75
73
  type FetchRouting = { providers: FetchProvider[]; allowRemoteHostedProviders: boolean };
76
- const DEFAULT_FETCH_PROVIDER_ORDER: FetchProvider[] = ["http", "firecrawl", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "brightdata", "gemini"];
74
+ const DEFAULT_FETCH_PROVIDER_ORDER: FetchProvider[] = ["http", "firecrawl", "crawl4ai", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "brightdata", "gemini"];
77
75
  const REMOTE_HOSTED_FETCH_PROVIDERS = new Set<FetchProvider>(["jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "parallel-mcp", "brightdata", "gemini"]);
78
76
 
79
77
  function isDefuddleConsoleError(args: Parameters<typeof console.error>): boolean {
@@ -83,6 +81,7 @@ function isDefuddleConsoleError(args: Parameters<typeof console.error>): boolean
83
81
 
84
82
  async function extractWithDefuddle(text: string, url: string): Promise<{ title: string; content: string } | null> {
85
83
  const { Defuddle } = await import("defuddle/node");
84
+ const { parseHTML } = await import("linkedom");
86
85
  const { document } = parseHTML(text);
87
86
  Object.defineProperty(document, "location", {
88
87
  value: new URL(url),
@@ -135,6 +134,10 @@ function isAbortError(err: unknown): boolean {
135
134
  return errorMessage(err).toLowerCase().includes("abort");
136
135
  }
137
136
 
137
+ function isAbortException(err: unknown): boolean {
138
+ return err instanceof DOMException && (err.name === "AbortError" || err.name === "TimeoutError");
139
+ }
140
+
138
141
  function isRedirectPolicyError(message: string): boolean {
139
142
  return message.startsWith("Authenticated fetch refused cross-origin redirect") ||
140
143
  message.startsWith("Blocked internal ") ||
@@ -288,10 +291,19 @@ function abortedResult(url: string): ExtractedContent {
288
291
  return { url, title: "", content: "", error: "Aborted" };
289
292
  }
290
293
 
291
- const turndown = new TurndownService({
292
- headingStyle: "atx",
293
- codeBlockStyle: "fenced",
294
- });
294
+ // Share first-use initialization without loading Turndown at extension startup.
295
+ let turndownInstance: Promise<TurndownService> | undefined;
296
+ async function loadTurndown(): Promise<TurndownService> {
297
+ const { default: TurndownService } = await import("turndown");
298
+ return new TurndownService({
299
+ headingStyle: "atx",
300
+ codeBlockStyle: "fenced",
301
+ });
302
+ }
303
+ function getTurndown(): Promise<TurndownService> {
304
+ turndownInstance ??= loadTurndown();
305
+ return turndownInstance;
306
+ }
295
307
 
296
308
  const fetchLimit = pLimit(CONCURRENT_LIMIT);
297
309
 
@@ -329,7 +341,7 @@ export interface ExtractOptions {
329
341
  answerModel?: string;
330
342
  authFetchProfile?: AuthFetchProfile;
331
343
  toolNames?: RegisteredToolNames;
332
- /** Optional http(s) proxy URL; routed through the curl-backed transport. */
344
+ /** Optional HTTP(S) or SOCKS proxy URL; routed through the curl-backed transport. */
333
345
  proxy?: string;
334
346
  /** Custom DNS resolver used for SSRF validation. Primarily a test seam. */
335
347
  lookup?: Lookup;
@@ -788,6 +800,7 @@ export async function extractContent(
788
800
  };
789
801
 
790
802
  let firecrawlError: string | null = null;
803
+ let crawl4aiError: string | null = null;
791
804
  let tinyfishError: string | null = null;
792
805
  let search1apiError: string | null = null;
793
806
  let queritError: string | null = null;
@@ -830,6 +843,25 @@ export async function extractContent(
830
843
  continue;
831
844
  }
832
845
 
846
+ if (provider === "crawl4ai") {
847
+ try {
848
+ if (isCrawl4aiAvailable()) {
849
+ const ssrf = loadSsrfConfig();
850
+ const crawl4aiResult = await extractWithCrawl4ai(url, signal, {
851
+ timeoutMs: options?.timeoutMs,
852
+ ...(options?.lookup ? { lookup: options.lookup } : {}),
853
+ ssrf,
854
+ });
855
+ if (crawl4aiResult) return withDeclaredLinks(crawl4aiResult);
856
+ }
857
+ } catch (err) {
858
+ if (signal?.aborted || isAbortException(err)) return abortedResult(url);
859
+ crawl4aiError = errorMessage(err);
860
+ if (isConfigParseError(err)) return parseErrorResult(crawl4aiError);
861
+ }
862
+ continue;
863
+ }
864
+
833
865
  if (provider === "jina") {
834
866
  const jinaResult = await extractWithJinaReader(url, fetchTimeoutMs, signal, options?.lookup);
835
867
  if (jinaResult) return withDeclaredLinks(jinaResult);
@@ -991,6 +1023,7 @@ export async function extractContent(
991
1023
  const guidance = [
992
1024
  finalHttpResult?.error ?? "No fetch_content provider returned content",
993
1025
  ...(firecrawlError ? [`Firecrawl fallback failed: ${firecrawlError}`] : []),
1026
+ ...(crawl4aiError ? [`Crawl4AI fallback failed: ${crawl4aiError}`] : []),
994
1027
  ...(tinyfishError ? [`TinyFish fallback failed: ${tinyfishError}`] : []),
995
1028
  ...(search1apiError ? [`Search1API fallback failed: ${search1apiError}`] : []),
996
1029
  ...(queritError ? [`Querit fallback failed: ${queritError}`] : []),
@@ -1002,6 +1035,7 @@ export async function extractContent(
1002
1035
  "",
1003
1036
  "Fallback options:",
1004
1037
  ` • Set firecrawlBaseUrl in ${WEB_SEARCH_CONFIG_PATH} to a self-hosted Firecrawl instance`,
1038
+ ` • Set crawl4aiBaseUrl in ${WEB_SEARCH_CONFIG_PATH} to a self-hosted Crawl4AI instance`,
1005
1039
  ` • Set tinyfishApiKey in ${WEB_SEARCH_CONFIG_PATH} or TINYFISH_API_KEY`,
1006
1040
  ` • Set search1apiApiKey in ${WEB_SEARCH_CONFIG_PATH} or SEARCH1API_KEY`,
1007
1041
  ` • Set queritApiKey in ${WEB_SEARCH_CONFIG_PATH} or QUERIT_API_KEY`,
@@ -1118,6 +1152,7 @@ async function extractViaHttp(
1118
1152
  const activityId = activityMonitor.logStart({ type: "fetch", url });
1119
1153
 
1120
1154
  const controller = new AbortController();
1155
+ const startedAt = Date.now();
1121
1156
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1122
1157
 
1123
1158
  const onAbort = () => controller.abort();
@@ -1210,6 +1245,7 @@ async function extractViaHttp(
1210
1245
  }
1211
1246
  try {
1212
1247
  const buffer = await readResponseBufferWithLimit(response, maxResponseSize, () => responseSizeLimitError(maxResponseSize));
1248
+ const { resizeImage } = await import("@earendil-works/pi-coding-agent");
1213
1249
  const resized = await resizeImage(new Uint8Array(buffer), mimeType, { maxWidth: 2000, maxHeight: 2000 });
1214
1250
  activityMonitor.logComplete(activityId, response.status);
1215
1251
  if (!resized) return { url, title: "", content: "", error: `Could not decode image: ${mimeType}`, mimeType, status: response.status };
@@ -1278,6 +1314,7 @@ async function extractViaHttp(
1278
1314
  return { url, title, content: text, error: null };
1279
1315
  }
1280
1316
 
1317
+ const { parseHTML } = await import("linkedom");
1281
1318
  const { document } = parseHTML(text);
1282
1319
  const documentTitle = document.title?.trim() ?? "";
1283
1320
  const declaredLinks = discoverDeclaredWebLinks(
@@ -1285,6 +1322,7 @@ async function extractViaHttp(
1285
1322
  response.headers.get("link"),
1286
1323
  response.url || url,
1287
1324
  );
1325
+ const { Readability } = await import("@mozilla/readability");
1288
1326
  const reader = new Readability(document as unknown as Document);
1289
1327
  const article = reader.parse();
1290
1328
 
@@ -1332,7 +1370,7 @@ async function extractViaHttp(
1332
1370
  if (typeof article.content !== "string") {
1333
1371
  throw new Error("Readability returned invalid article content");
1334
1372
  }
1335
- const markdown = turndown.turndown(article.content);
1373
+ const markdown = (await getTurndown()).turndown(article.content);
1336
1374
  activityMonitor.logComplete(activityId, response.status);
1337
1375
 
1338
1376
  if (markdown.length < MIN_USEFUL_CONTENT) {
@@ -1387,6 +1425,13 @@ async function extractViaHttp(
1387
1425
  } finally {
1388
1426
  clearTimeout(timeoutId);
1389
1427
  signal?.removeEventListener("abort", onAbort);
1428
+ // Imports and CPU-bound processing need not observe the fetch signal, and
1429
+ // can finish before an expired timer gets a turn. Guard every exit, with
1430
+ // caller cancellation taking precedence over the internal deadline.
1431
+ if (signal?.aborted) return abortedResult(url);
1432
+ if (controller.signal.aborted || Date.now() - startedAt >= timeoutMs) {
1433
+ return { url, title: "", content: "", error: "The operation was aborted." };
1434
+ }
1390
1435
  }
1391
1436
  }
1392
1437
 
@@ -96,7 +96,7 @@ function normalizeAuth(value: unknown): true | string | undefined {
96
96
 
97
97
  function normalizeProxy(value: unknown): string | undefined {
98
98
  if (value === undefined || value === false) return undefined;
99
- if (value === null) throw new Error("proxy must be an http(s) proxy URL string");
99
+ if (value === null) throw new Error("proxy must be an http(s) or socks proxy URL string");
100
100
  const normalized = normalizeProxyUrl(value, "proxy");
101
101
  return normalized ?? "";
102
102
  }
@@ -34,13 +34,14 @@ import { isXcrawlAvailable, searchWithXCrawl } from "./xcrawl.ts";
34
34
  import { isXaiSearchAvailable, searchWithXai } from "./xai-search.ts";
35
35
  import { isBrightDataAvailable, searchWithBrightData } from "./brightdata.ts";
36
36
  import { isSerpBaseAvailable, searchWithSerpBase } from "./serpbase.ts";
37
+ import { isSerpApiAvailable, searchWithSerpApi } from "./serpapi.ts";
37
38
  import { isSerperAvailable, searchWithSerper } from "./serper.ts";
38
39
  import { isValyuAvailable, searchWithValyu } from "./valyu.ts";
39
40
  import { isKimiSearchAvailable, searchWithKimi } from "./kimi-search.ts";
40
41
  import { isMistralAvailable, searchWithMistral } from "./mistral-search.ts";
41
42
  import { getWebSearchConfigPath } from "./utils.ts";
42
43
 
43
- 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", "serper", "valyu", "bocha", "xcrawl"] as const;
44
+ 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", "valyu", "bocha", "xcrawl"] as const;
44
45
  export const SEARCH_PROVIDERS = ["auto", "all", ...RESOLVED_SEARCH_PROVIDERS] as const;
45
46
 
46
47
  export type ResolvedSearchProvider = typeof RESOLVED_SEARCH_PROVIDERS[number];
@@ -105,7 +106,7 @@ export interface AttributedSearchResponse extends SearchResponse {
105
106
 
106
107
  const CONFIG_PATH = getWebSearchConfigPath();
107
108
  const DEFAULT_SEARCH_MODEL = "gemini-3.6-flash";
108
- // Explicit-only providers (Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, xAI, Mistral, Bright Data, SerpBase, Serper, Valyu) are deliberately absent:
109
+ // Explicit-only providers (Parallel MCP, DuckDuckGo, Kimi, AnySearch, XCrawl, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Valyu) are deliberately absent:
109
110
  // `all` must never fan out to an opt-in or paid provider without the user asking for it.
110
111
  export const ALL_SEARCH_PROVIDERS: ResolvedSearchProvider[] = ["searxng", "openai", "exa", "brave", "parallel", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "ollama", "perplexity", "gemini", "bocha"];
111
112
  const VALID_ROUTING_KINDS = ["transient", "quota", "network", "invalid-response", "unsupported"] as const;
@@ -311,7 +312,7 @@ function classifyProviderError(provider: ResolvedSearchProvider, err: unknown):
311
312
  kind = "unsupported";
312
313
  } else if (status === 400 || status === 422) {
313
314
  kind = "invalid-request";
314
- } else if (status === 402 || status === 429) {
315
+ } else if (status === 402 || status === 429 || (provider === "tavily" && status === 432)) {
315
316
  kind = "quota";
316
317
  } else if (status !== undefined && (status === 408 || status === 425 || status >= 500)) {
317
318
  kind = "transient";
@@ -364,6 +365,7 @@ async function searchWithResolvedProvider(
364
365
  if (provider === "mistral") return { ...(await searchWithMistral(query, options)), provider };
365
366
  if (provider === "brightdata") return { ...(await searchWithBrightData(query, options)), provider };
366
367
  if (provider === "serpbase") return { ...(await searchWithSerpBase(query, options)), provider };
368
+ if (provider === "serpapi") return { ...(await searchWithSerpApi(query, options)), provider };
367
369
  if (provider === "serper") return { ...(await searchWithSerper(query, options)), provider };
368
370
  if (provider === "valyu") return { ...(await searchWithValyu(query, options)), provider };
369
371
  if (provider === "xcrawl") return { ...(await searchWithXCrawl(query, options)), provider };
@@ -412,6 +414,7 @@ async function isResolvedProviderAvailable(provider: ResolvedSearchProvider, opt
412
414
  if (provider === "mistral") return isMistralAvailable();
413
415
  if (provider === "brightdata") return isBrightDataAvailable();
414
416
  if (provider === "serpbase") return isSerpBaseAvailable();
417
+ if (provider === "serpapi") return isSerpApiAvailable();
415
418
  if (provider === "serper") return isSerperAvailable();
416
419
  if (provider === "valyu") return isValyuAvailable();
417
420
  if (provider === "xcrawl") return isXcrawlAvailable();
@@ -451,6 +454,7 @@ function providerLabel(provider: ResolvedSearchProvider): string {
451
454
  if (provider === "mistral") return "Mistral";
452
455
  if (provider === "brightdata") return "Bright Data";
453
456
  if (provider === "serpbase") return "SerpBase";
457
+ if (provider === "serpapi") return "SerpApi";
454
458
  if (provider === "serper") return "Serper";
455
459
  if (provider === "valyu") return "Valyu";
456
460
  return provider.charAt(0).toUpperCase() + provider.slice(1);
@@ -479,7 +483,7 @@ async function searchWithProviders(
479
483
  : await isResolvedProviderAvailable(provider, options),
480
484
  })))).filter((entry) => entry.available).map((entry) => entry.provider);
481
485
  if (providers.length === 0) {
482
- throw new Error("No configured search provider available for provider \"all\". Parallel MCP, DuckDuckGo, Kimi, AnySearch, xAI, Mistral, Bright Data, SerpBase, Serper, Valyu, and XCrawl are excluded.");
486
+ throw new Error("No configured search provider available for provider \"all\". Parallel MCP, DuckDuckGo, Kimi, AnySearch, xAI, Mistral, Bright Data, SerpBase, SerpApi, Serper, Valyu, and XCrawl are excluded.");
483
487
  }
484
488
 
485
489
  const settled = await Promise.allSettled(
@@ -772,7 +776,7 @@ export async function search(query: string, options: FullSearchOptions = {}): Pr
772
776
  " 3. Set OPENAI_API_KEY, BRAVE_API_KEY, PARALLEL_API_KEY, TINYFISH_API_KEY, SEARCH1API_KEY, SEARCHINFINITY_API_KEY, QUERIT_API_KEY, TAVILY_API_KEY, FIRECRAWL_BASE_URL, JINA_API_KEY, SERPDIVE_API_KEY, KAGI_API_KEY, BOCHA_API_KEY, OLLAMA_API_KEY, SEARXNG_BASE_URL, EXA_API_KEY, PERPLEXITY_API_KEY, GEMINI_API_KEY, or CLOUDFLARE_API_KEY env vars\n" +
773
777
  " 4. Set GOOGLE_GEMINI_BASE_URL with CLOUDFLARE_API_KEY for Cloudflare AI Gateway routing\n" +
774
778
  " 5. Sign into gemini.google.com in a supported Chromium-based browser\n" +
775
- " 6. Explicitly select provider: \"anysearch\" for anonymous AnySearch, \"xcrawl\" for XCrawl, \"xai\" for Grok, \"mistral\" for Mistral Conversations web search, \"brightdata\" with brightdataSerpZone for paid Bright Data SERP, \"serpbase\" or \"serper\" for Google SERP, or \"valyu\" for research search"
779
+ " 6. Explicitly select provider: \"anysearch\" for anonymous AnySearch, \"xcrawl\" for XCrawl, \"xai\" for Grok, \"mistral\" for Mistral Conversations web search, \"brightdata\" with brightdataSerpZone for paid Bright Data SERP, \"serpbase\", \"serpapi\", or \"serper\" for Google SERP, or \"valyu\" for research search"
776
780
  );
777
781
  }
778
782