@bitbaum/ai-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -287,6 +287,52 @@ stop paying for a form library.
287
287
  React lives on its own subpath and is an **optional** peer, so importing
288
288
  `@bitbaum/ai-kit` on a server never pulls in a UI library.
289
289
 
290
+ ### Seeing the web — search and read, as citable facts
291
+
292
+ ```ts
293
+ import { webSearch, readPage, resultsToFacts, describeEmptySearch } from "@bitbaum/ai-kit/web";
294
+ import { assignFactIds, renderFacts } from "@bitbaum/ai-kit/grounding";
295
+
296
+ const found = await webSearch("lightning address spec", { limit: 5 });
297
+ if (found.status !== "found") {
298
+ return describeEmptySearch(found); // the model is told WHICH of the two this is
299
+ }
300
+ const facts = assignFactIds(resultsToFacts(found.results, found.provider));
301
+ ```
302
+
303
+ Backends are a chain, in the same shape and for the same reason as the model
304
+ chain: `searxng` (self-hosted, no key, no third party told what your users are
305
+ looking for) → `brave` (an independent index, so the fallback is not the same
306
+ index asked twice) → `tavily`. Each is included only if its env is set, so the
307
+ common case is one call rather than a policy re-decided in every app.
308
+
309
+ **The answer is three-valued, and that is the whole point.** `found`; `nothing`
310
+ (a backend answered and had nothing — a real negative the model may state); and
311
+ `could_not_look` (nobody answered — an outage, which the model must **not**
312
+ report as a fact about the world). Collapsing the last two into `[]` is how an
313
+ expired API key becomes a confident sentence about what does not exist on the
314
+ internet. A backend that answers `200` with zero results is walked past rather
315
+ than believed, because that is exactly what a self-hosted metasearch instance
316
+ does when its upstream engines refuse it.
317
+
318
+ `readPage()` is the other half — search returns titles and one sentence, and
319
+ almost every real question needs the page. It fetches with `redirect: "manual"`
320
+ and re-runs the **full SSRF check on every hop**, because an agent picks its own
321
+ URLs: any page it reads can hand it a link to the cloud metadata service, and
322
+ validating the first URL and then letting `fetch` follow the 302 is not a check
323
+ at all. Truncation is stated in the result rather than implied, so a summary can
324
+ say "the first part of the page" instead of implying it read all of it.
325
+
326
+ Both produce `Fact`s from `/grounding` rather than a bespoke shape, so retrieved
327
+ web content and retrieved database rows flow through **one** verifier and "cite
328
+ your sources" becomes a string check instead of a line in a prompt. Search
329
+ without citation binding makes hallucination worse, not better: the invented
330
+ sentence is now surrounded by real ones.
331
+
332
+ It does not summarise, re-rank with an LLM, crawl, render JavaScript, or cache.
333
+ The first two are the model's job and belong upstream where the app's prompt
334
+ lives; the last three are a different product with a different cost profile.
335
+
290
336
  ---
291
337
 
292
338
  ## What it deliberately does not ship
@@ -54,6 +54,13 @@ export const FACT_KINDS = {
54
54
  assignment: ["title", "assignee", "status", "due", "fee", "why"],
55
55
  document: ["title", "source", "excerpt"],
56
56
  pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
57
+ // Retrieved from the open web rather than stored. `url` is the field that
58
+ // makes these checkable by a human reader, which is what separates a cited
59
+ // claim from a confident one. The rest are declared precisely BECAUSE search
60
+ // engines so often omit them: a rendered `published: <not recorded>` is what
61
+ // stops a model putting a year on an undated page.
62
+ web_result: ["title", "url", "published", "snippet", "engine"],
63
+ web_page: ["title", "url", "retrieved", "truncated"],
57
64
  };
