@bitbaum/ai-kit 0.15.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 +46 -0
- package/dist/grounding/facts.js +7 -0
- package/dist/web/facts.d.ts +60 -0
- package/dist/web/facts.js +104 -0
- package/dist/web/index.d.ts +38 -0
- package/dist/web/index.js +5 -0
- package/dist/web/providers.d.ts +45 -0
- package/dist/web/providers.js +246 -0
- package/dist/web/read.d.ts +48 -0
- package/dist/web/read.js +227 -0
- package/dist/web/search.d.ts +61 -0
- package/dist/web/search.js +65 -0
- package/dist/web/ssrf.d.ts +32 -0
- package/dist/web/ssrf.js +171 -0
- package/dist/web/types.d.ts +105 -0
- package/dist/web/types.js +16 -0
- package/dist-cjs/grounding/facts.js +7 -0
- package/package.json +5 -1
- package/src/grounding/facts.ts +7 -0
- package/src/web/facts.ts +117 -0
- package/src/web/index.ts +70 -0
- package/src/web/providers.ts +293 -0
- package/src/web/read.ts +284 -0
- package/src/web/search.ts +129 -0
- package/src/web/ssrf.ts +175 -0
- package/src/web/types.ts +89 -0
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
|
package/dist/grounding/facts.js
CHANGED
|
@@ -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 = []) {
|
|
@@ -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 `<script>` in page text into
|
|
46
|
+
* a real tag mid-pass.
|
|
47
|
+
*/
|
|
48
|
+
export declare function extractReadableText(html: string, maxChars?: number): ExtractedPage;
|