@arnilo/prism-tools 0.0.0-stage → 0.13.1

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 (331) hide show
  1. package/CHANGELOG.md +418 -0
  2. package/LICENSE +21 -0
  3. package/README.md +44 -2
  4. package/browser/README.md +98 -0
  5. package/browser/skills/browser-automation/SKILL.md +14 -0
  6. package/browser/src/actions/cdp.ts +131 -0
  7. package/browser/src/actions/dialog.ts +25 -0
  8. package/browser/src/actions/download_release.ts +22 -0
  9. package/browser/src/actions/index.ts +37 -0
  10. package/browser/src/actions/locator.ts +70 -0
  11. package/browser/src/actions/navigate.ts +23 -0
  12. package/browser/src/actions/screenshot.ts +27 -0
  13. package/browser/src/actions/scroll.ts +24 -0
  14. package/browser/src/actions/select_page.ts +21 -0
  15. package/browser/src/actions/types.ts +114 -0
  16. package/browser/src/actions/upload.ts +44 -0
  17. package/browser/src/actions/wait.ts +45 -0
  18. package/browser/src/browser.ts +197 -0
  19. package/browser/src/cdp.ts +178 -0
  20. package/browser/src/checkpoint.ts +189 -0
  21. package/browser/src/config.ts +72 -0
  22. package/browser/src/downloads.ts +253 -0
  23. package/browser/src/errors.ts +31 -0
  24. package/browser/src/evaluate.ts +101 -0
  25. package/browser/src/index.ts +129 -0
  26. package/browser/src/limits.ts +271 -0
  27. package/browser/src/manager.ts +730 -0
  28. package/browser/src/network.ts +263 -0
  29. package/browser/src/observe.ts +178 -0
  30. package/browser/src/policy.ts +133 -0
  31. package/browser/src/screenshot.ts +107 -0
  32. package/browser/src/shared-sandbox.ts +53 -0
  33. package/browser/src/snapshot.ts +99 -0
  34. package/browser/src/targets.ts +165 -0
  35. package/browser/src/tools.ts +480 -0
  36. package/browser/src/types.ts +316 -0
  37. package/browser/src/uploads.ts +174 -0
  38. package/coding/README.md +337 -0
  39. package/coding/src/agent/acp-operations.ts +146 -0
  40. package/coding/src/agent/artifacts.ts +38 -0
  41. package/coding/src/agent/ask-user-decision.ts +623 -0
  42. package/coding/src/agent/atomic-write.ts +23 -0
  43. package/coding/src/agent/bounded-file.ts +21 -0
  44. package/coding/src/agent/checks.ts +301 -0
  45. package/coding/src/agent/coding-checkpoint.ts +828 -0
  46. package/coding/src/agent/delete.ts +237 -0
  47. package/coding/src/agent/diagnostics-tool.ts +163 -0
  48. package/coding/src/agent/diagnostics.ts +262 -0
  49. package/coding/src/agent/edit-diff.ts +382 -0
  50. package/coding/src/agent/edit.ts +322 -0
  51. package/coding/src/agent/effects.ts +102 -0
  52. package/coding/src/agent/env.ts +28 -0
  53. package/coding/src/agent/execution-policy.ts +89 -0
  54. package/coding/src/agent/file-mutation-queue.ts +72 -0
  55. package/coding/src/agent/forge/github.ts +603 -0
  56. package/coding/src/agent/forge/index.ts +16 -0
  57. package/coding/src/agent/forge/types.ts +185 -0
  58. package/coding/src/agent/git-aware-repository.ts +305 -0
  59. package/coding/src/agent/git-exec.ts +326 -0
  60. package/coding/src/agent/git-status.ts +177 -0
  61. package/coding/src/agent/git-tools.ts +360 -0
  62. package/coding/src/agent/git.ts +1074 -0
  63. package/coding/src/agent/glob-match.ts +146 -0
  64. package/coding/src/agent/glob.ts +182 -0
  65. package/coding/src/agent/goal-verify.ts +385 -0
  66. package/coding/src/agent/grep-worker.ts +74 -0
  67. package/coding/src/agent/grep.ts +459 -0
  68. package/coding/src/agent/identity.ts +48 -0
  69. package/coding/src/agent/index.ts +676 -0
  70. package/coding/src/agent/language/client.ts +429 -0
  71. package/coding/src/agent/language/framing.ts +190 -0
  72. package/coding/src/agent/language/index.ts +23 -0
  73. package/coding/src/agent/language/intelligence.ts +682 -0
  74. package/coding/src/agent/language/types.ts +187 -0
  75. package/coding/src/agent/lifecycle-events.ts +121 -0
  76. package/coding/src/agent/lifecycle.ts +166 -0
  77. package/coding/src/agent/limits.ts +214 -0
  78. package/coding/src/agent/move.ts +178 -0
  79. package/coding/src/agent/mutation-path.ts +52 -0
  80. package/coding/src/agent/output-accumulator.ts +304 -0
  81. package/coding/src/agent/path-utils.ts +120 -0
  82. package/coding/src/agent/process/index.ts +58 -0
  83. package/coding/src/agent/process/recovery.ts +508 -0
  84. package/coding/src/agent/process/sessions-host.ts +222 -0
  85. package/coding/src/agent/process/sessions-monitor.ts +141 -0
  86. package/coding/src/agent/process/sessions-recovery.ts +241 -0
  87. package/coding/src/agent/process/sessions-spawn.ts +613 -0
  88. package/coding/src/agent/process/sessions-teardown.ts +281 -0
  89. package/coding/src/agent/process/sessions.ts +30 -0
  90. package/coding/src/agent/process/types.ts +358 -0
  91. package/coding/src/agent/read-path-set.ts +126 -0
  92. package/coding/src/agent/read.ts +713 -0
  93. package/coding/src/agent/repository/glob.ts +147 -0
  94. package/coding/src/agent/repository/indexed-search.ts +480 -0
  95. package/coding/src/agent/repository/list.ts +131 -0
  96. package/coding/src/agent/repository/operations.ts +22 -0
  97. package/coding/src/agent/repository/path.ts +98 -0
  98. package/coding/src/agent/repository/search.ts +277 -0
  99. package/coding/src/agent/repository/types.ts +243 -0
  100. package/coding/src/agent/repository/walk.ts +126 -0
  101. package/coding/src/agent/repository.ts +12 -0
  102. package/coding/src/agent/shell-background.ts +264 -0
  103. package/coding/src/agent/shell-output.ts +75 -0
  104. package/coding/src/agent/shell.ts +526 -0
  105. package/coding/src/agent/todo.ts +399 -0
  106. package/coding/src/agent/truncate.ts +251 -0
  107. package/coding/src/agent/write.ts +170 -0
  108. package/coding/src/ask-user-host.ts +48 -0
  109. package/coding/src/config.ts +278 -0
  110. package/coding/src/hard-deny.ts +97 -0
  111. package/coding/src/index.ts +318 -0
  112. package/coding/src/principal-guard.ts +84 -0
  113. package/coding/src/sandbox.ts +92 -0
  114. package/coding/src/security/approval.ts +144 -0
  115. package/coding/src/security/command-rules.ts +57 -0
  116. package/coding/src/security/docker-cli.ts +222 -0
  117. package/coding/src/security/docker-sandbox.ts +1205 -0
  118. package/coding/src/security/e2b-sandbox.ts +853 -0
  119. package/coding/src/security/egress/dns-pin.ts +88 -0
  120. package/coding/src/security/egress/index.ts +32 -0
  121. package/coding/src/security/egress/limits.ts +57 -0
  122. package/coding/src/security/egress/policy.ts +137 -0
  123. package/coding/src/security/egress/proxy.ts +599 -0
  124. package/coding/src/security/egress/types.ts +30 -0
  125. package/coding/src/security/index.ts +199 -0
  126. package/coding/src/security/native-sandbox.ts +639 -0
  127. package/coding/src/security/path-containment.ts +55 -0
  128. package/coding/src/security/sandbox-coding-operations.ts +392 -0
  129. package/coding/src/security/sandbox-fs-operations.ts +644 -0
  130. package/coding/src/security/sandbox-limits.ts +147 -0
  131. package/coding/src/security/sandbox-tar.ts +308 -0
  132. package/coding/src/security/sandbox.ts +179 -0
  133. package/coding/src/security/semaphore.ts +55 -0
  134. package/coding/src/targets.ts +110 -0
  135. package/coding/src/workspace/diff.ts +78 -0
  136. package/coding/src/workspace/errors.ts +29 -0
  137. package/coding/src/workspace/index.ts +5 -0
  138. package/coding/src/workspace/merge.ts +151 -0
  139. package/coding/src/workspace/routing.ts +76 -0
  140. package/coding/src/workspace/service.ts +584 -0
  141. package/computer-use/README.md +105 -0
  142. package/computer-use/skills/computer-use-linux/SKILL.md +17 -0
  143. package/computer-use/src/classify.ts +53 -0
  144. package/computer-use/src/command.ts +29 -0
  145. package/computer-use/src/config.ts +69 -0
  146. package/computer-use/src/create.ts +170 -0
  147. package/computer-use/src/devices.ts +247 -0
  148. package/computer-use/src/env.ts +28 -0
  149. package/computer-use/src/index.ts +157 -0
  150. package/computer-use/src/skill.ts +21 -0
  151. package/openapi/README.md +97 -0
  152. package/openapi/src/compile.ts +386 -0
  153. package/openapi/src/config.ts +73 -0
  154. package/openapi/src/errors.ts +16 -0
  155. package/openapi/src/execute.ts +241 -0
  156. package/openapi/src/index.ts +124 -0
  157. package/openapi/src/limits.ts +63 -0
  158. package/openapi/src/specs.ts +86 -0
  159. package/openapi/src/tools.ts +60 -0
  160. package/openapi/src/types.ts +61 -0
  161. package/package.json +171 -4
  162. package/research/README.md +72 -0
  163. package/research/skills/research-report/SKILL.md +12 -0
  164. package/research/skills/research-scoping/SKILL.md +10 -0
  165. package/research/skills/research-synthesis/SKILL.md +10 -0
  166. package/research/skills/source-triage/SKILL.md +10 -0
  167. package/research/src/check.ts +71 -0
  168. package/research/src/config.ts +41 -0
  169. package/research/src/index.ts +147 -0
  170. package/research/src/ledger.ts +157 -0
  171. package/research/src/urls.ts +21 -0
  172. package/web/README.md +287 -0
  173. package/web/skills/web-research/SKILL.md +17 -0
  174. package/web/src/brave.ts +66 -0
  175. package/web/src/cache.ts +151 -0
  176. package/web/src/config.ts +210 -0
  177. package/web/src/controls.ts +128 -0
  178. package/web/src/domains.ts +114 -0
  179. package/web/src/exa.ts +60 -0
  180. package/web/src/firecrawl.ts +160 -0
  181. package/web/src/index.ts +387 -0
  182. package/web/src/kagi.ts +73 -0
  183. package/web/src/limits.ts +55 -0
  184. package/web/src/native/convert.ts +84 -0
  185. package/web/src/native/fetch.ts +226 -0
  186. package/web/src/normalize.ts +131 -0
  187. package/web/src/obscura/cdp.ts +201 -0
  188. package/web/src/obscura/classify.ts +35 -0
  189. package/web/src/obscura/cli.ts +254 -0
  190. package/web/src/obscura/errors.ts +8 -0
  191. package/web/src/obscura/index.ts +59 -0
  192. package/web/src/obscura/limits.ts +92 -0
  193. package/web/src/obscura/mcp.ts +104 -0
  194. package/web/src/obscura/playwright.ts +133 -0
  195. package/web/src/obscura/process.ts +235 -0
  196. package/web/src/obscura/search-profile.ts +73 -0
  197. package/web/src/obscura/types.ts +48 -0
  198. package/web/src/obscura/web.ts +419 -0
  199. package/web/src/prompt-fetch.ts +88 -0
  200. package/web/src/render.ts +75 -0
  201. package/web/src/searxng.ts +78 -0
  202. package/web/src/slice.ts +139 -0
  203. package/web/src/tavily.ts +77 -0
  204. package/web/src/tools.ts +182 -0
  205. package/web/src/transport.ts +310 -0
  206. package/web/src/types.ts +199 -0
  207. package/work/README.md +77 -0
  208. package/work/THIRD_PARTY_NOTICES +24 -0
  209. package/work/docling/ocr.py +150 -0
  210. package/work/sandbox/Dockerfile +36 -0
  211. package/work/sandbox/soffice.sh +12 -0
  212. package/work/src/command.ts +29 -0
  213. package/work/src/config.ts +138 -0
  214. package/work/src/connectors/cli.ts +343 -0
  215. package/work/src/connectors/drafts.ts +647 -0
  216. package/work/src/connectors/errors.ts +8 -0
  217. package/work/src/connectors/file-bytes.ts +149 -0
  218. package/work/src/connectors/google-workspace-http.ts +399 -0
  219. package/work/src/connectors/google-workspace.ts +465 -0
  220. package/work/src/connectors/gws-tools.ts +378 -0
  221. package/work/src/connectors/http.ts +123 -0
  222. package/work/src/connectors/idempotency.ts +235 -0
  223. package/work/src/connectors/identity.ts +34 -0
  224. package/work/src/connectors/index.ts +93 -0
  225. package/work/src/connectors/limits.ts +50 -0
  226. package/work/src/connectors/m365-tools.ts +308 -0
  227. package/work/src/connectors/microsoft365-http.ts +428 -0
  228. package/work/src/connectors/microsoft365.ts +402 -0
  229. package/work/src/connectors/normalize.ts +120 -0
  230. package/work/src/connectors/tool-helpers.ts +398 -0
  231. package/work/src/connectors/tools.ts +42 -0
  232. package/work/src/connectors/types.ts +399 -0
  233. package/work/src/diagrams/canonicalize.ts +166 -0
  234. package/work/src/diagrams/caps.ts +52 -0
  235. package/work/src/diagrams/embed.ts +340 -0
  236. package/work/src/diagrams/errors.ts +76 -0
  237. package/work/src/diagrams/index.ts +81 -0
  238. package/work/src/diagrams/messages.ts +150 -0
  239. package/work/src/diagrams/origin.ts +52 -0
  240. package/work/src/diagrams/telemetry.ts +34 -0
  241. package/work/src/diagrams/xml.ts +267 -0
  242. package/work/src/document-extraction/index.ts +563 -0
  243. package/work/src/document-reader/ambient.d.ts +2 -0
  244. package/work/src/document-reader/errors.ts +7 -0
  245. package/work/src/document-reader/index.ts +181 -0
  246. package/work/src/document-reader/mistral-ocr.ts +318 -0
  247. package/work/src/document-reader/parsers.ts +171 -0
  248. package/work/src/documents/caps.ts +135 -0
  249. package/work/src/documents/diff.ts +269 -0
  250. package/work/src/documents/errors.ts +44 -0
  251. package/work/src/documents/fidelity.ts +98 -0
  252. package/work/src/documents/generate.ts +96 -0
  253. package/work/src/documents/hash.ts +8 -0
  254. package/work/src/documents/index.ts +14 -0
  255. package/work/src/documents/model-schema.ts +639 -0
  256. package/work/src/documents/parse.ts +252 -0
  257. package/work/src/documents/patch-history.ts +81 -0
  258. package/work/src/documents/patch.ts +476 -0
  259. package/work/src/documents/preview-html.ts +245 -0
  260. package/work/src/documents/preview.ts +227 -0
  261. package/work/src/documents/telemetry.ts +36 -0
  262. package/work/src/documents/translate/docx.ts +292 -0
  263. package/work/src/documents/translate/pptx.ts +304 -0
  264. package/work/src/documents/translate/xlsx.ts +137 -0
  265. package/work/src/documents/types.ts +188 -0
  266. package/work/src/index.ts +311 -0
  267. package/work/src/runtime/worker-pool-entry.ts +102 -0
  268. package/work/src/runtime/worker-pool.ts +267 -0
  269. package/work/src/sandbox/composition.ts +212 -0
  270. package/work/src/sandbox/image.ts +6 -0
  271. package/work/src/sandbox/index.ts +14 -0
  272. package/work/src/sheets/caps.ts +68 -0
  273. package/work/src/sheets/csv.ts +351 -0
  274. package/work/src/sheets/decimal.ts +388 -0
  275. package/work/src/sheets/errors.ts +52 -0
  276. package/work/src/sheets/index.ts +62 -0
  277. package/work/src/sheets/inference.ts +378 -0
  278. package/work/src/sheets/telemetry.ts +34 -0
  279. package/work/src/sheets/types.ts +162 -0
  280. package/work/src/sheets/xlsx.ts +388 -0
  281. package/work/src/skills/index.ts +8 -0
  282. package/work/src/skills/load.ts +43 -0
  283. package/work/src/tools/filesystem.ts +21 -0
  284. package/work/src/tools/index.ts +1 -0
  285. package/work/src/tools/local-filesystem.ts +33 -0
  286. package/work/src/tools/office.ts +311 -0
  287. package/work/vendor/hermes-agent/LICENSE +21 -0
  288. package/work/vendor/hermes-agent/skills/productivity/docx/LICENSE +21 -0
  289. package/work/vendor/hermes-agent/skills/productivity/docx/SKILL.md +196 -0
  290. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_comments.py +289 -0
  291. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_common.py +94 -0
  292. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_create.py +177 -0
  293. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_edit.py +250 -0
  294. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_read.py +149 -0
  295. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_revisions.py +147 -0
  296. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_template.py +70 -0
  297. package/work/vendor/hermes-agent/skills/productivity/docx/scripts/docx_validate.py +156 -0
  298. package/work/vendor/hermes-agent/skills/productivity/pdf/LICENSE +21 -0
  299. package/work/vendor/hermes-agent/skills/productivity/pdf/SKILL.md +125 -0
  300. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/_raster.py +76 -0
  301. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_marker.py +88 -0
  302. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/extract_pymupdf.py +101 -0
  303. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_create.py +130 -0
  304. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_fill_form.py +97 -0
  305. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_form_layout.py +168 -0
  306. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_make_form.py +145 -0
  307. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_merge.py +50 -0
  308. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_meta.py +115 -0
  309. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_page_image.py +99 -0
  310. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_read.py +153 -0
  311. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_secure.py +71 -0
  312. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_split.py +84 -0
  313. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_stamp.py +143 -0
  314. package/work/vendor/hermes-agent/skills/productivity/pdf/scripts/pdf_watermark.py +51 -0
  315. package/work/vendor/hermes-agent/skills/productivity/powerpoint/LICENSE +21 -0
  316. package/work/vendor/hermes-agent/skills/productivity/powerpoint/SKILL.md +220 -0
  317. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_create.py +214 -0
  318. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_edit.py +436 -0
  319. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_from_template.py +88 -0
  320. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_read.py +131 -0
  321. package/work/vendor/hermes-agent/skills/productivity/powerpoint/scripts/pptx_render.py +93 -0
  322. package/work/vendor/hermes-agent/skills/productivity/xlsx/LICENSE +21 -0
  323. package/work/vendor/hermes-agent/skills/productivity/xlsx/SKILL.md +196 -0
  324. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/csv_to_xlsx.py +104 -0
  325. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_create.py +259 -0
  326. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_edit.py +263 -0
  327. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_read.py +160 -0
  328. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_recalc.py +111 -0
  329. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_restructure.py +337 -0
  330. package/work/vendor/hermes-agent/skills/productivity/xlsx/scripts/xlsx_to_csv.py +69 -0
  331. package/work/vendor-lock.json +11 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * One page cache for every fetch backend: session-scoped, bounded, and in process memory only.
