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.
@@ -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 })