58
65
  /** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
59
66
  export function declaredFields(kind, fallback = []) {
@@ -149,8 +149,15 @@ export interface AiHealthHandlerOptions extends LivenessOptions {
149
149
  * When absent, the handler NEVER probes — it only reports passive health.
150
150
  * That default is deliberate: an app that forgets to configure a secret gets
151
151
  * a route that cannot spend money, rather than an open endpoint that can.
152
+ *
153
+ * Pass a FUNCTION to read it per request. A handler is normally built once
154
+ * and reused (its cache has to live somewhere), so a plain string is captured
155
+ * at that moment — which means the secret is whatever the environment held on
156
+ * the first request, and rotating it needs a process restart. A getter also
157
+ * makes the route testable: with a captured string, the first test that runs
158
+ * without a secret configured pins every later one to 501.
152
159
  */
153
- secret?: string;
160
+ secret?: string | (() => string | undefined);
154
161
  /** Passive health to report alongside. Optional. */
155
162
  health?: HealthTracker;
156
163
  }
package/dist/liveness.js CHANGED
@@ -189,17 +189,20 @@ export function createAiHealthHandler(options = {}) {
189
189
  if (!wantsProbe) {
190
190
  return json(200, { probed: false, ...passive });
191
191
  }
192
+ // Read per request when a getter was given, so rotating the secret does not
193
+ // need a restart and a route built before the env was set is not stuck.
194
+ const expected = typeof secret === "function" ? secret() : secret;
192
195
  // No secret configured means probing is switched off, which is a different
193
196
  // answer from "your secret is wrong" — say so, rather than implying the
194
197
  // caller could retry with a better credential.
195
- if (!secret) {
198
+ if (!expected) {
196
199
  return json(501, {
197
200
  probed: false,
198
201
  error: "Probing is not configured on this deployment (no secret set).",
199
202
  ...passive,
200
203
  });
201
204
  }
202
- if (!offered || !timingSafeEqual(offered, secret)) {
205
+ if (!offered || !timingSafeEqual(offered, expected)) {
203
206
  return json(401, { probed: false, error: "Bad or missing probe secret.", ...passive });
204
207
  }
205
208
  const result = await probe.run();
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Web results → grounded Facts.
3
+ *
4
+ * This is the join that makes web search safe to give an agent. On its own,
5
+ * search makes hallucination WORSE, not better: the model now has a pile of
6
+ * plausible-sounding text from strangers, mixed into a context window beside
7
+ * the user's real data, with nothing marking which sentence came from where.
8
+ * The failure mode is not "the model made something up" — it is "the model
9
+ * attributed a real sentence to the wrong source", and that is indistinguishable
10
+ * from competence until someone clicks the link.
11
+ *
12
+ * The fix is the one the grounding harness already implements for database
13
+ * rows. Every retrieved item becomes a `Fact` with a citation handle ([F3]) and
14
+ * a `url` field, `verifyAnswer` then checks mechanically that every handle in
15
+ * the answer exists and that proper nouns and numbers in the answer appear in
16
+ * the evidence. A claim that cites nothing, or cites [F9] when only F1–F4 were
17
+ * retrieved, is caught by a string check rather than by a reviewer's judgement.
18
+ *
19
+ * Two sets come out of here on purpose:
20
+ *
21
+ * facts — the citable records. Short, uniform, one block per source.
22
+ * evidence — the actual prose (snippets, page text). The verifier needs the
23
+ * words themselves to judge whether "€4.2 billion" in the answer
24
+ * was read or invented; a fact's five short fields are not enough
25
+ * of a corpus for that, and passing the page text AS a fact field
26
+ * would bury the citable metadata in ten thousand characters.
27
+ */
28
+ import { type Fact } from "../grounding/facts.js";
29
+ import type { PageOutcome, WebResult } from "./types.js";
30
+ import type { WebSearchResult } from "./search.js";
31
+ /** Search hits as citable records. Order is the engine's ranking, preserved. */
32
+ export declare function resultsToFacts(results: WebResult[], provider: string): Fact[];
33
+ /** One fetched page as a citable record. The prose goes to `pageEvidence`. */
34
+ export declare function pageToFact(page: Extract<PageOutcome, {
35
+ ok: true;
36
+ }>): Fact;
37
+ /**
38
+ * The prose a claim may draw on, one block per source, each labelled with the
39
+ * fact id that licenses citing it.
40
+ *
41
+ * `facts` must be the ID-ASSIGNED array (post `assignFactIds`) and must line up
42
+ * positionally with `results`. Passing un-assigned facts produces blocks labelled
43
+ * `[]`, which is the bug this note exists to prevent.
44
+ */
45
+ export declare function resultsEvidence(facts: Fact[], results: WebResult[]): string[];
46
+ /** A fetched page's readable text as one evidence block, labelled with its id. */
47
+ export declare function pageEvidence(fact: Fact, page: Extract<PageOutcome, {
48
+ ok: true;
49
+ }>): string;
50
+ /**
51
+ * The sentence a model is shown when a lookup produced no usable answer.
52
+ *
53
+ * Written as an INSTRUCTION rather than a status code because that is what the
54
+ * model actually acts on, and because the two cases need opposite replies: a
55
+ * genuine "nothing out there" may be reported as a finding, while a backend
56
+ * outage may not be reported at all — the honest reply is that we could not
57
+ * look. Handing both to the model as `results: []` is how a broken API key
58
+ * becomes a confident statement about the state of the world.
59
+ */
60
+ export declare function describeEmptySearch(outcome: WebSearchResult): string;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Web results → grounded Facts.
3
+ *
4
+ * This is the join that makes web search safe to give an agent. On its own,
5
+ * search makes hallucination WORSE, not better: the model now has a pile of
6
+ * plausible-sounding text from strangers, mixed into a context window beside
7
+ * the user's real data, with nothing marking which sentence came from where.
8
+ * The failure mode is not "the model made something up" — it is "the model
9
+ * attributed a real sentence to the wrong source", and that is indistinguishable
10
+ * from competence until someone clicks the link.
11
+ *
12
+ * The fix is the one the grounding harness already implements for database
13
+ * rows. Every retrieved item becomes a `Fact` with a citation handle ([F3]) and
14
+ * a `url` field, `verifyAnswer` then checks mechanically that every handle in
15
+ * the answer exists and that proper nouns and numbers in the answer appear in
16
+ * the evidence. A claim that cites nothing, or cites [F9] when only F1–F4 were
17
+ * retrieved, is caught by a string check rather than by a reviewer's judgement.
18
+ *
19
+ * Two sets come out of here on purpose:
20
+ *
21
+ * facts — the citable records. Short, uniform, one block per source.
22
+ * evidence — the actual prose (snippets, page text). The verifier needs the
23
+ * words themselves to judge whether "€4.2 billion" in the answer
24
+ * was read or invented; a fact's five short fields are not enough
25
+ * of a corpus for that, and passing the page text AS a fact field
26
+ * would bury the citable metadata in ten thousand characters.
27
+ */
28
+ import { makeFact } from "../grounding/facts.js";
29
+ /** Search hits as citable records. Order is the engine's ranking, preserved. */
30
+ export function resultsToFacts(results, provider) {
31
+ return results.map((r) => makeFact({
32
+ kind: "web_result",
33
+ subject: r.title,
34
+ source: `web search (${provider})`,
35
+ values: {
36
+ title: r.title,
37
+ url: r.url,
38
+ published: r.published ?? null,
39
+ snippet: r.snippet || null,
40
+ engine: r.engine ?? provider,
41
+ },
42
+ }));
43
+ }
44
+ /** One fetched page as a citable record. The prose goes to `pageEvidence`. */
45
+ export function pageToFact(page) {
46
+ return makeFact({
47
+ kind: "web_page",
48
+ subject: page.title || page.url,
49
+ source: "page read",
50
+ values: {
51
+ title: page.title || null,
52
+ url: page.url,
53
+ retrieved: new Date().toISOString().slice(0, 10),
54
+ // Stated rather than implied: a model told the text is partial will say
55
+ // "the first part of the page" instead of summarising a page it half read.
56
+ truncated: page.truncated ? "yes — only the first part of the page was read" : "no",
57
+ },
58
+ });
59
+ }
60
+ /**
61
+ * The prose a claim may draw on, one block per source, each labelled with the
62
+ * fact id that licenses citing it.
63
+ *
64
+ * `facts` must be the ID-ASSIGNED array (post `assignFactIds`) and must line up
65
+ * positionally with `results`. Passing un-assigned facts produces blocks labelled
66
+ * `[]`, which is the bug this note exists to prevent.
67
+ */
68
+ export function resultsEvidence(facts, results) {
69
+ return results.map((r, i) => {
70
+ const id = facts[i]?.id ?? "";
71
+ const head = `${id ? `[${id}] ` : ""}${r.title} — ${r.url}`;
72
+ return r.snippet ? `${head}\n${r.snippet}` : head;
73
+ });
74
+ }
75
+ /** A fetched page's readable text as one evidence block, labelled with its id. */
76
+ export function pageEvidence(fact, page) {
77
+ const head = `[${fact.id}] ${page.title || page.url} — ${page.url}`;
78
+ const tail = page.truncated ? "\n[…the rest of the page was not read]" : "";
79
+ return `${head}\n${page.text}${tail}`;
80
+ }
81
+ /**
82
+ * The sentence a model is shown when a lookup produced no usable answer.
83
+ *
84
+ * Written as an INSTRUCTION rather than a status code because that is what the
85
+ * model actually acts on, and because the two cases need opposite replies: a
86
+ * genuine "nothing out there" may be reported as a finding, while a backend
87
+ * outage may not be reported at all — the honest reply is that we could not
88
+ * look. Handing both to the model as `results: []` is how a broken API key
89
+ * becomes a confident statement about the state of the world.
90
+ */
91
+ export function describeEmptySearch(outcome) {
92
+ if (outcome.status === "nothing") {
93
+ return (`The web search for "${outcome.query}" ran and returned no results. ` +
94
+ `You may tell the user nothing was found for that phrasing, and suggest a different one. ` +
95
+ `Do NOT present this as proof that the thing does not exist.`);
96
+ }
97
+ const reasons = outcome.attempts
98
+ .map((a) => `${a.provider}: ${a.failure?.reason ?? a.outcome}`)
99
+ .join(" | ");
100
+ return (`The web search for "${outcome.query}" COULD NOT BE PERFORMED (${reasons}). ` +
101
+ `Tell the user plainly that you could not search the web right now. ` +
102
+ `You must NOT say that nothing was found, that no such thing exists, or describe any web content — ` +
103
+ `you have not seen any.`);
104
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * ai-kit/web — the agent's eyes on the open web.
3
+ *
4
+ * Why this belongs in this package rather than in each app. Every AI product
5
+ * in the fleet is about to need the same four things, and each one is a
6
+ * distinct way to be wrong:
7
+ *
8
+ * 1. WHICH backend. Self-hosted metasearch, an independent index, an
9
+ * agent-oriented API — with the same fallback-chain problem `chain.ts`
10
+ * already solved for models, for the same reason: a single pinned
11
+ * backend is a scheduled outage.
12
+ * 2. WHETHER it answered. "Found nothing" and "could not look" are different
13
+ * answers, and an app that collapses them ships confident negatives it
14
+ * never earned.
15
+ * 3. Fetching a page an AGENT chose, which is a genuinely different security
16
+ * problem from fetching one a user typed — the model can be steered to a
17
+ * URL by any page it reads, so the SSRF guard has to survive redirects and
18
+ * DNS rebinding rather than checking a string once.
19
+ * 4. CITATIONS. Search without citation binding makes hallucination worse,
20
+ * because now the invented sentence is surrounded by real ones.
21
+ *
22
+ * Four chances to get it wrong, times every app, is the duplication this
23
+ * package exists to end. The output type is deliberately `Fact` from
24
+ * `ai-kit/grounding` rather than a bespoke shape: retrieved web content and
25
+ * retrieved database rows then flow through ONE verifier, and "cite your
26
+ * sources" becomes a mechanical check instead of a line in a prompt.
27
+ *
28
+ * What it deliberately does not do: summarise, re-rank with an LLM, crawl,
29
+ * render JavaScript, or cache. The first two are the model's job and belong
30
+ * upstream where the app's own prompt lives; the last three are a different
31
+ * product with a different cost profile.
32
+ */
33
+ export type { WebResult, WebFailure, SearchOutcome, PageOutcome, SearchProvider, SearchOptions, ReadOptions, WebEnv, } from "./types.js";
34
+ export { webSearch, describeAttempts, type WebSearchResult, type SearchAttempt, type WebSearchDeps, } from "./search.js";
35
+ export { searxngProvider, braveProvider, tavilyProvider, defaultProviders } from "./providers.js";
36
+ export { readPage, extractReadableText, type ReadDeps, type ExtractedPage } from "./read.js";
37
+ export { validateFetchTarget, isPrivateAddress, defaultLookup, type LookupFn, type UrlVerdict, } from "./ssrf.js";
38
+ export { resultsToFacts, pageToFact, resultsEvidence, pageEvidence, describeEmptySearch, } from "./facts.js";
@@ -0,0 +1,5 @@
1
+ export { webSearch, describeAttempts, } from "./search.js";
2
+ export { searxngProvider, braveProvider, tavilyProvider, defaultProviders } from "./providers.js";
3
+ export { readPage, extractReadableText } from "./read.js";
4
+ export { validateFetchTarget, isPrivateAddress, defaultLookup, } from "./ssrf.js";
5
+ export { resultsToFacts, pageToFact, resultsEvidence, pageEvidence, describeEmptySearch, } from "./facts.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Search backends.
3
+ *
4
+ * Three, on purpose, and in this order of preference:
5
+ *
6
+ * searxng — self-hosted, no API key, no per-call cost, no third party told
7
+ * what your users are looking for. A metasearch front end over the
8
+ * engines you enable. The catch is that it is scraping on your
9
+ * behalf from your own IP, so a datacenter host will sometimes be
10
+ * refused by upstream engines; that failure is loud here, which is
11
+ * why the chain exists.
12
+ * brave — an independent index (not a Bing or Google reseller) with a free
13
+ * tier, so the fallback is not the same index the primary was
14
+ * already asking.
15
+ * tavily — built for agents: it returns cleaned content rather than SERP
16
+ * chrome. Last resort because it is the most expensive per call.
17
+ *
18
+ * The seam is one method wide deliberately. Every provider here also sells
19
+ * "AI answers", news verticals and summarisation; adopting any of those would
20
+ * make the backends non-interchangeable, and an interchangeable backend is the
21
+ * entire reason to have a seam. Summarising is the model's job and it happens
22
+ * upstream of this file.
23
+ *
24
+ * None of them throws. A provider that cannot answer returns a typed failure,
25
+ * because the chain has to tell "no results" apart from "no answer" to pick
26
+ * its next move — and so does the model reading the result.
27
+ */
28
+ import type { SearchProvider, WebEnv } from "./types.js";
29
+ declare const DEFAULT_LIMIT = 8;
30
+ declare const DEFAULT_TIMEOUT_MS = 8000;
31
+ declare const MAX_LIMIT = 20;
32
+ type Fetch = typeof globalThis.fetch;
33
+ export declare function searxngProvider(env: WebEnv, doFetch?: Fetch): SearchProvider;
34
+ export declare function braveProvider(env: WebEnv, doFetch?: Fetch): SearchProvider;
35
+ export declare function tavilyProvider(env: WebEnv, doFetch?: Fetch): SearchProvider;
36
+ /**
37
+ * The default chain, in preference order, filtered to what the env configures.
38
+ *
39
+ * A caller that wants a different order passes its own array — this function
40
+ * exists so that the common case ("use whatever we have") is one call and not
41
+ * a policy decision re-made in every app, which is how the fleet ended up with
42
+ * one model-fallback implementation per repo.
43
+ */
44
+ export declare function defaultProviders(env: WebEnv, doFetch?: Fetch): SearchProvider[];
45
+ export { DEFAULT_LIMIT, DEFAULT_TIMEOUT_MS, MAX_LIMIT };
@@ -0,0 +1,246 @@
1
+ const DEFAULT_LIMIT = 8;
2
+ const DEFAULT_TIMEOUT_MS = 8_000;
3
+ const MAX_LIMIT = 20;
4
+ function clampLimit(limit) {
5
+ if (!limit || !Number.isFinite(limit))
6
+ return DEFAULT_LIMIT;
7
+ return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit)));
8
+ }
9
+ /** `site:` is spelled the same by every engine here, so it is applied once. */
10
+ function applySite(query, site) {
11
+ const trimmed = site?.trim();
12
+ if (!trimmed)
13
+ return query;
14
+ return `${query} site:${trimmed.replace(/^https?:\/\//, "").replace(/\/.*$/, "")}`;
15
+ }
16
+ function transportFailure(err) {
17
+ const name = err instanceof Error ? err.name : "";
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ if (name === "TimeoutError" || name === "AbortError") {
20
+ return { kind: "timeout", reason: "The search backend did not answer in time." };
21
+ }
22
+ return { kind: "unreachable", reason: `The search backend could not be reached (${message}).` };
23
+ }
24
+ /** HTTP status → failure kind. Shared because every provider gets this wrong the same way. */
25
+ function statusFailure(status, provider) {
26
+ if (status === 429) {
27
+ return { kind: "rate_limited", reason: `${provider} is rate-limiting this key right now.` };
28
+ }
29
+ if (status === 401 || status === 403) {
30
+ return { kind: "auth", reason: `${provider} rejected the credentials (${status}).` };
31
+ }
32
+ return { kind: "bad_response", reason: `${provider} answered ${status}.` };
33
+ }
34
+ function trimResult(raw) {
35
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
36
+ if (!url || !/^https?:\/\//i.test(url)) {
37
+ return null;
38
+ }
39
+ const title = typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : url;
40
+ const snippet = typeof raw.snippet === "string" ? raw.snippet.trim().slice(0, 600) : "";
41
+ const published = typeof raw.published === "string" && raw.published.trim() ? raw.published.trim() : undefined;
42
+ const engine = typeof raw.engine === "string" && raw.engine.trim() ? raw.engine.trim() : undefined;
43
+ return {
44
+ title: title.slice(0, 300),
45
+ url,
46
+ snippet,
47
+ ...(published ? { published } : {}),
48
+ ...(engine ? { engine } : {}),
49
+ };
50
+ }
51
+ // ── SearXNG ────────────────────────────────────────────────────────────────
52
+ // Needs `formats: [html, json]` in the instance's settings.yml — a stock
53
+ // instance serves HTML only and answers a JSON request with 403, which is
54
+ // reported here as an auth failure so the operator sees the real fix rather
55
+ // than "no results".
56
+ export function searxngProvider(env, doFetch = globalThis.fetch) {
57
+ const base = env.SEARXNG_URL?.trim().replace(/\/+$/, "");
58
+ return {
59
+ name: "searxng",
60
+ configured: () => Boolean(base),
61
+ async search(query, opts) {
62
+ if (!base) {
63
+ return {
64
+ ok: false,
65
+ provider: "searxng",
66
+ query,
67
+ failure: { kind: "not_configured", reason: "SEARXNG_URL is not set." },
68
+ };
69
+ }
70
+ const url = new URL(`${base}/search`);
71
+ url.searchParams.set("q", applySite(query, opts.site));
72
+ url.searchParams.set("format", "json");
73
+ url.searchParams.set("safesearch", "0");
74
+ if (opts.lang) {
75
+ url.searchParams.set("language", opts.lang);
76
+ }
77
+ try {
78
+ const res = await doFetch(url.toString(), {
79
+ headers: {
80
+ Accept: "application/json",
81
+ ...(env.SEARXNG_TOKEN ? { Authorization: `Bearer ${env.SEARXNG_TOKEN}` } : {}),
82
+ },
83
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
84
+ });
85
+ if (!res.ok) {
86
+ return {
87
+ ok: false,
88
+ provider: "searxng",
89
+ query,
90
+ failure: statusFailure(res.status, "SearXNG"),
91
+ };
92
+ }
93
+ const body = (await res.json());
94
+ const rows = Array.isArray(body.results) ? body.results : [];
95
+ const results = rows
96
+ .map((r) => {
97
+ const row = r;
98
+ return trimResult({
99
+ title: row.title,
100
+ url: row.url,
101
+ snippet: row.content,
102
+ published: row.publishedDate,
103
+ engine: row.engine,
104
+ });
105
+ })
106
+ .filter((r) => r !== null)
107
+ .slice(0, clampLimit(opts.limit));
108
+ return { ok: true, provider: "searxng", query, results };
109
+ }
110
+ catch (err) {
111
+ return { ok: false, provider: "searxng", query, failure: transportFailure(err) };
112
+ }
113
+ },
114
+ };
115
+ }
116
+ // ── Brave ──────────────────────────────────────────────────────────────────
117
+ export function braveProvider(env, doFetch = globalThis.fetch) {
118
+ const key = env.BRAVE_SEARCH_API_KEY?.trim();
119
+ return {
120
+ name: "brave",
121
+ configured: () => Boolean(key),
122
+ async search(query, opts) {
123
+ if (!key) {
124
+ return {
125
+ ok: false,
126
+ provider: "brave",
127
+ query,
128
+ failure: { kind: "not_configured", reason: "BRAVE_SEARCH_API_KEY is not set." },
129
+ };
130
+ }
131
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
132
+ url.searchParams.set("q", applySite(query, opts.site));
133
+ url.searchParams.set("count", String(clampLimit(opts.limit)));
134
+ if (opts.lang) {
135
+ url.searchParams.set("search_lang", opts.lang.split("-")[0] ?? opts.lang);
136
+ }
137
+ try {
138
+ const res = await doFetch(url.toString(), {
139
+ headers: { Accept: "application/json", "X-Subscription-Token": key },
140
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
141
+ });
142
+ if (!res.ok) {
143
+ return {
144
+ ok: false,
145
+ provider: "brave",
146
+ query,
147
+ failure: statusFailure(res.status, "Brave Search"),
148
+ };
149
+ }
150
+ const body = (await res.json());
151
+ const rows = Array.isArray(body.web?.results) ? body.web.results : [];
152
+ const results = rows
153
+ .map((r) => {
154
+ const row = r;
155
+ return trimResult({
156
+ title: row.title,
157
+ url: row.url,
158
+ snippet: row.description,
159
+ published: row.page_age,
160
+ engine: "brave",
161
+ });
162
+ })
163
+ .filter((r) => r !== null);
164
+ return { ok: true, provider: "brave", query, results };
165
+ }
166
+ catch (err) {
167
+ return { ok: false, provider: "brave", query, failure: transportFailure(err) };
168
+ }
169
+ },
170
+ };
171
+ }
172
+ // ── Tavily ─────────────────────────────────────────────────────────────────
173
+ export function tavilyProvider(env, doFetch = globalThis.fetch) {
174
+ const key = env.TAVILY_API_KEY?.trim();
175
+ return {
176
+ name: "tavily",
177
+ configured: () => Boolean(key),
178
+ async search(query, opts) {
179
+ if (!key) {
180
+ return {
181
+ ok: false,
182
+ provider: "tavily",
183
+ query,
184
+ failure: { kind: "not_configured", reason: "TAVILY_API_KEY is not set." },
185
+ };
186
+ }
187
+ try {
188
+ const res = await doFetch("https://api.tavily.com/search", {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ Authorization: `Bearer ${key}`,
193
+ },
194
+ body: JSON.stringify({
195
+ query: applySite(query, opts.site),
196
+ max_results: clampLimit(opts.limit),
197
+ search_depth: "basic",
198
+ }),
199
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
200
+ });
201
+ if (!res.ok) {
202
+ return {
203
+ ok: false,
204
+ provider: "tavily",
205
+ query,
206
+ failure: statusFailure(res.status, "Tavily"),
207
+ };
208
+ }
209
+ const body = (await res.json());
210
+ const rows = Array.isArray(body.results) ? body.results : [];
211
+ const results = rows
212
+ .map((r) => {
213
+ const row = r;
214
+ return trimResult({
215
+ title: row.title,
216
+ url: row.url,
217
+ snippet: row.content,
218
+ published: row.published_date,
219
+ engine: "tavily",
220
+ });
221
+ })
222
+ .filter((r) => r !== null);
223
+ return { ok: true, provider: "tavily", query, results };
224
+ }
225
+ catch (err) {
226
+ return { ok: false, provider: "tavily", query, failure: transportFailure(err) };
227
+ }
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * The default chain, in preference order, filtered to what the env configures.
233
+ *
234
+ * A caller that wants a different order passes its own array — this function
235
+ * exists so that the common case ("use whatever we have") is one call and not
236
+ * a policy decision re-made in every app, which is how the fleet ended up with
237
+ * one model-fallback implementation per repo.
238
+ */
239
+ export function defaultProviders(env, doFetch = globalThis.fetch) {
240
+ return [
241
+ searxngProvider(env, doFetch),
242
+ braveProvider(env, doFetch),
243
+ tavilyProvider(env, doFetch),
244
+ ].filter((p) => p.configured());
245
+ }
246
+ export { DEFAULT_LIMIT, DEFAULT_TIMEOUT_MS, MAX_LIMIT };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * readPage — fetch one web page and return its readable text.
3
+ *
4
+ * This is the other half of search. A result list gives an agent titles and a
5
+ * sentence of snippet; almost every real question needs the page. Without a
6
+ * reader the agent either answers from the snippet (which is how a plausible
7
+ * wrong number gets into a reply) or says it cannot know, and both are worse
8
+ * than reading.
9
+ *
10
+ * Three things it refuses to do, each because the alternative failed somewhere:
11
+ *
12
+ * - It never follows a redirect automatically. Every hop goes back through the
13
+ * SSRF guard, because a public URL that 302s to localhost is the cheapest
14
+ * bypass there is.
15
+ * - It never returns partial bytes as though they were the page. When the cap
16
+ * is hit the result says `truncated: true`, so a model summarising it can say
17
+ * "the first part of the page says…" instead of implying it read the whole.
18
+ * - It never throws. The caller is an agent loop; an unreadable page is a fact
19
+ * about the world to reason about, not an exception to unwind through.
20
+ *
21
+ * Extraction is deliberately regex-based and dependency-free. A DOM parser
22
+ * would read more sites (SPAs especially) at the cost of making this package
23
+ * depend on one, and the failure it would fix — a JS-rendered page yielding
24
+ * nothing — is honestly reportable: empty text with `ok: true` tells the model
25
+ * the page had nothing readable, which is true and useful.
26
+ */
27
+ import type { PageOutcome, ReadOptions } from "./types.js";
28
+ import { type LookupFn } from "./ssrf.js";
29
+ export type ReadDeps = {
30
+ fetch?: typeof globalThis.fetch;
31
+ lookup?: LookupFn;
32
+ };
33
+ export declare function readPage(rawUrl: string, opts?: ReadOptions, deps?: ReadDeps): Promise<PageOutcome>;
34
+ export type ExtractedPage = {
35
+ title: string;
36
+ text: string;
37
+ truncated: boolean;
38
+ };
39
+ /**
40
+ * Strip a document to the text a reader would see.
41
+ *
42
+ * Order matters and is load-bearing: comments go first (they can contain
43
+ * unbalanced tags that desync a later pass), then whole non-content elements
44
+ * WITH their contents, then remaining tags, then entities. Doing entities
45
+ * before tag-stripping would turn an encoded `&lt;script&gt;` in page text into
46
+ * a real tag mid-pass.
47
+ */
48
+ export declare function extractReadableText(html: string, maxChars?: number): ExtractedPage;