3
+ *
4
+ * `web_fetch` returns a page in slices, so a second call for the same URL in one session must not
5
+ * pay for a second fetch. With `cache` config the page is kept for `ttlSeconds` and marked
6
+ * `cached`; without it Prism keeps the plan 192 paging memo (10 minutes, 8 pages, no annotations)
7
+ * through the same implementation, so there is one cache, not two. Nothing is persisted: the
8
+ * entries live in `prism.state.agent`, which resets at package dispose, and the `session.closed`
9
+ * hook drops a session's entries so one tenant's page never answers another session.
10
+ */
11
+ import type { ToolsWebCacheConfig } from "./config.js";
12
+ import { WebToolError } from "./transport.js";
13
+ import type { FetchCache, WebDocument, WebFetchOptions } from "./types.js";
14
+
15
+ /** `cache.maxEntries` default. */
16
+ export const DEFAULT_CACHE_MAX_ENTRIES = 64;
17
+ /** `cache.maxBytes` default: 16 MiB of Markdown per agent process. */
18
+ export const DEFAULT_CACHE_MAX_BYTES = 16 * 1024 * 1024;
19
+ /** Hard bounds, so a config cannot turn the cache into unbounded memory. */
20
+ export const HARD_CACHE_TTL_SECONDS = 86_400;
21
+ export const HARD_CACHE_MAX_ENTRIES = 4_096;
22
+ export const HARD_CACHE_MAX_BYTES = 64 * 1024 * 1024;
23
+ /** The anonymous paging memo kept when no `cache` is configured (plan 192's values). */
24
+ export const MEMO_TTL_SECONDS = 600;
25
+ export const MEMO_MAX_ENTRIES = 8;
26
+
27
+ export interface ResolvedFetchCache {
28
+ readonly ttlMs: number;
29
+ readonly maxEntries: number;
30
+ readonly maxBytes: number;
31
+ /** `false` for the implicit memo: no `cached` flag, no `refresh` argument. */
32
+ readonly annotated: boolean;
33
+ }
34
+
35
+ interface CacheEntry {
36
+ readonly document: WebDocument;
37
+ readonly at: number;
38
+ readonly bytes: number;
39
+ }
40
+
41
+ /** Validates `cache`; without it the memo defaults apply, unannotated. */
42
+ export function resolveFetchCache(config: ToolsWebCacheConfig | undefined): ResolvedFetchCache {
43
+ if (config === undefined)
44
+ return {
45
+ ttlMs: MEMO_TTL_SECONDS * 1_000,
46
+ maxEntries: MEMO_MAX_ENTRIES,
47
+ maxBytes: HARD_CACHE_MAX_BYTES,
48
+ annotated: false,
49
+ };
50
+ const ttl = config.ttlSeconds;
51
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > HARD_CACHE_TTL_SECONDS)
52
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", `cache.ttlSeconds must be an integer between 1 and ${HARD_CACHE_TTL_SECONDS}`);
53
+ const maxEntries = config.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
54
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > HARD_CACHE_MAX_ENTRIES)
55
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", `cache.maxEntries must be an integer between 1 and ${HARD_CACHE_MAX_ENTRIES}`);
56
+ const maxBytes = config.maxBytes ?? DEFAULT_CACHE_MAX_BYTES;
57
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > HARD_CACHE_MAX_BYTES)
58
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", `cache.maxBytes must be an integer between 1 and ${HARD_CACHE_MAX_BYTES}`);
59
+ return { ttlMs: ttl * 1_000, maxEntries, maxBytes, annotated: true };
60
+ }
61
+
62
+ /**
63
+ * The cache itself: one LRU map keyed by session and URL, bounded by count and Markdown bytes.
64
+ * Insertion order is recency, so eviction is "delete the first key".
65
+ */
66
+ export function createFetchCache(config: ResolvedFetchCache, now: () => number = Date.now): FetchCache {
67
+ const entries = new Map<string, CacheEntry>();
68
+ const key = (sessionId: string | undefined, url: string) => (sessionId === undefined ? undefined : `${sessionId}\u0000${url}`);
69
+ let bytes = 0;
70
+ const remove = (id: string): void => {
71
+ const entry = entries.get(id);
72
+ if (entry === undefined) return;
73
+ entries.delete(id);
74
+ bytes -= entry.bytes;
75
+ };
76
+ return {
77
+ annotated: config.annotated,
78
+ read(sessionId, url, at = now()) {
79
+ const id = key(sessionId, url);
80
+ if (id === undefined) return undefined;
81
+ const entry = entries.get(id);
82
+ if (entry === undefined) return undefined;
83
+ if (at - entry.at > config.ttlMs) {
84
+ remove(id);
85
+ return undefined;
86
+ }
87
+ // Touch: delete and re-insert so this page is the newest entry.
88
+ entries.delete(id);
89
+ entries.set(id, entry);
90
+ return { document: entry.document, ageMs: Math.max(0, at - entry.at) };
91
+ },
92
+ write(sessionId, url, document) {
93
+ const id = key(sessionId, url);
94
+ if (id === undefined) return;
95
+ const size = Buffer.byteLength(document.markdown, "utf8");
96
+ remove(id);
97
+ // A page larger than the whole budget is fetched again rather than evicting everything else.
98
+ if (size > config.maxBytes) return;
99
+ entries.set(id, { document, at: now(), bytes: size });
100
+ bytes += size;
101
+ for (const oldest of entries.keys()) {
102
+ if (entries.size <= config.maxEntries && bytes <= config.maxBytes) break;
103
+ remove(oldest);
104
+ }
105
+ },
106
+ dropSession(sessionId) {
107
+ const prefix = `${sessionId}\u0000`;
108
+ for (const id of [...entries.keys()]) if (id.startsWith(prefix)) remove(id);
109
+ },
110
+ };
111
+ }
112
+
113
+ /** The cache stores the canonical URL, never the requested one, so trailing `#fragment` is one page. */
114
+ export function tryCanonicalUrl(url: string): string | undefined {
115
+ try {
116
+ const parsed = new URL(url);
117
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined;
118
+ if (parsed.username || parsed.password) return undefined;
119
+ parsed.hash = "";
120
+ return parsed.toString();
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Decorator over anything with a `fetch`: a hit makes no request. The adapter sees the original
128
+ * URL (so redirects and citations are unchanged); the cache is keyed by its canonical form.
129
+ */
130
+ export function withFetchCache<T extends { fetch(url: string, options?: WebFetchOptions): Promise<WebDocument> }>(
131
+ target: T,
132
+ cache: FetchCache,
133
+ ): T {
134
+ return {
135
+ ...target,
136
+ async fetch(url: string, options?: WebFetchOptions): Promise<WebDocument> {
137
+ const sessionId = options?.sessionId;
138
+ const canonical = sessionId === undefined ? undefined : tryCanonicalUrl(url);
139
+ if (sessionId === undefined || canonical === undefined) return target.fetch(url, options);
140
+ if (options?.refresh !== true) {
141
+ const hit = cache.read(sessionId, canonical);
142
+ if (hit !== undefined) {
143
+ return cache.annotated ? { ...hit.document, cached: true, cachedAgeMs: hit.ageMs } : hit.document;
144
+ }
145
+ }
146
+ const document = await target.fetch(url, options);
147
+ cache.write(sessionId, canonical, document);
148
+ return document;
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,210 @@
1
+ import type { JsonObject, JsonSchema, ModelRef } from "@arnilo/prism";
2
+
3
+ import type { ToolsWebDomainsConfig } from "./domains.js";
4
+
5
+ import type { ObscuraWebLimits } from "./obscura/limits.js";
6
+ import type { WebLimits } from "./types.js";
7
+
8
+ /** The search/fetch backend the package operates as. `native` is fetch-only and needs no key. */
9
+ export type ToolsWebBackend = "brave" | "exa" | "firecrawl" | "native" | "obscura" | "searxng" | "tavily" | "kagi";
10
+
11
+ /** The optional second backend used for `web_fetch` when the search backend cannot fetch. */
12
+ export interface ToolsWebFetchConfig {
13
+ /** `"native"` (default, keyless), `"firecrawl"`, or `"off"` to register no fetch tool. */
14
+ readonly backend?: "native" | "firecrawl" | "off";
15
+ /** `env:NAME` or `keyring:NAME`; a literal fails config validation. Required when `backend` is `"firecrawl"`. */
16
+ readonly apiKey?: string;
17
+ /** Replaces the native backend's `user-agent`; it still identifies Prism unless a host opts out. */
18
+ readonly userAgent?: string;
19
+ /** Adds the `prompt` argument: one model call answers a question from the fetched page. */
20
+ readonly prompt?: ToolsWebPromptConfig;
21
+ }
22
+
23
+ /** Opt-in `web_fetch` question answering: the host picks the model, the model only asks the question. */
24
+ export interface ToolsWebPromptConfig {
25
+ /** The model that reads the page; resolved once at setup. */
26
+ readonly model: ModelRef;
27
+ /** Characters of page Markdown sent to it. Default 100 000. */
28
+ readonly maxInputChars?: number;
29
+ /** Output token budget for the answer. Default 1 024. */
30
+ readonly maxOutputTokens?: number;
31
+ }
32
+
33
+ /** Obscura backend settings. `obscura` needs no key. */
34
+ export interface ToolsWebObscuraConfig {
35
+ /** Absolute path or a bare name resolved on `PATH`. Default `"obscura"`. */
36
+ readonly binary?: string;
37
+ /** Register the native `obscura_fetch`/`obscura_scrape` tools. Default `true`. */
38
+ readonly nativeTools?: boolean;
39
+ /** Bounds for the CLI-backed search/fetch/scrape children; out-of-range values fail setup. */
40
+ readonly limits?: Partial<ObscuraWebLimits>;
41
+ }
42
+
43
+ /** A self-hosted SearXNG instance: no key, no vendor, but an exact origin the host names. */
44
+ export interface ToolsWebSearxngConfig {
45
+ /** Exact origin, e.g. `https://search.example.org`; `http://` only for a loopback hostname. */
46
+ readonly origin: string;
47
+ /** Accept a plaintext origin when its hostname is loopback. Default `false`. */
48
+ readonly allowLoopback?: boolean;
49
+ }
50
+
51
+ export type { ToolsWebDomainsConfig } from "./domains.js";
52
+
53
+ /** Per-session page cache. Off unless configured; `ttlSeconds` is required when the object is present. */
54
+ export interface ToolsWebCacheConfig {
55
+ /** How long a page stays fresh, 1–86 400 seconds. */
56
+ readonly ttlSeconds: number;
57
+ /** Maximum pages kept across sessions. Default 64. */
58
+ readonly maxEntries?: number;
59
+ /** Maximum Markdown bytes kept across sessions. Default 16 MiB. */
60
+ readonly maxBytes?: number;
61
+ }
62
+
63
+ /** Schema that `web_extract` asks the extract backend to fill; validated at setup, not first use. */
64
+ export interface ToolsWebExtractConfig {
65
+ readonly schema: JsonObject;
66
+ }
67
+
68
+ /** Validated `packageConfig` for `@arnilo/prism-tools/web`. */
69
+ export interface ToolsWebConfig {
70
+ /** Required; checked in `setup` with a naming error. */
71
+ readonly backend: ToolsWebBackend;
72
+ /** `env:NAME` or `keyring:NAME`; a literal fails config validation. Required for `brave`, `exa`, and `firecrawl`; `native` and `obscura` need none. */
73
+ readonly apiKey?: string;
74
+ /** Optional second backend for `web_fetch` when `backend` is `brave` or `exa`. */
75
+ readonly fetch?: ToolsWebFetchConfig;
76
+ /** Obscura binary settings; only read when `backend` is `"obscura"`. */
77
+ readonly obscura?: ToolsWebObscuraConfig;
78
+ /** SearXNG instance settings; only read when `backend` is `"searxng"`. */
79
+ readonly searxng?: ToolsWebSearxngConfig;
80
+ /** Register `web_extract` over this schema (needs a Firecrawl key: `apiKey`, or `fetch.apiKey`). */
81
+ readonly extract?: ToolsWebExtractConfig;
82
+ /** Host-owned allow/block lists enforced on every search result and fetch target. */
83
+ readonly domains?: ToolsWebDomainsConfig;
84
+ /** Per-session page cache for `web_fetch` and `web.fetch`; absent means the short paging memo. */
85
+ readonly cache?: ToolsWebCacheConfig;
86
+ /** Byte, retry, concurrency, and timeout bounds for the HTTP adapters. */
87
+ readonly limits?: WebLimits;
88
+ }
89
+
90
+ const limitProperties: Readonly<Record<string, JsonSchema>> = {
91
+ maxQueryBytes: { type: "number", minimum: 1 },
92
+ maxResults: { type: "number", minimum: 1 },
93
+ maxUrls: { type: "number", minimum: 1 },
94
+ maxRequestBytes: { type: "number", minimum: 1 },
95
+ maxResponseBytes: { type: "number", minimum: 1 },
96
+ maxMarkdownBytes: { type: "number", minimum: 1 },
97
+ maxExtractBytes: { type: "number", minimum: 1 },
98
+ maxSchemaBytes: { type: "number", minimum: 1 },
99
+ maxAggregateBytes: { type: "number", minimum: 1 },
100
+ maxJsonDepth: { type: "number", minimum: 1 },
101
+ maxJsonProperties: { type: "number", minimum: 1 },
102
+ maxRetries: { type: "number", minimum: 0 },
103
+ maxRateLimitDelayMs: { type: "number", minimum: 1 },
104
+ maxConcurrency: { type: "number", minimum: 1 },
105
+ maxQueued: { type: "number", minimum: 1 },
106
+ maxPollingAttempts: { type: "number", minimum: 1 },
107
+ pollingDelayMs: { type: "number", minimum: 0 },
108
+ timeoutMs: { type: "number", minimum: 1 },
109
+ };
110
+
111
+ const obscuraLimitProperties: Readonly<Record<string, JsonSchema>> = {
112
+ maxQueryBytes: { type: "number", minimum: 1 },
113
+ maxResults: { type: "number", minimum: 1 },
114
+ maxUrls: { type: "number", minimum: 1 },
115
+ maxConcurrency: { type: "number", minimum: 1 },
116
+ maxOutputBytes: { type: "number", minimum: 1 },
117
+ maxStderrBytes: { type: "number", minimum: 1 },
118
+ timeoutMs: { type: "number", minimum: 1 },
119
+ maxEvalBytes: { type: "number", minimum: 1 },
120
+ };
121
+
122
+ const domainList: JsonSchema = {
123
+ type: "array",
124
+ items: { type: "string", minLength: 1, maxLength: 253 },
125
+ maxItems: 256,
126
+ };
127
+
128
+ const secretString: JsonSchema = {
129
+ type: "string",
130
+ minLength: 1,
131
+ maxLength: 4096,
132
+ pattern: "^(env|keyring):",
133
+ "x-prism-secret": true,
134
+ };
135
+
136
+ /**
137
+ * The config schema. `backend` is deliberately not declared `required`: a required field with no
138
+ * default would fail package conformance, so `setup` checks it and names the field instead.
139
+ * `apiKey` (and `fetch.apiKey`) are ref-only secret fields; a literal fails validation.
140
+ */
141
+ export const toolsWebConfigSchema: JsonSchema = {
142
+ type: "object",
143
+ properties: {
144
+ backend: { type: "string", enum: ["brave", "exa", "firecrawl", "native", "obscura", "searxng", "tavily", "kagi"] },
145
+ apiKey: secretString,
146
+ fetch: {
147
+ type: "object",
148
+ properties: {
149
+ backend: { type: "string", enum: ["native", "firecrawl", "off"], default: "native" },
150
+ apiKey: secretString,
151
+ userAgent: { type: "string", minLength: 1, maxLength: 512 },
152
+ prompt: {
153
+ type: "object",
154
+ properties: {
155
+ model: {
156
+ anyOf: [
157
+ { type: "string" },
158
+ {
159
+ type: "object",
160
+ properties: { provider: { type: "string" }, model: { type: "string" } },
161
+ required: ["provider", "model"],
162
+ },
163
+ ],
164
+ },
165
+ // No `default`: a nested default would materialize `fetch.prompt` and fail its required model.
166
+ maxInputChars: { type: "integer", minimum: 1, maximum: 1_000_000 },
167
+ maxOutputTokens: { type: "integer", minimum: 1, maximum: 32_768 },
168
+ },
169
+ required: ["model"],
170
+ },
171
+ },
172
+ },
173
+ obscura: {
174
+ type: "object",
175
+ properties: {
176
+ binary: { type: "string", minLength: 1, maxLength: 4096 },
177
+ nativeTools: { type: "boolean", default: true },
178
+ limits: { type: "object", properties: obscuraLimitProperties },
179
+ },
180
+ },
181
+ searxng: {
182
+ type: "object",
183
+ properties: {
184
+ origin: { type: "string", minLength: 1, maxLength: 4096 },
185
+ allowLoopback: { type: "boolean" },
186
+ },
187
+ required: ["origin"],
188
+ },
189
+ domains: {
190
+ type: "object",
191
+ properties: { allow: domainList, block: domainList },
192
+ },
193
+ cache: {
194
+ type: "object",
195
+ properties: {
196
+ ttlSeconds: { type: "integer", minimum: 1, maximum: 86_400 },
197
+ maxEntries: { type: "integer", minimum: 1, maximum: 4_096 },
198
+ maxBytes: { type: "integer", minimum: 1, maximum: 67_108_864 },
199
+ },
200
+ required: ["ttlSeconds"],
201
+ },
202
+ extract: {
203
+ type: "object",
204
+ // Free-form JSON Schema: the kernel subset is checked in setup, naming `extract.schema`.
205
+ properties: { schema: { type: "object", additionalProperties: true } },
206
+ required: ["schema"],
207
+ },
208
+ limits: { type: "object", properties: limitProperties },
209
+ },
210
+ };
@@ -0,0 +1,128 @@
1
+ import type { JsonObject } from "@arnilo/prism";
2
+ import { WebToolError } from "./transport.js";
3
+ import type { SearchControl, SearchControls, SearchFreshness, WebSearchControlValues } from "./types.js";
4
+
5
+ /** A search accepts at most this many domains on each side. */
6
+ export const MAX_DOMAIN_CONTROLS = 10;
7
+ export const SEARCH_FRESHNESS: readonly SearchFreshness[] = ["day", "week", "month", "year"];
8
+
9
+ /** The argument name a model sees, so an error names exactly what it asked for. */
10
+ const ARGUMENT: Record<SearchControl, string> = {
11
+ freshness: "freshness",
12
+ includeDomains: "include_domains",
13
+ excludeDomains: "exclude_domains",
14
+ country: "country",
15
+ language: "language",
16
+ offset: "offset",
17
+ };
18
+
19
+ /** A bare hostname: no scheme, path, port, wildcard, space, or operator syntax. */
20
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/iu;
21
+ const CODE_2 = /^[a-z]{2}$/iu;
22
+
23
+ /**
24
+ * Validates the controls a call asked for against one backend's support matrix. A control the
25
+ * backend cannot honor fails loudly, naming the argument — silently ignoring it would make results
26
+ * look filtered when they are not.
27
+ */
28
+ export function searchControls(supports: ReadonlySet<SearchControl>, input: object, values: WebSearchControlValues = {}): SearchControls {
29
+ const provided = input as Record<string, unknown>;
30
+ const controls: { -readonly [K in keyof SearchControls]: SearchControls[K] } = {};
31
+ for (const control of Object.keys(ARGUMENT) as SearchControl[]) {
32
+ const value = provided[control];
33
+ if (value === undefined) continue;
34
+ const argument = ARGUMENT[control];
35
+ if (!supports.has(control)) throw invalid(`${argument} is not supported by this search backend`);
36
+ switch (control) {
37
+ case "freshness": {
38
+ // A backend may support `freshness` without every value; the narrowed set is what it honors.
39
+ const allowed = values.freshness ?? SEARCH_FRESHNESS;
40
+ if (!allowed.includes(value as SearchFreshness)) throw invalid(`freshness must be one of ${allowed.join(", ")}`);
41
+ controls.freshness = value as SearchFreshness;
42
+ break;
43
+ }
44
+ case "includeDomains":
45
+ case "excludeDomains":
46
+ controls[control] = domains(value, argument);
47
+ break;
48
+ case "country":
49
+ if (typeof value !== "string" || !CODE_2.test(value)) throw invalid("country must be a two-letter ISO 3166-1 code");
50
+ controls.country = value.toLowerCase();
51
+ break;
52
+ case "language":
53
+ if (typeof value !== "string" || !CODE_2.test(value)) throw invalid("language must be a two-letter ISO 639-1 code");
54
+ controls.language = value.toLowerCase();
55
+ break;
56
+ case "offset":
57
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw invalid("offset must be a non-negative integer");
58
+ controls.offset = value as number;
59
+ break;
60
+ }
61
+ }
62
+ return controls;
63
+ }
64
+
65
+ /** Reads the tool's snake_case arguments into the typed shape `searchControls` validates. */
66
+ export function controlsFromArgs(args: JsonObject): Record<string, unknown> {
67
+ return {
68
+ freshness: args.freshness,
69
+ includeDomains: args.include_domains,
70
+ excludeDomains: args.exclude_domains,
71
+ country: args.country,
72
+ language: args.language,
73
+ offset: args.offset,
74
+ };
75
+ }
76
+
77
+ /** The `web_search` schema properties for one backend, in the model's snake_case spelling. */
78
+ export function searchControlProperties(supports: ReadonlySet<SearchControl>, values: WebSearchControlValues = {}): JsonObject {
79
+ const properties: Record<string, JsonObject> = {};
80
+ const entry = (control: SearchControl, schema: JsonObject): void => {
81
+ if (supports.has(control)) properties[ARGUMENT[control]] = schema;
82
+ };
83
+ entry("freshness", {
84
+ type: "string",
85
+ enum: [...(values.freshness ?? SEARCH_FRESHNESS)],
86
+ description: "Only results published in this window",
87
+ });
88
+ entry("includeDomains", {
89
+ type: "array",
90
+ items: { type: "string" },
91
+ maxItems: MAX_DOMAIN_CONTROLS,
92
+ description: "Only results from these hostnames",
93
+ });
94
+ entry("excludeDomains", {
95
+ type: "array",
96
+ items: { type: "string" },
97
+ maxItems: MAX_DOMAIN_CONTROLS,
98
+ description: "Drop results from these hostnames",
99
+ });
100
+ entry("country", { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code, e.g. US" });
101
+ entry("language", { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter language code, e.g. en" });
102
+ entry("offset", { type: "integer", minimum: 0, description: "Result page offset" });
103
+ return properties as JsonObject;
104
+ }
105
+
106
+ /**
107
+ * Turns domain controls into the `site:`/`-site:` operators the HTML-index backends understand.
108
+ * The operators join the query, so they count toward every query limit the provider enforces.
109
+ */
110
+ export function domainOperatorsQuery(query: string, controls: SearchControls): string {
111
+ const parts = [query];
112
+ for (const domain of controls.includeDomains ?? []) parts.push(`site:${domain}`);
113
+ for (const domain of controls.excludeDomains ?? []) parts.push(`-site:${domain}`);
114
+ return parts.join(" ");
115
+ }
116
+
117
+ function domains(value: unknown, argument: string): readonly string[] {
118
+ if (!Array.isArray(value) || value.length > MAX_DOMAIN_CONTROLS)
119
+ throw invalid(`${argument} must be an array of at most ${MAX_DOMAIN_CONTROLS} hostnames`);
120
+ return value.map((entry) => {
121
+ if (typeof entry !== "string" || !HOSTNAME.test(entry)) throw invalid(`${argument} must contain bare hostnames`);
122
+ return entry.toLowerCase();
123
+ });
124
+ }
125
+
126
+ function invalid(message: string): WebToolError {
127
+ return new WebToolError("ERR_PRISM_WEB_INPUT", message);
128
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Host-owned domain policy: which hosts web search may show and which URLs fetch/extract may touch.
3
+ * Model-supplied `include_domains` is a preference, never a permission; this is the permission.
4
+ */
5
+ import { normalizeHostname } from "@arnilo/prism/net";
6
+ import { WebToolError } from "./transport.js";
7
+
8
+ export interface ToolsWebDomainsConfig {
9
+ /** When set, only matching hosts are permitted (an empty list permits none). */
10
+ readonly allow?: readonly string[];
11
+ /** Always refused, even when `allow` matches. */
12
+ readonly block?: readonly string[];
13
+ }
14
+
15
+ export interface DomainPolicy {
16
+ /** True when `allow` was configured, so `web_search` can say results are limited. */
17
+ readonly restricted: boolean;
18
+ readonly allows: (url: string) => boolean;
19
+ /** Throws `ERR_PRISM_WEB_DOMAIN` naming the host when the URL is not permitted. */
20
+ readonly assert: (url: string) => void;
21
+ /** Keep only permitted rows; `filtered` counts the dropped ones. */
22
+ readonly filter: <T extends { readonly url: string }>(
23
+ rows: readonly T[],
24
+ ) => { readonly results: readonly T[]; readonly filtered: number };
25
+ }
26
+
27
+ const MAX_ENTRIES = 256;
28
+
29
+ /** One policy, built once per package, shared by search, fetch, extract, and the `web.fetch` service. */
30
+ export function createDomainPolicy(config: ToolsWebDomainsConfig): DomainPolicy {
31
+ const allow = config.allow === undefined ? undefined : compile(config.allow, "allow");
32
+ const block = compile(config.block ?? [], "block");
33
+
34
+ const permitted = (host: string): boolean => {
35
+ if (matches(host, block)) return false;
36
+ return allow === undefined || matches(host, allow);
37
+ };
38
+ const allows = (url: string): boolean => {
39
+ const host = hostOf(url);
40
+ // A malformed URL is not a permitted host; adapters report their own error later.
41
+ return host !== undefined && permitted(host);
42
+ };
43
+
44
+ return {
45
+ restricted: allow !== undefined,
46
+ allows,
47
+ assert: (url) => {
48
+ if (allows(url)) return;
49
+ const host = hostOf(url);
50
+ throw new WebToolError(
51
+ "ERR_PRISM_WEB_DOMAIN",
52
+ host === undefined ? "Host domain policy does not permit this URL" : `Host domain policy does not permit ${host}`,
53
+ );
54
+ },
55
+ filter: <T extends { readonly url: string }>(rows: readonly T[]) => {
56
+ const results = rows.filter((row) => allows(row.url));
57
+ return { results, filtered: rows.length - results.length };
58
+ },
59
+ };
60
+ }
61
+
62
+ interface Compiled {
63
+ readonly exact: ReadonlySet<string>;
64
+ readonly wildcard: ReadonlySet<string>;
65
+ }
66
+
67
+ function compile(entries: readonly string[], field: "allow" | "block"): Compiled {
68
+ if (entries.length > MAX_ENTRIES) throw new Error(`domains.${field} may hold at most ${MAX_ENTRIES} entries`);
69
+ const exact = new Set<string>();
70
+ const wildcard = new Set<string>();
71
+ for (const entry of entries) {
72
+ if (typeof entry !== "string") throw new Error(`domains.${field} entries must be strings`);
73
+ const trimmed = entry.trim();
74
+ const isWildcard = trimmed.startsWith("*.");
75
+ const hostPart = isWildcard ? trimmed.slice(2) : trimmed;
76
+ if (hostPart.length === 0) throw new Error(`domains.${field} contains an empty hostname`);
77
+ if (hostPart.includes("*"))
78
+ throw new Error(`domains.${field} entry ${JSON.stringify(entry)} may only use a leading "*.", not a partial wildcard`);
79
+ let host: string;
80
+ try {
81
+ const parsed = new URL(`https://${hostPart}`);
82
+ if (parsed.port !== "" || parsed.pathname !== "/" || parsed.username !== "" || parsed.search !== "" || parsed.hash !== "")
83
+ throw new Error("not a bare hostname");
84
+ host = normalizeHostname(parsed.hostname);
85
+ } catch {
86
+ throw new Error(`domains.${field} entry ${JSON.stringify(entry)} is not a bare hostname`);
87
+ }
88
+ (isWildcard ? wildcard : exact).add(host);
89
+ }
90
+ return { exact, wildcard };
91
+ }
92
+
93
+ /** Exact host, or a strict subdomain of a `*.` entry (the apex itself does not match `*.`). */
94
+ function matches(host: string, compiled: Compiled): boolean {
95
+ if (compiled.exact.has(host)) return true;
96
+ let candidate = host;
97
+ while (true) {
98
+ const dot = candidate.indexOf(".");
99
+ if (dot === -1) return false;
100
+ candidate = candidate.slice(dot + 1);
101
+ if (compiled.wildcard.has(candidate)) return true;
102
+ }
103
+ }
104
+
105
+ function hostOf(url: string): string | undefined {
106
+ let parsed: URL;
107
+ try {
108
+ parsed = new URL(url);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ if (!/^https?:$/u.test(parsed.protocol)) return undefined;
113
+ return normalizeHostname(parsed.hostname);
114
+ }
package/web/src/exa.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { searchControls } from "./controls.js";
2
+ import { normalizeSearchResults, providerFacts } from "./normalize.js";
3
+ import { boundedJson, createWebTransport, WebToolError } from "./transport.js";
4
+ import type { SearchControl, WebAdapterOptions, WebSearchAdapter } from "./types.js";
5
+
6
+ const ORIGIN = "https://api.exa.ai";
7
+ /** Exa filters domain and dates natively; it has no result paging and no language control. */
8
+ const SUPPORTS: ReadonlySet<SearchControl> = new Set(["freshness", "includeDomains", "excludeDomains", "country"]);
9
+ const FRESHNESS_DAYS: Readonly<Record<string, number>> = { day: 1, week: 7, month: 30, year: 365 };
10
+ export function createExaSearch(options: WebAdapterOptions): WebSearchAdapter {
11
+ const transport = createWebTransport("exa", ORIGIN, options);
12
+ return {
13
+ provider: "exa",
14
+ limits: transport.limits,
15
+ supports: SUPPORTS,
16
+ async search(query, call = {}) {
17
+ return transport.run(call.signal, async (signal) => {
18
+ // An `offset` or `language` request fails here, naming the argument Exa cannot honor.
19
+ const controls = searchControls(SUPPORTS, call);
20
+ const normalized = query.trim();
21
+ if (!normalized || Buffer.byteLength(normalized) > transport.limits.maxQueryBytes)
22
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", "Exa query is empty or exceeds byte limit");
23
+ const count = call.count ?? transport.limits.maxResults;
24
+ if (!Number.isSafeInteger(count) || count < 1 || count > transport.limits.maxResults)
25
+ throw new WebToolError("ERR_PRISM_WEB_INPUT", "Exa result count is invalid");
26
+ const body = JSON.stringify({
27
+ query: normalized,
28
+ numResults: count,
29
+ contents: { text: { maxCharacters: 2_000 }, highlights: { maxCharacters: 1_000 } },
30
+ ...(controls.includeDomains === undefined ? {} : { includeDomains: controls.includeDomains }),
31
+ ...(controls.excludeDomains === undefined ? {} : { excludeDomains: controls.excludeDomains }),
32
+ ...(controls.country === undefined ? {} : { userLocation: controls.country }),
33
+ ...(controls.freshness === undefined ? {} : { startPublishedDate: publishedSince(controls.freshness) }),
34
+ });
35
+ // Search is billed per request; a 429 was refused, so retrying it cannot double-bill.
36
+ const response = await transport.json("exa", "api_key", new URL("/search", ORIGIN), { method: "POST", body }, signal, {
37
+ retry: "safe-post",
38
+ });
39
+ const metadata = providerFacts(response.value, response.metadata);
40
+ return boundedJson(
41
+ {
42
+ provider: "exa" as const,
43
+ query: normalized,
44
+ results: normalizeSearchResults("exa", normalized, response.value, count),
45
+ metadata,
46
+ untrusted: true as const,
47
+ },
48
+ transport.limits.maxJsonDepth,
49
+ transport.limits.maxJsonProperties,
50
+ transport.limits.maxAggregateBytes,
51
+ );
52
+ });
53
+ },
54
+ };
55
+ }
56
+
57
+ /** Exa takes an ISO 8601 instant, so a freshness window becomes a lower bound on the publish date. */
58
+ function publishedSince(freshness: string): string {
59
+ return new Date(Date.now() - FRESHNESS_DAYS[freshness]! * 86_400_000).toISOString();
60
+ }