@stratusagent/search 0.11.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stratus Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # @stratusagent/search
2
+
3
+ **`web.search`: the contract, not a backend.** One tool name, one option
4
+ meaning, one result shape — so two independently written search plugins are
5
+ interchangeable behind one soul allowlist, and swapping vendors changes no
6
+ soul, no skill, and no result shape.
7
+
8
+ This package ships **no backend and reaches no vendor.** Every backend needs
9
+ an API key and a commercial relationship, so core ships `web.fetch` and the
10
+ ecosystem ships `web.search` ([`plugins.md`](../../docs/architecture/plugins.md)).
11
+ What has to be first-party is the half that makes the other half worth
12
+ anything.
13
+
14
+ ## For an operator
15
+
16
+ You need a backend plugin. Install it, enable it in a trusted config, and
17
+ store the key:
18
+
19
+ ```bash
20
+ npm install -g stratus-plugin-somesearch
21
+ printf %s "$SEARCH_KEY" | stratus credential set search.apiKey
22
+ ```
23
+
24
+ ```jsonc
25
+ // ~/.stratus/config.json — a trusted config only
26
+ {
27
+ "plugins": {
28
+ "stratus-plugin-somesearch": { "enabled": true, "maxSearchesPerDay": 200 }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Then each agent's soul opts in, on both gates — the tool and the credential:
34
+
35
+ ```markdown
36
+ ---
37
+ id: ava
38
+ tools: [web.fetch, web.search]
39
+ credentials: [search.apiKey]
40
+ ---
41
+ ```
42
+
43
+ `tools: [web.*]` picks up search too, with no soul edit, which is the reason
44
+ the glob exists. The `credentials:` line is separate and is **not** optional:
45
+ without it every call answers "Agent ava is not allowed to access credential:
46
+ search.apiKey".
47
+
48
+ **Two backends cannot be enabled at once.** Both contribute `web.search`, and
49
+ a tool name is unique per install, so that is a load-time error naming both
50
+ packages. A deployment that genuinely needs two backends wants two daemons.
51
+
52
+ ### Settings every backend accepts
53
+
54
+ A backend's manifest must declare these in its `config` schema, because they
55
+ are read by the shared tool rather than by the backend's own code. All of
56
+ them take a per-agent override in the `agents` sub-block.
57
+
58
+ | Key | Default | What |
59
+ | --- | --- | --- |
60
+ | `maxSearchesPerDay` | `200` | Calls one agent may make in a UTC day. A query acts on nothing, but a metered API costs money and an agent in a loop can spend it. `0` refuses every call, which is how you turn one agent's search off without editing its soul. There is deliberately no "uncapped" — set a large number if that is what you mean. |
61
+ | `allowedHosts` | none | Hosts exempt from the address check. What a self-hosted backend on `localhost` needs. |
62
+ | `allowPrivateAddresses` | `false` | Reach any non-global address. Turns the SSRF protection off rather than adjusting it. |
63
+
64
+ The cap is **kept in the daemon's process**, so a restart forgives the count.
65
+ That is stated rather than hidden: its job is to stop a runaway loop, and a
66
+ loop lives inside one daemon's lifetime.
67
+
68
+ ### The credential is one name, whatever the vendor
69
+
70
+ `search.apiKey`. Always. A Brave adapter asking for `BRAVE_API_KEY` and a
71
+ Tavily adapter asking for `TAVILY_API_KEY` would mean swapping backends edits
72
+ **every soul in the fleet**, even though the `web.search` allowlist entry
73
+ never moved.
74
+
75
+ Two corollaries. Something that is *not* secret — a Google CSE engine id, a
76
+ SearXNG base URL — goes in the plugin's config block, which is not
77
+ soul-scoped. And a backend needing **no** credential is legitimate: a
78
+ self-hosted instance never calls `get`, and is conforming rather than broken.
79
+
80
+ A key belongs in the credential store, never as a literal in the config
81
+ block — a key in `stratus.config.json` is a key in a file people commit.
82
+
83
+ ## For a backend author
84
+
85
+ Implement one method. Everything else — the tool name, the schema, the
86
+ option parsing, the filtering, the envelope, the credential seam, the address
87
+ policy — is this package's, which is what makes your backend swappable with
88
+ somebody else's.
89
+
90
+ ```ts
91
+ import {
92
+ createWebSearchTool,
93
+ defineSearchProvider,
94
+ SEARCH_CREDENTIAL_NAME,
95
+ } from '@stratusagent/search';
96
+ import { requestThroughPolicy } from '@stratusagent/egress';
97
+ import type { JsonObject, Plugin } from '@stratusagent/core';
98
+
99
+ const createBackend = (config: JsonObject) => defineSearchProvider({
100
+ name: 'somesearch',
101
+ async search(query, options, context) {
102
+ const key = await context.credentials.get(SEARCH_CREDENTIAL_NAME);
103
+ const url = new URL('https://api.somesearch.example/v1/search');
104
+ url.searchParams.set('q', query); // literal text — see below
105
+ url.searchParams.set('count', String(options.count));
106
+ if (options.site) url.searchParams.set('host', options.site);
107
+ if (options.freshness) url.searchParams.set('since', options.freshness.since);
108
+
109
+ // Every request through the policy you were handed. Never derive one.
110
+ const response = await requestThroughPolicy(url.href, {
111
+ policy: context.policy,
112
+ headers: { 'x-api-key': key },
113
+ ...(context.signal ? { signal: context.signal } : {}),
114
+ });
115
+ if (response.status !== 200) {
116
+ throw new Error(`The somesearch API answered ${response.status}.`);
117
+ }
118
+ return (JSON.parse(response.body).results ?? []).map((row) => ({
119
+ title: row.title,
120
+ url: row.url,
121
+ snippet: row.description,
122
+ publishedAt: row.published,
123
+ }));
124
+ },
125
+ });
126
+
127
+ export const createPlugin = (config: JsonObject): Plugin => ({
128
+ name: 'stratus-plugin-somesearch',
129
+ setup(context) {
130
+ context.tools.register(createWebSearchTool({
131
+ provider: createBackend(config),
132
+ config,
133
+ ...(context.credentials ? { credentials: context.credentials } : {}),
134
+ // The daemon's log records that a search ran and against which
135
+ // provider — never the query, which is user content.
136
+ onSearch: (record) => context.log?.(`web.search via ${record.provider} — ${record.results} result(s)`),
137
+ }));
138
+ },
139
+ });
140
+ ```
141
+
142
+ Your `package.json` manifest declares the tool and the credential:
143
+
144
+ ```jsonc
145
+ "stratus": {
146
+ "pluginVersion": 1,
147
+ "contributes": { "tools": [{ "name": "web.search", "risk": "safe" }] },
148
+ "credentials": ["search.apiKey"],
149
+ "config": {
150
+ "type": "object",
151
+ "properties": {
152
+ "maxSearchesPerDay": { "type": "integer" },
153
+ "allowedHosts": { "type": "array", "items": { "type": "string" } },
154
+ "allowPrivateAddresses": { "type": "boolean" }
155
+ },
156
+ "additionalProperties": false
157
+ }
158
+ }
159
+ ```
160
+
161
+ Declare `risk: "safe"` if you like — **it will register `gated` anyway.**
162
+ `safe` means "run this unattended, with nobody watching", and that is not a
163
+ claim the code being judged gets to make about itself; the third-party floor
164
+ applies to `web.*` exactly as it does everywhere else.
165
+
166
+ ### The options, pinned
167
+
168
+ | Field | Type | Meaning |
169
+ | --- | --- | --- |
170
+ | `query` | `string`, required, non-empty | **Literal text.** Do whatever your provider requires — escaping, quoting, a literal-search parameter — so the upstream searches for the characters given rather than parsing operators out of them. An unbalanced quote is a search for a quote character, not a syntax error. |
171
+ | `count` | `integer`, 1–50, default 10 | A **maximum**. Returning fewer is normal; returning more is a contract violation, and this package truncates. |
172
+ | `site` | one normalized hostname | Matches that host and anything under it, on label boundaries: `example.com` matches `docs.example.com` and **not** `notexample.com`. Already lower-cased, trailing dot removed, IDN in A-label form. Not a query operator — never splice `site:` into the query. |
173
+ | `freshness` | `{ duration, ms, since }` | An age, resolved to an instant once per call. `since` is the oldest a result may be. |
174
+
175
+ A call carrying any **other** field is refused naming it. The caller is a
176
+ model writing JSON, so `freshnes: "P7D"` is a realistic mistake — and read as
177
+ "no freshness filter" it runs an unrestricted search that *succeeds*, after
178
+ which the agent says the answer is recent. A dropped option is a silently
179
+ ignored one arriving a step earlier, and this table is strict for that reason
180
+ throughout.
181
+
182
+ ### What `publishedAt` may be
183
+
184
+ An **ISO 8601 instant**, and the shape is checked before anything is parsed
185
+ — because `new Date(value)` is not a validator. It reads `03/04/2026` as
186
+ 4 March without ever saying whether the backend meant March or April, and it
187
+ silently *corrects* `2026-02-30` into 2 March. A lenient parse therefore does
188
+ not fail to date a result, it **invents** a date, and a `freshness` filter
189
+ then keeps or drops the result on the strength of it.
190
+
191
+ | Accepted | Read as |
192
+ | --- | --- |
193
+ | `2026-09-01T09:00:00Z` | itself |
194
+ | `2026-09-01T11:00:00+02:00`, `…+0200` | the same instant, in UTC |
195
+ | `2026-09-01` | UTC midnight |
196
+
197
+ Everything else becomes absent, which under `freshness` means the result is
198
+ dropped: a locale-formatted date, a day that does not exist, and a
199
+ **zone-less** `2026-09-01T10:00:00` — that last one is *local* time to
200
+ whatever machine parses it, so the same string is two different instants on
201
+ two daemons, which is the disagreement this package exists to remove. Convert
202
+ your vendor's format; that is the one job a backend cannot hand back.
203
+
204
+ A date with no time is accepted rather than refused because plenty of
205
+ backends only have a day, and refusing them would silently empty every
206
+ `freshness` search against such a backend. Reading it as midnight errs
207
+ towards calling a page slightly older than it is, which is the safe
208
+ direction.
209
+
210
+ Results older than `freshness` are excluded, **and so are results with no
211
+ known date** — which is the case every backend produces without being asked.
212
+ Leaving that unsaid would let two conforming adapters return different sets
213
+ for identical calls. The agent asked for recent results and will say the
214
+ answer is recent, and "we could not determine a date" is not evidence that it
215
+ is. The exclusion applies *only* when `freshness` is set.
216
+
217
+ **Refuse rather than approximate.** A backend that silently ignores an option
218
+ is worse than one that fails. Declare what you cannot honor and the call is
219
+ refused naming it, before a request is spent:
220
+
221
+ ```ts
222
+ defineSearchProvider({ name: 'somesearch', unsupported: ['freshness'], search })
223
+ ```
224
+
225
+ Omitting `unsupported` means you honor everything, which fails towards the
226
+ contract: you opt out deliberately rather than being quietly excused.
227
+
228
+ ### What you do not have to get right
229
+
230
+ The shared tool applies `count`, `site`, and `freshness` to whatever you
231
+ return, validates every result URL against the address policy, strips markup
232
+ from snippets, and turns `publishedAt` into a UTC instant (dropping anything
233
+ that will not parse, rather than guessing).
234
+
235
+ Pass your vendor's decorated snippet straight through — highlight markup is
236
+ expected. Inline formatting is **removed**, not replaced with a space,
237
+ because vendors highlight the matched *substring*: `un<strong>expected</strong>`
238
+ reads back as `unexpected`, and `<strong>kettle</strong>.` keeps its full
239
+ stop attached. Anything that is not inline formatting becomes a space
240
+ instead, so two table cells never fuse into `AlphaBeta`. Send the options upstream anyway
241
+ where your vendor supports them — native filtering ranks better than a filter
242
+ applied afterwards — but the guarantee does not rest on your having done so.
243
+
244
+ Freshness is fixed-length only: `P7D`, `P4W`, `PT12H`, `PT30M`. `P1M` and
245
+ `P1Y` are refused, because a month from 31 March and a year from 29 February
246
+ each have more than one defensible answer, and an adapter that converted to
247
+ 30 or 365 days would disagree with one that did not — at boundaries,
248
+ intermittently. The rejection is **positional**: `PT30M` is thirty minutes
249
+ and is fine.
250
+
251
+ ## The result envelope
252
+
253
+ ```jsonc
254
+ {
255
+ "query": "kettles",
256
+ "provider": "somesearch",
257
+ "results": [
258
+ { "title": "…", "url": "https://…", "snippet": "…", "publishedAt": "2026-09-01T09:00:00.000Z" }
259
+ ],
260
+ "untrusted": true,
261
+ "untrustedNote": "Titles and snippets below were written by the pages that own them…"
262
+ }
263
+ ```
264
+
265
+ Snippets are attacker-controlled text: written by whoever owns the page,
266
+ selected by a third-party ranker, and handed to a model that is about to
267
+ decide what to do next. **This does not solve prompt injection** and does not
268
+ claim to — it marks the boundary, because doing so is nearly free here and a
269
+ retrofit across every tool that returns third-party text is not. Whether the
270
+ marking should become a kernel concept that `web.fetch` and the MCP bridge
271
+ share is [13](../../docs/roadmap/13-search.md)'s open question, still open.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * What a search backend implements, and what it is handed per call.
3
+ *
4
+ * Published from this repository, implemented outside it. Every backend
5
+ * needs a vendor key and a commercial relationship, so the backends belong
6
+ * to the ecosystem — but the *shape* has to be first-party or the ecosystem
7
+ * is worth nothing: two independently written plugins are interchangeable
8
+ * only if a soul allowlisting `web.search` gets the same tool, the same
9
+ * options, and the same results whichever one is installed.
10
+ */
11
+ import type { ScopedCredentials } from '@stratusagent/core';
12
+ import type { EgressPolicy } from '@stratusagent/egress';
13
+ /** The tool name, in the `web` namespace on purpose — see the README. */
14
+ export declare const WEB_SEARCH_TOOL_NAME = "web.search";
15
+ /**
16
+ * The one credential name every backend asks for, whatever vendor is
17
+ * behind it.
18
+ *
19
+ * This is not tidiness. A soul's `credentials` list is enforced by the
20
+ * `CredentialResolver`, so a Brave adapter asking for `BRAVE_API_KEY` and a
21
+ * Tavily adapter asking for `TAVILY_API_KEY` would mean swapping backends
22
+ * edits every soul in the fleet — even though the `web.search` allowlist
23
+ * entry never moved. One name is what makes the swap free.
24
+ *
25
+ * A backend needing something that is not secret (a Google CSE engine id, a
26
+ * SearXNG base URL) puts it in the plugin's config block, which is not
27
+ * soul-scoped. A backend needing no credential at all never calls `get`,
28
+ * and is conforming rather than broken.
29
+ */
30
+ export declare const SEARCH_CREDENTIAL_NAME = "search.apiKey";
31
+ /**
32
+ * The options a backend may refuse by name.
33
+ *
34
+ * `count` is absent on purpose: it is a maximum, and this package truncates
35
+ * to it after the fact, so every backend honors it whether or not its
36
+ * vendor has a parameter for it.
37
+ */
38
+ export type SearchOptionName = 'site' | 'freshness';
39
+ /**
40
+ * An age, resolved once per call so every part of the request agrees on
41
+ * what "now" was.
42
+ *
43
+ * All three forms are given because backends need different ones: some take
44
+ * a coarse enum, some take a date range, and the filter this package
45
+ * applies afterwards needs the instant.
46
+ */
47
+ export interface SearchFreshness {
48
+ /** The ISO 8601 duration as the caller wrote it, normalized to upper case (`P7D`). */
49
+ duration: string;
50
+ /** That duration in milliseconds. Always a fixed length — see `parseFreshnessDuration`. */
51
+ ms: number;
52
+ /** The oldest instant a result may carry, as a UTC ISO 8601 instant. */
53
+ since: string;
54
+ }
55
+ /**
56
+ * One call's options, already parsed, validated, and normalized. A backend
57
+ * never sees the agent's raw input — that is the point of the shape.
58
+ */
59
+ export interface SearchOptions {
60
+ /**
61
+ * A **maximum**. Returning fewer is normal; returning more is a contract
62
+ * violation, and this package truncates rather than trusting.
63
+ */
64
+ count: number;
65
+ /**
66
+ * One normalized hostname — lower case, no trailing dot, IDN in its
67
+ * A-label form. Matches that host and anything under it on label
68
+ * boundaries: `example.com` matches `docs.example.com` and never
69
+ * `notexample.com`.
70
+ *
71
+ * Not a query operator. A backend without native site filtering must not
72
+ * splice `site:` into the query — the query is literal text — and can
73
+ * leave the filtering to this package, which applies it either way.
74
+ */
75
+ site?: string;
76
+ /**
77
+ * Results older than this are excluded, **and so are results with no
78
+ * known date**. A backend that cannot honor it declares `freshness` in
79
+ * `unsupported` rather than ignoring it: an agent that asked for recent
80
+ * results will say the answer is recent.
81
+ */
82
+ freshness?: SearchFreshness;
83
+ }
84
+ /**
85
+ * What one search call is handed besides the query.
86
+ *
87
+ * The third parameter is what makes per-agent keys possible at all.
88
+ * `CredentialResolver.resolve` takes an `AgentDefinition`, so a
89
+ * `search(query, options)` seam would leave one shared adapter instance no
90
+ * way to reach the calling agent's key except a factory outside the
91
+ * contract or mutable global state.
92
+ */
93
+ export interface SearchContext {
94
+ /**
95
+ * Already bound to the calling agent, and `get(name)` is the whole
96
+ * surface. An adapter has no way to *name* another agent, so it has no
97
+ * way to reach another agent's key — where handing it an
98
+ * `AgentDefinition` and a resolver would make agent isolation something
99
+ * every third-party backend author has to not get wrong.
100
+ */
101
+ credentials: ScopedCredentials;
102
+ /**
103
+ * The address policy every request this call makes must go through,
104
+ * resolved for the calling agent. Backends do not derive it: pass it
105
+ * straight to `requestThroughPolicy` from `@stratusagent/egress`. A
106
+ * self-hosted backend on `http://localhost:8888` is a legitimate
107
+ * configuration and a deliberate allowance, which is exactly the
108
+ * decision this policy already carries.
109
+ */
110
+ policy: EgressPolicy;
111
+ /**
112
+ * The turn's. A cancelled turn must stop the HTTP request, not merely
113
+ * stop waiting on it.
114
+ */
115
+ signal?: AbortSignal;
116
+ }
117
+ /**
118
+ * One result, after the backend has reshaped whatever its vendor returned.
119
+ *
120
+ * A backend's raw payload never reaches the agent. This is the whole value
121
+ * of specifying the step: a provider that reshapes its JSON must not
122
+ * reshape what every soul and skill in the fleet was written against.
123
+ */
124
+ export interface SearchResult {
125
+ title: string;
126
+ /** Absolute, `http`/`https` only. Validated against the address policy before an agent sees it. */
127
+ url: string;
128
+ /** Plain text. Markup is stripped by this package, so a backend may pass its vendor's decorated string through. */
129
+ snippet?: string;
130
+ /**
131
+ * An ISO 8601 instant in UTC when the backend has one, and absent when it
132
+ * does not — never a guess, never a locale-formatted date. Anything that
133
+ * will not parse as an instant is treated as absent, which under
134
+ * `freshness` means the result is dropped.
135
+ */
136
+ publishedAt?: string;
137
+ }
138
+ export interface SearchProvider {
139
+ /**
140
+ * What this backend is, for the daemon's log and the result envelope —
141
+ * `brave`, `tavily`, `searxng`. Not a URL and not a secret: it is written
142
+ * to a log an operator shares.
143
+ */
144
+ readonly name: string;
145
+ /**
146
+ * Options this backend cannot honor. A call using one is refused naming
147
+ * it, before a request is made.
148
+ *
149
+ * Omitted means "all of them", which is the right default because it
150
+ * fails towards the contract: a backend opts *out* deliberately rather
151
+ * than being quietly excused for having forgotten. Refusing beats
152
+ * approximating — a backend that silently ignored `freshness` would be
153
+ * worse than one that failed, because the agent's next sentence states
154
+ * the result is recent.
155
+ */
156
+ readonly unsupported?: readonly SearchOptionName[];
157
+ search(query: string, options: SearchOptions, context: SearchContext): Promise<SearchResult[]>;
158
+ }
159
+ export interface SearchProviderDefinition {
160
+ name: string;
161
+ unsupported?: readonly SearchOptionName[];
162
+ search(query: string, options: SearchOptions, context: SearchContext): Promise<SearchResult[]>;
163
+ }
164
+ /**
165
+ * Canonicalize a backend definition. The conditional spread is not
166
+ * decoration: `exactOptionalPropertyTypes` means an absent `unsupported`
167
+ * and one explicitly set to `undefined` are different types, and only the
168
+ * first one means "honors everything".
169
+ */
170
+ export declare const defineSearchProvider: (definition: SearchProviderDefinition) => SearchProvider;
171
+ //# sourceMappingURL=contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,eAAe,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,sBAAsB,kBAAkB,CAAC;AAEtD;;;;;;GAMG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,WAAW,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,CAAC;IACjB,2FAA2F;IAC3F,EAAE,EAAE,MAAM,CAAC;IACX,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,WAAW,EAAE,iBAAiB,CAAC;IAC/B;;;;;;;OAOG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,mGAAmG;IACnG,GAAG,EAAE,MAAM,CAAC;IACZ,mHAAmH;IACnH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACnD,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAChG;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC1C,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAChG;AAED;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAAI,YAAY,wBAAwB,KAAG,cAI1E,CAAC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What a search backend implements, and what it is handed per call.
3
+ *
4
+ * Published from this repository, implemented outside it. Every backend
5
+ * needs a vendor key and a commercial relationship, so the backends belong
6
+ * to the ecosystem — but the *shape* has to be first-party or the ecosystem
7
+ * is worth nothing: two independently written plugins are interchangeable
8
+ * only if a soul allowlisting `web.search` gets the same tool, the same
9
+ * options, and the same results whichever one is installed.
10
+ */
11
+ /** The tool name, in the `web` namespace on purpose — see the README. */
12
+ export const WEB_SEARCH_TOOL_NAME = 'web.search';
13
+ /**
14
+ * The one credential name every backend asks for, whatever vendor is
15
+ * behind it.
16
+ *
17
+ * This is not tidiness. A soul's `credentials` list is enforced by the
18
+ * `CredentialResolver`, so a Brave adapter asking for `BRAVE_API_KEY` and a
19
+ * Tavily adapter asking for `TAVILY_API_KEY` would mean swapping backends
20
+ * edits every soul in the fleet — even though the `web.search` allowlist
21
+ * entry never moved. One name is what makes the swap free.
22
+ *
23
+ * A backend needing something that is not secret (a Google CSE engine id, a
24
+ * SearXNG base URL) puts it in the plugin's config block, which is not
25
+ * soul-scoped. A backend needing no credential at all never calls `get`,
26
+ * and is conforming rather than broken.
27
+ */
28
+ export const SEARCH_CREDENTIAL_NAME = 'search.apiKey';
29
+ /**
30
+ * Canonicalize a backend definition. The conditional spread is not
31
+ * decoration: `exactOptionalPropertyTypes` means an absent `unsupported`
32
+ * and one explicitly set to `undefined` are different types, and only the
33
+ * first one means "honors everything".
34
+ */
35
+ export const defineSearchProvider = (definition) => ({
36
+ name: definition.name,
37
+ ...(definition.unsupported !== undefined ? { unsupported: definition.unsupported } : {}),
38
+ search: definition.search,
39
+ });
40
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,yEAAyE;AACzE,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,eAAe,CAAC;AA8ItD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,UAAoC,EAAkB,EAAE,CAAC,CAAC;IAC7F,IAAI,EAAE,UAAU,CAAC,IAAI;IACrB,GAAG,CAAC,UAAU,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACxF,MAAM,EAAE,UAAU,CAAC,MAAM;CAC1B,CAAC,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `web.search`: the contract the ecosystem implements.
3
+ *
4
+ * The backends stay outside this repository — every one of them needs a
5
+ * vendor key and a commercial relationship, so core ships `web.fetch` and
6
+ * the ecosystem ships `web.search`. What has to be first-party is the half
7
+ * that makes the other half worth anything: a fixed tool name and a fixed
8
+ * result shape, so a soul or a skill written against search keeps working
9
+ * when the operator changes vendor.
10
+ *
11
+ * The contract is specified in `docs/roadmap/13-search.md` and summarized
12
+ * in this package's README; the code here is what enforces it.
13
+ */
14
+ export { defineSearchProvider, SEARCH_CREDENTIAL_NAME, WEB_SEARCH_TOOL_NAME, type SearchContext, type SearchFreshness, type SearchOptionName, type SearchOptions, type SearchProvider, type SearchProviderDefinition, type SearchResult, } from './contract.ts';
15
+ export { hostMatchesSite, normalizeSearchSite, parseFreshnessDuration, parseSearchCall, SearchOptionError, SEARCH_COUNT_DEFAULT, SEARCH_COUNT_MAX, type ParsedSearchCall, } from './options.ts';
16
+ export { normalizePublishedAt, normalizeSearchResults, plainSnippet, } from './results.ts';
17
+ export { createWebSearchTool, DEFAULT_MAX_SEARCHES_PER_DAY, UNTRUSTED_RESULT_NOTE, type SearchLogRecord, type WebSearchToolOptions, } from './tool.ts';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACpB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,wBAAwB,EAC7B,KAAK,YAAY,GAClB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,sBAAsB,EACtB,eAAe,EACf,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,KAAK,gBAAgB,GACtB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,YAAY,GACb,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,oBAAoB,GAC1B,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `web.search`: the contract the ecosystem implements.
3
+ *
4
+ * The backends stay outside this repository — every one of them needs a
5
+ * vendor key and a commercial relationship, so core ships `web.fetch` and
6
+ * the ecosystem ships `web.search`. What has to be first-party is the half
7
+ * that makes the other half worth anything: a fixed tool name and a fixed
8
+ * result shape, so a soul or a skill written against search keeps working
9
+ * when the operator changes vendor.
10
+ *
11
+ * The contract is specified in `docs/roadmap/13-search.md` and summarized
12
+ * in this package's README; the code here is what enforces it.
13
+ */
14
+ export { defineSearchProvider, SEARCH_CREDENTIAL_NAME, WEB_SEARCH_TOOL_NAME, } from "./contract.js";
15
+ export { hostMatchesSite, normalizeSearchSite, parseFreshnessDuration, parseSearchCall, SearchOptionError, SEARCH_COUNT_DEFAULT, SEARCH_COUNT_MAX, } from "./options.js";
16
+ export { normalizePublishedAt, normalizeSearchResults, plainSnippet, } from "./results.js";
17
+ export { createWebSearchTool, DEFAULT_MAX_SEARCHES_PER_DAY, UNTRUSTED_RESULT_NOTE, } from "./tool.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,GAQrB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,sBAAsB,EACtB,eAAe,EACf,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,GAEjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,YAAY,GACb,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,mBAAmB,EACnB,4BAA4B,EAC5B,qBAAqB,GAGtB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Every option pinned to a meaning.
3
+ *
4
+ * Naming the options is not specifying them: two adapters can both satisfy
5
+ * `{ search(query, options) }` while accepting incompatible calls, which
6
+ * defeats the one criterion this package exists for. So the parsing lives
7
+ * here, once, in front of every backend — a call that means one thing on
8
+ * Brave means the same thing on SearXNG because neither of them parsed it.
9
+ */
10
+ import type { JsonObject } from '@stratusagent/core';
11
+ import { type SearchOptions } from './contract.ts';
12
+ export declare const SEARCH_COUNT_DEFAULT = 10;
13
+ export declare const SEARCH_COUNT_MAX = 50;
14
+ /**
15
+ * A call this contract refuses. Exported because a backend throws it too:
16
+ * an option it declared `unsupported` and a value its vendor rejects are
17
+ * the same kind of answer to the agent.
18
+ */
19
+ export declare class SearchOptionError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ /**
23
+ * An ISO 8601 duration of fixed length, in milliseconds.
24
+ *
25
+ * **Fixed-length only, because `P1M` and `P1Y` are not one length.**
26
+ * Calendar arithmetic is where two conforming adapters part company:
27
+ * subtracting a month from 31 March, or a year from 29 February, has more
28
+ * than one defensible answer, and an adapter that converts to 30 or 365
29
+ * days instead disagrees with one that does not — at boundaries,
30
+ * intermittently, which is the worst way for a difference to show up.
31
+ * Rejecting the two ambiguous designators costs nothing a caller wants:
32
+ * `P30D` and `P365D` say what someone asking for "a month" or "a year" of
33
+ * search results actually means, and say it identically everywhere.
34
+ *
35
+ * `M` means two things and only one of them is rejected. ISO 8601 uses the
36
+ * same letter for months in the date portion and minutes after the `T`, so
37
+ * "reject `M`" would throw out `PT30M`, which is fixed-length and perfectly
38
+ * well defined.
39
+ */
40
+ export declare const parseFreshnessDuration: (raw: string) => number;
41
+ /**
42
+ * One hostname, normalized so two adapters compare the same string.
43
+ *
44
+ * **Hostname with label-boundary matching, not "registrable domain".** A
45
+ * registrable domain is the public-suffix-plus-one form, so
46
+ * `docs.example.com` is not one — an adapter enforcing that term would
47
+ * reject it or widen it up to `example.com`, silently broadening a search
48
+ * another adapter would keep narrow. This needs no public-suffix list,
49
+ * which is a dependency worth not acquiring. The two-label minimum is what
50
+ * stops `site: com`.
51
+ */
52
+ export declare const normalizeSearchSite: (raw: string) => string;
53
+ /**
54
+ * Whether one result's host is inside a `site` filter.
55
+ *
56
+ * On label boundaries, which is the whole rule: `notexample.com` ends with
57
+ * `example.com` as a string and is a different company.
58
+ */
59
+ export declare const hostMatchesSite: (host: string, site: string) => boolean;
60
+ export interface ParsedSearchCall {
61
+ query: string;
62
+ options: SearchOptions;
63
+ }
64
+ /**
65
+ * One tool call's input, turned into the options a backend receives.
66
+ *
67
+ * The query is passed through as **literal text**. "Verbatim" was the wrong
68
+ * word for it and the two halves could not both be true: most search APIs
69
+ * parse their own operators out of the query field, so handing the string
70
+ * over untouched is precisely what lets the upstream reinterpret `site:`,
71
+ * `OR`, a minus sign, or a quote — while a backend that escapes them is no
72
+ * longer passing anything verbatim. What is required is the *meaning*: the
73
+ * query is literal, and each backend does whatever its provider needs to
74
+ * make that true. An unbalanced quote is a search for a quote character,
75
+ * not a syntax error.
76
+ */
77
+ export declare const parseSearchCall: (input: JsonObject, now: Date) => ParsedSearchCall;
78
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAErD,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAE/F,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAoBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,sBAAsB,GAAI,KAAK,MAAM,KAAG,MAsBpD,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,KAAG,MA0BjD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,OAG5D,CAAC;AAkBF,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,UAAU,EAAE,KAAK,IAAI,KAAG,gBA6C9D,CAAC"}