opencode-search-plugin 0.0.0-stage → 1.0.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 +21 -0
- package/README.md +229 -2
- package/README.zh-CN.md +230 -0
- package/index.ts +59 -0
- package/package.json +57 -4
- package/plugins/anysearch-search.ts +227 -0
- package/plugins/brave-search.ts +270 -0
- package/plugins/jina-search.ts +242 -0
- package/plugins/linkup-search.ts +213 -0
- package/plugins/metaso-search.ts +268 -0
- package/plugins/querit-search.ts +290 -0
- package/plugins/serpapi-search.ts +269 -0
- package/plugins/serper-search.ts +272 -0
- package/plugins/youcom-search.ts +313 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
// AnySearch wraps every response in an envelope: code 0 / "success" on the happy
|
|
5
|
+
// path, code -1 with a human-readable message otherwise. request_id is the only
|
|
6
|
+
// handle support has on a request, so it rides along on every failure. Result
|
|
7
|
+
// fields are optional -- a missing one means "the source provided none", not an
|
|
8
|
+
// error.
|
|
9
|
+
const AnySearchResponse = Schema.Struct({
|
|
10
|
+
code: Schema.Number,
|
|
11
|
+
message: Schema.optional(Schema.String),
|
|
12
|
+
request_id: Schema.optional(Schema.String),
|
|
13
|
+
data: Schema.optional(
|
|
14
|
+
Schema.Struct({
|
|
15
|
+
results: Schema.optional(
|
|
16
|
+
Schema.Array(
|
|
17
|
+
Schema.Struct({
|
|
18
|
+
title: Schema.optional(Schema.String),
|
|
19
|
+
url: Schema.String,
|
|
20
|
+
snippet: Schema.optional(Schema.String),
|
|
21
|
+
content: Schema.optional(Schema.String),
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
30
|
+
const ID = "anysearch"
|
|
31
|
+
|
|
32
|
+
// AnySearch returns no publication date, so results carry an empty time record.
|
|
33
|
+
function toResult(item: {
|
|
34
|
+
title?: string | undefined
|
|
35
|
+
url: string
|
|
36
|
+
snippet?: string | undefined
|
|
37
|
+
content?: string | undefined
|
|
38
|
+
}) {
|
|
39
|
+
return {
|
|
40
|
+
url: item.url,
|
|
41
|
+
title: item.title || item.url,
|
|
42
|
+
content: item.content ?? item.snippet ?? "",
|
|
43
|
+
time: {},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
48
|
+
|
|
49
|
+
function envInt(name: string, fallback: number): number {
|
|
50
|
+
const raw = (process.env[name] ?? "").trim()
|
|
51
|
+
if (raw === "") return fallback
|
|
52
|
+
const value = Number(raw)
|
|
53
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function envStr(name: string): string {
|
|
57
|
+
return (process.env[name] ?? "").trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// AnySearch is callable without a key: anonymous traffic is rate-limited per IP
|
|
61
|
+
// against the daily free quota, and a key raises the quota. Each query is a
|
|
62
|
+
// fresh upstream dispatch that routes intent on its own, so responses are
|
|
63
|
+
// cached per normalized query. Knobs are read once at load time, restart after
|
|
64
|
+
// changing them.
|
|
65
|
+
//
|
|
66
|
+
// ANYSEARCH_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
67
|
+
// ANYSEARCH_MAX_RESULTS results per search, clamped to 1-10 (the API's range)
|
|
68
|
+
// ANYSEARCH_TAG pin the intent router to a sub-domain, e.g. "code.doc";
|
|
69
|
+
// leave unset and AnySearch routes on the query itself
|
|
70
|
+
// ANYSEARCH_ZONE "cn" or "intl"
|
|
71
|
+
// ANYSEARCH_LANGUAGE preferred result language, e.g. "zh-CN"
|
|
72
|
+
// ANYSEARCH_FORMAT "markdown" (default) or "json" for the content field
|
|
73
|
+
// ANYSEARCH_API_KEY optional; anonymous access works without it
|
|
74
|
+
const CACHE_TTL = envInt("ANYSEARCH_CACHE_TTL", 600_000)
|
|
75
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("ANYSEARCH_MAX_RESULTS", 10), 1), 10)
|
|
76
|
+
const TAG = envStr("ANYSEARCH_TAG")
|
|
77
|
+
const ZONE = envStr("ANYSEARCH_ZONE")
|
|
78
|
+
const LANGUAGE = envStr("ANYSEARCH_LANGUAGE")
|
|
79
|
+
const FORMAT = envStr("ANYSEARCH_FORMAT") === "json" ? "json" : "markdown"
|
|
80
|
+
|
|
81
|
+
const CACHE_LIMIT = 200
|
|
82
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
83
|
+
|
|
84
|
+
function cacheKey(query: string): string {
|
|
85
|
+
return query.trim().replace(/\s+/g, " ")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
89
|
+
if (CACHE_TTL <= 0) return undefined
|
|
90
|
+
const hit = cache.get(queryKey)
|
|
91
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
95
|
+
if (CACHE_TTL <= 0) return
|
|
96
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
97
|
+
if (cache.size > CACHE_LIMIT) {
|
|
98
|
+
const oldest = cache.keys().next().value
|
|
99
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Unlike the other providers in this repo, AnySearch never gates on `bound`:
|
|
104
|
+
// anonymous access is a first-class mode, so the provider is always in the
|
|
105
|
+
// pool. The key is still read per call, which is why no credential-event
|
|
106
|
+
// subscription is needed -- a rotated key takes effect on the next search
|
|
107
|
+
// without a reload.
|
|
108
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
// Make "AnySearch" show up in /connect with an API-key method.
|
|
111
|
+
yield* ctx.integration.transform((editor) => {
|
|
112
|
+
editor.update(ID, (integration) => {
|
|
113
|
+
integration.name = "AnySearch"
|
|
114
|
+
})
|
|
115
|
+
editor.method.update({
|
|
116
|
+
integrationID: ID,
|
|
117
|
+
method: { type: "key", label: "AnySearch API key" },
|
|
118
|
+
})
|
|
119
|
+
editor.method.update({
|
|
120
|
+
integrationID: ID,
|
|
121
|
+
method: { type: "env", names: ["ANYSEARCH_API_KEY"] },
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
126
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
127
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
128
|
+
// environment fallback below applies exactly as it does when the credential
|
|
129
|
+
// simply does not exist.
|
|
130
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
131
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
132
|
+
|
|
133
|
+
yield* ctx.websearch.transform((editor) => {
|
|
134
|
+
editor.add({
|
|
135
|
+
id: ID,
|
|
136
|
+
name: "AnySearch",
|
|
137
|
+
execute: ({ query }) =>
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
const queryKey = cacheKey(query)
|
|
140
|
+
const cached = readCache(queryKey)
|
|
141
|
+
if (cached) return cached
|
|
142
|
+
|
|
143
|
+
// Anonymous access is a first-class mode, so a missing key is not
|
|
144
|
+
// an error: it just means no Authorization header goes out.
|
|
145
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
146
|
+
const credential = connection
|
|
147
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
148
|
+
: undefined
|
|
149
|
+
const key =
|
|
150
|
+
credential?.type === "key" ? credential.key : process.env.ANYSEARCH_API_KEY
|
|
151
|
+
|
|
152
|
+
const headers: Record<string, string> = {
|
|
153
|
+
Accept: "application/json",
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
}
|
|
156
|
+
if (key) headers.Authorization = "Bearer " + key
|
|
157
|
+
|
|
158
|
+
const response = yield* Effect.tryPromise({
|
|
159
|
+
try: () =>
|
|
160
|
+
fetch("https://api.anysearch.com/v1/search", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers,
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
query: queryKey,
|
|
165
|
+
max_results: SEARCH_SIZE,
|
|
166
|
+
...(TAG ? { tag: TAG } : {}),
|
|
167
|
+
...(ZONE ? { zone: ZONE } : {}),
|
|
168
|
+
...(LANGUAGE ? { language: LANGUAGE } : {}),
|
|
169
|
+
format: FORMAT,
|
|
170
|
+
}),
|
|
171
|
+
}),
|
|
172
|
+
catch: (error) => new Error("AnySearch request failed: " + String(error)),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
// A 402 body can hand back freshly generated credentials, so it
|
|
177
|
+
// is deliberately never echoed into the conversation.
|
|
178
|
+
if (response.status === 402) {
|
|
179
|
+
return yield* Effect.fail(
|
|
180
|
+
new Error(
|
|
181
|
+
"AnySearch quota exhausted (HTTP 402). Set ANYSEARCH_API_KEY " +
|
|
182
|
+
"or connect AnySearch in /connect to continue. The response " +
|
|
183
|
+
"body is withheld because it may contain credentials.",
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
const detail = yield* Effect.tryPromise({
|
|
188
|
+
try: () => response.text(),
|
|
189
|
+
catch: () => new Error("response body unavailable"),
|
|
190
|
+
})
|
|
191
|
+
return yield* Effect.fail(
|
|
192
|
+
new Error("AnySearch API error " + response.status + ": " + detail),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const json = yield* Effect.tryPromise({
|
|
197
|
+
try: () => response.json(),
|
|
198
|
+
catch: (error) => new Error("Invalid AnySearch response: " + String(error)),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const body = yield* Schema.decodeUnknownEffect(AnySearchResponse)(json)
|
|
202
|
+
|
|
203
|
+
// AnySearch reports failures in the envelope too, even on HTTP
|
|
204
|
+
// 200. request_id is what support needs to trace the request.
|
|
205
|
+
if (body.code !== 0) {
|
|
206
|
+
return yield* Effect.fail(
|
|
207
|
+
new Error(
|
|
208
|
+
"AnySearch API error " +
|
|
209
|
+
body.code +
|
|
210
|
+
": " +
|
|
211
|
+
(body.message ?? "unknown error") +
|
|
212
|
+
" (request_id " +
|
|
213
|
+
(body.request_id ?? "unknown") +
|
|
214
|
+
")",
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const results = (body.data?.results ?? []).map(toResult)
|
|
220
|
+
writeCache(queryKey, results)
|
|
221
|
+
return results
|
|
222
|
+
}),
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
export default Plugin.define({ id: "anysearch-search", effect })
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Brave returns { web: { results: [...] } } beside several result types this
|
|
5
|
+
// plugin does not read (news, videos, discussions, infobox). Failures arrive as
|
|
6
|
+
// plain HTTP status codes with no body-level envelope. Every result field is
|
|
7
|
+
// optional -- a missing one means "Brave provided none", not an error.
|
|
8
|
+
const BraveResponse = Schema.Struct({
|
|
9
|
+
web: Schema.optional(
|
|
10
|
+
Schema.Struct({
|
|
11
|
+
results: Schema.Array(
|
|
12
|
+
Schema.Struct({
|
|
13
|
+
title: Schema.String,
|
|
14
|
+
url: Schema.String,
|
|
15
|
+
description: Schema.optional(Schema.String),
|
|
16
|
+
page_age: Schema.optional(Schema.String),
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
24
|
+
const ID = "brave"
|
|
25
|
+
|
|
26
|
+
function envInt(name: string, fallback: number): number {
|
|
27
|
+
const raw = (process.env[name] ?? "").trim()
|
|
28
|
+
if (raw === "") return fallback
|
|
29
|
+
const value = Number(raw)
|
|
30
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envStr(name: string): string {
|
|
34
|
+
return (process.env[name] ?? "").trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Search engines hand out dates in three flavours: relative ("28 minutes ago"),
|
|
38
|
+
// absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
|
|
39
|
+
// relative ("4天前"). Relative values are rounded upstream, so month/year use
|
|
40
|
+
// 30/365 days. ISO 8601 timestamps are accepted as-is.
|
|
41
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
42
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
43
|
+
minute: 60_000,
|
|
44
|
+
hour: 3_600_000,
|
|
45
|
+
day: 86_400_000,
|
|
46
|
+
week: 604_800_000,
|
|
47
|
+
month: 2_592_000_000,
|
|
48
|
+
year: 31_536_000_000,
|
|
49
|
+
分钟: 60_000,
|
|
50
|
+
小时: 3_600_000,
|
|
51
|
+
天: 86_400_000,
|
|
52
|
+
周: 604_800_000,
|
|
53
|
+
个月: 2_592_000_000,
|
|
54
|
+
月: 2_592_000_000,
|
|
55
|
+
年: 31_536_000_000,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RELATIVE_DATE =
|
|
59
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
60
|
+
|
|
61
|
+
const ABSOLUTE_DATE = [
|
|
62
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
63
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
64
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
65
|
+
// ISO 8601 with a time component, which Brave returns on `page_age`.
|
|
66
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
function parseSearchDate(value: string | undefined): number {
|
|
70
|
+
const text = value?.trim()
|
|
71
|
+
if (!text) return NaN
|
|
72
|
+
|
|
73
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
74
|
+
if (relative) {
|
|
75
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
76
|
+
const amount = Number(relative[1])
|
|
77
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
78
|
+
return Date.now() - amount * unit
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
82
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
83
|
+
// to 2001.
|
|
84
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toResult(item: {
|
|
88
|
+
title: string
|
|
89
|
+
url: string
|
|
90
|
+
description?: string | undefined
|
|
91
|
+
page_age?: string | undefined
|
|
92
|
+
}) {
|
|
93
|
+
const published = parseSearchDate(item.page_age)
|
|
94
|
+
return {
|
|
95
|
+
url: item.url,
|
|
96
|
+
title: item.title,
|
|
97
|
+
content: item.description ?? "",
|
|
98
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
103
|
+
|
|
104
|
+
// Brave bills per query and has no anonymous tier, so a repeat query is a fresh
|
|
105
|
+
// billed dispatch. Responses are cached per normalized query. Knobs are read
|
|
106
|
+
// once at load time, restart after changing them.
|
|
107
|
+
//
|
|
108
|
+
// BRAVE_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
109
|
+
// BRAVE_NUM results per search, clamped to 1-20 (Brave's maximum)
|
|
110
|
+
// BRAVE_COUNTRY 2-letter country code, e.g. "us", "cn"; or "ALL"
|
|
111
|
+
// BRAVE_LANG result language code, e.g. "en", "zh-hans", "pt-br"
|
|
112
|
+
// BRAVE_FRESHNESS "pd" (24h) "pw" (7d) "pm" (31d) "py" (365d) or a
|
|
113
|
+
// "YYYY-MM-DDtoYYYY-MM-DD" range
|
|
114
|
+
// BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
|
|
115
|
+
const CACHE_TTL = envInt("BRAVE_CACHE_TTL", 600_000)
|
|
116
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("BRAVE_NUM", 8), 1), 20)
|
|
117
|
+
const COUNTRY = envStr("BRAVE_COUNTRY")
|
|
118
|
+
const SEARCH_LANG = envStr("BRAVE_LANG")
|
|
119
|
+
const FRESHNESS = envStr("BRAVE_FRESHNESS")
|
|
120
|
+
|
|
121
|
+
const CACHE_LIMIT = 200
|
|
122
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
123
|
+
|
|
124
|
+
function cacheKey(query: string): string {
|
|
125
|
+
return query.trim().replace(/\s+/g, " ")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
129
|
+
if (CACHE_TTL <= 0) return undefined
|
|
130
|
+
const hit = cache.get(queryKey)
|
|
131
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
135
|
+
if (CACHE_TTL <= 0) return
|
|
136
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
137
|
+
if (cache.size > CACHE_LIMIT) {
|
|
138
|
+
const oldest = cache.keys().next().value
|
|
139
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
144
|
+
Effect.gen(function* () {
|
|
145
|
+
// Make "Brave Search" show up in /connect with an API-key method.
|
|
146
|
+
yield* ctx.integration.transform((editor) => {
|
|
147
|
+
editor.update(ID, (integration) => {
|
|
148
|
+
integration.name = "Brave Search"
|
|
149
|
+
})
|
|
150
|
+
editor.method.update({
|
|
151
|
+
integrationID: ID,
|
|
152
|
+
method: { type: "key", label: "Brave Search API key" },
|
|
153
|
+
})
|
|
154
|
+
editor.method.update({
|
|
155
|
+
integrationID: ID,
|
|
156
|
+
method: { type: "env", names: ["BRAVE_SEARCH_API_KEY"] },
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
161
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
162
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
163
|
+
// environment fallback below applies exactly as it does when the credential
|
|
164
|
+
// simply does not exist.
|
|
165
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
166
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
167
|
+
|
|
168
|
+
// The key comes from the /connect credential, falling back to the
|
|
169
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
170
|
+
const resolveKey = () =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
173
|
+
const credential = connection
|
|
174
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
175
|
+
: undefined
|
|
176
|
+
return credential?.type === "key" ? credential.key : process.env.BRAVE_SEARCH_API_KEY
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Brave is bound to websearch only while a key exists. Deleting the key
|
|
180
|
+
// unbinds the provider, so `random` can never pick a keyless Brave.
|
|
181
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
182
|
+
let bound = Boolean(yield* resolveKey())
|
|
183
|
+
|
|
184
|
+
yield* ctx.websearch.transform((editor) => {
|
|
185
|
+
if (!bound) return
|
|
186
|
+
editor.add({
|
|
187
|
+
id: ID,
|
|
188
|
+
name: "Brave Search",
|
|
189
|
+
execute: ({ query }) =>
|
|
190
|
+
Effect.gen(function* () {
|
|
191
|
+
const queryKey = cacheKey(query)
|
|
192
|
+
const cached = readCache(queryKey)
|
|
193
|
+
if (cached) return cached
|
|
194
|
+
|
|
195
|
+
// Rechecked per call: the binding can lag a key deletion by the
|
|
196
|
+
// event round-trip, and this keeps that window explicit.
|
|
197
|
+
const key = yield* resolveKey()
|
|
198
|
+
if (!key) {
|
|
199
|
+
return yield* Effect.fail(
|
|
200
|
+
new Error("Connect Brave in /connect, or set BRAVE_SEARCH_API_KEY"),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search")
|
|
205
|
+
url.searchParams.set("q", queryKey)
|
|
206
|
+
url.searchParams.set("count", String(SEARCH_SIZE))
|
|
207
|
+
if (COUNTRY) url.searchParams.set("country", COUNTRY)
|
|
208
|
+
if (SEARCH_LANG) url.searchParams.set("search_lang", SEARCH_LANG)
|
|
209
|
+
if (FRESHNESS) url.searchParams.set("freshness", FRESHNESS)
|
|
210
|
+
|
|
211
|
+
const response = yield* Effect.tryPromise({
|
|
212
|
+
try: () =>
|
|
213
|
+
fetch(url, {
|
|
214
|
+
headers: {
|
|
215
|
+
Accept: "application/json",
|
|
216
|
+
"X-Subscription-Token": key,
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
catch: (error) => new Error("Brave request failed: " + String(error)),
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
const detail = yield* Effect.tryPromise({
|
|
224
|
+
try: () => response.text(),
|
|
225
|
+
catch: () => new Error("response body unavailable"),
|
|
226
|
+
})
|
|
227
|
+
return yield* Effect.fail(
|
|
228
|
+
new Error("Brave API error " + response.status + ": " + detail),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const json = yield* Effect.tryPromise({
|
|
233
|
+
try: () => response.json(),
|
|
234
|
+
catch: (error) => new Error("Invalid Brave response: " + String(error)),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const body = yield* Schema.decodeUnknownEffect(BraveResponse)(json)
|
|
238
|
+
const results = (body.web?.results ?? []).map(toResult)
|
|
239
|
+
|
|
240
|
+
writeCache(queryKey, results)
|
|
241
|
+
return results
|
|
242
|
+
}),
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
247
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
248
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
249
|
+
yield* Effect.forkScoped(
|
|
250
|
+
ctx.event.subscribe().pipe(
|
|
251
|
+
Stream.filter(
|
|
252
|
+
(event) =>
|
|
253
|
+
event.type === "credential.updated" ||
|
|
254
|
+
event.type === "credential.switched" ||
|
|
255
|
+
event.type === "integration.updated",
|
|
256
|
+
),
|
|
257
|
+
Stream.mapEffect(() =>
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const next = Boolean(yield* resolveKey())
|
|
260
|
+
if (next === bound) return
|
|
261
|
+
bound = next
|
|
262
|
+
yield* ctx.websearch.reload()
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
Stream.runDrain,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
export default Plugin.define({ id: "brave-search", effect })
|