opencode-search-plugin 2.1.1 → 3.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/index.ts CHANGED
@@ -3,12 +3,14 @@ import { Effect } from "effect"
3
3
  import type { Scope } from "effect"
4
4
 
5
5
  import { effect as anysearch } from "./plugins/anysearch-search"
6
+ import { effect as bocha } from "./plugins/bocha-search"
6
7
  import { effect as brave } from "./plugins/brave-search"
7
8
  import { effect as deepseek } from "./plugins/deepseek-search"
8
9
  import { effect as jina } from "./plugins/jina-search"
9
10
  import { effect as keenable } from "./plugins/keenable-search"
10
11
  import { effect as linkup } from "./plugins/linkup-search"
11
12
  import { effect as metaso } from "./plugins/metaso-search"
13
+ import { effect as ollama } from "./plugins/ollama-search"
12
14
  import { effect as querit } from "./plugins/querit-search"
13
15
  import { effect as serpapi } from "./plugins/serpapi-search"
14
16
  import { effect as serper } from "./plugins/serper-search"
@@ -17,7 +19,7 @@ import { effect as youcom } from "./plugins/youcom-search"
17
19
  type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
18
20
 
19
21
  /**
20
- * The eleven providers this package registers, in alphabetical order. Each entry
22
+ * The thirteen providers this package registers, in alphabetical order. Each entry
21
23
  * is the same effect its standalone file uses as a default export, so a file
22
24
  * copied out of this repository behaves exactly as it does here.
23
25
  *
@@ -28,12 +30,14 @@ type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.
28
30
  */
29
31
  const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
30
32
  ["osp-anysearch", anysearch],
33
+ ["osp-bocha", bocha],
31
34
  ["osp-brave", brave],
32
35
  ["osp-deepseek", deepseek],
33
36
  ["osp-jina", jina],
34
37
  ["osp-keenable", keenable],
35
38
  ["osp-linkup", linkup],
36
39
  ["osp-metaso", metaso],
40
+ ["osp-ollama", ollama],
37
41
  ["osp-querit", querit],
38
42
  ["osp-serpapi", serpapi],
39
43
  ["osp-serper", serper],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-search-plugin",
3
- "version": "2.1.1",
4
- "description": "Eleven self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
3
+ "version": "3.0.0",
4
+ "description": "Thirteen self-contained websearch provider plugins for OpenCode v2: AnySearch, Bocha, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Ollama, Querit, SerpApi, Serper and You.com.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -23,11 +23,14 @@
23
23
  "exports": {
24
24
  ".": "./index.ts",
25
25
  "./anysearch-search": "./plugins/anysearch-search.ts",
26
+ "./bocha-search": "./plugins/bocha-search.ts",
26
27
  "./brave-search": "./plugins/brave-search.ts",
28
+ "./deepseek-search": "./plugins/deepseek-search.ts",
27
29
  "./jina-search": "./plugins/jina-search.ts",
28
30
  "./keenable-search": "./plugins/keenable-search.ts",
29
31
  "./linkup-search": "./plugins/linkup-search.ts",
30
32
  "./metaso-search": "./plugins/metaso-search.ts",
33
+ "./ollama-search": "./plugins/ollama-search.ts",
31
34
  "./querit-search": "./plugins/querit-search.ts",
32
35
  "./serpapi-search": "./plugins/serpapi-search.ts",
33
36
  "./serper-search": "./plugins/serper-search.ts",
@@ -73,8 +73,12 @@ function envStr(name: string): string {
73
73
  // ANYSEARCH_ZONE "cn" or "intl"
74
74
  // ANYSEARCH_LANGUAGE preferred result language, e.g. "zh-CN"
75
75
  // ANYSEARCH_FORMAT "markdown" (default) or "json" for the content field
76
- // ANYSEARCH_API_KEY optional; anonymous access works without it
76
+ // ANYSEARCH_TIMEOUT ms to wait before failing (0 removes the deadline)
77
+ // OSP_ANYSEARCH_API_KEY optional; anonymous access works without it
77
78
  const CACHE_TTL = envInt("ANYSEARCH_CACHE_TTL", 600_000)
79
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
80
+ // no deadline of its own around `execute`, so this is the only one that exists.
81
+ const SEARCH_TIMEOUT = envInt("ANYSEARCH_TIMEOUT", 25_000)
78
82
  const SEARCH_SIZE = Math.min(Math.max(envInt("ANYSEARCH_MAX_RESULTS", 10), 1), 10)
79
83
  const TAG = envStr("ANYSEARCH_TAG")
80
84
  const ZONE = envStr("ANYSEARCH_ZONE")
@@ -103,6 +107,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
103
107
  }
104
108
  }
105
109
 
110
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
111
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
112
+ // removes the deadline but never the cancellation, because
113
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
114
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
115
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
116
+ }
117
+
106
118
  // Unlike the other providers in this repo, AnySearch never gates on `bound`:
107
119
  // anonymous access is a first-class mode, so the provider is always in the
108
120
  // pool. The key is still read per call, which is why no credential-event
@@ -121,7 +133,7 @@ export const effect = (ctx: Plugin.Context) =>
121
133
  })
122
134
  editor.method.update({
123
135
  integrationID: ID,
124
- method: { type: "env", names: ["ANYSEARCH_API_KEY"] },
136
+ method: { type: "env", names: ["OSP_ANYSEARCH_API_KEY"] },
125
137
  })
126
138
  })
127
139
 
@@ -150,7 +162,7 @@ export const effect = (ctx: Plugin.Context) =>
150
162
  ? yield* soften(ctx.integration.connection.resolve(connection))
151
163
  : undefined
152
164
  const key =
153
- credential?.type === "key" ? credential.key : process.env.ANYSEARCH_API_KEY
165
+ credential?.type === "key" ? credential.key : process.env.OSP_ANYSEARCH_API_KEY
154
166
 
155
167
  const headers: Record<string, string> = {
156
168
  Accept: "application/json",
@@ -159,7 +171,7 @@ export const effect = (ctx: Plugin.Context) =>
159
171
  if (key) headers.Authorization = "Bearer " + key
160
172
 
161
173
  const response = yield* Effect.tryPromise({
162
- try: () =>
174
+ try: (signal) =>
163
175
  fetch("https://api.anysearch.com/v1/search", {
164
176
  method: "POST",
165
177
  headers,
@@ -171,8 +183,14 @@ export const effect = (ctx: Plugin.Context) =>
171
183
  ...(LANGUAGE ? { language: LANGUAGE } : {}),
172
184
  format: FORMAT,
173
185
  }),
186
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
174
187
  }),
175
- catch: (error) => new Error("AnySearch request failed: " + String(error)),
188
+ // A timeout is its own failure: naming it stops the user hunting
189
+ // for a key problem that was never there.
190
+ catch: (error) =>
191
+ error instanceof Error && error.name === "TimeoutError"
192
+ ? new Error("AnySearch search timed out after " + SEARCH_TIMEOUT + "ms")
193
+ : new Error("AnySearch request failed: " + String(error)),
176
194
  })
177
195
 
178
196
  if (!response.ok) {
@@ -181,7 +199,7 @@ export const effect = (ctx: Plugin.Context) =>
181
199
  if (response.status === 402) {
182
200
  return yield* Effect.fail(
183
201
  new Error(
184
- "AnySearch quota exhausted (HTTP 402). Set ANYSEARCH_API_KEY " +
202
+ "AnySearch quota exhausted (HTTP 402). Set OSP_ANYSEARCH_API_KEY " +
185
203
  "or connect AnySearch in /connect to continue. The response " +
186
204
  "body is withheld because it may contain credentials.",
187
205
  ),
@@ -0,0 +1,392 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // Bocha answers inside a Bing-compatible envelope: `code` and `msg` mirror the
5
+ // HTTP status, `log_id` names the call in Bocha's own logs, and
6
+ // `data.webPages.value` carries the hits. A live response carries every
7
+ // documented page key -- nulls on the nullable ones, and no `summary` key at
8
+ // all unless it was asked for -- so the fields this provider reads decode as
9
+ // nullable, and one bad page must not fail the whole list.
10
+ const BochaPage = Schema.Struct({
11
+ name: Schema.optional(Schema.NullOr(Schema.String)),
12
+ url: Schema.String,
13
+ snippet: Schema.optional(Schema.NullOr(Schema.String)),
14
+ summary: Schema.optional(Schema.NullOr(Schema.String)),
15
+ datePublished: Schema.optional(Schema.NullOr(Schema.String)),
16
+ dateLastCrawled: Schema.optional(Schema.NullOr(Schema.String)),
17
+ })
18
+
19
+ const BochaResponse = Schema.Struct({
20
+ data: Schema.optional(
21
+ Schema.Struct({
22
+ webPages: Schema.optional(
23
+ Schema.Struct({
24
+ value: Schema.optional(Schema.Array(BochaPage)),
25
+ }),
26
+ ),
27
+ }),
28
+ ),
29
+ })
30
+
31
+ // The /connect integration ID, and the websearch provider ID it binds to.
32
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
33
+ // vendor plugin's own entry are bare vendor names, so `bocha` would collide
34
+ // with them, and with the opencode.db row keyed by integration ID.
35
+ const ID = "osp-bocha"
36
+
37
+ function envInt(name: string, fallback: number): number {
38
+ const raw = (process.env[name] ?? "").trim()
39
+ if (raw === "") return fallback
40
+ const value = Number(raw)
41
+ return Number.isFinite(value) && value >= 0 ? value : fallback
42
+ }
43
+
44
+ function envStr(name: string): string {
45
+ return (process.env[name] ?? "").trim()
46
+ }
47
+
48
+ // Bocha stamps both of its date fields in ISO 8601, which the parser below
49
+ // accepts as-is. The relative-date tables still ship byte-identical with the
50
+ // other providers in this package -- the parser comes from the same shared
51
+ // block, and `test/structure.test.ts` compares it byte for byte.
52
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
53
+ const RELATIVE_MS: Record<string, number> = {
54
+ minute: 60_000,
55
+ hour: 3_600_000,
56
+ day: 86_400_000,
57
+ week: 604_800_000,
58
+ month: 2_592_000_000,
59
+ year: 31_536_000_000,
60
+ 分钟: 60_000,
61
+ 小时: 3_600_000,
62
+ 天: 86_400_000,
63
+ 周: 604_800_000,
64
+ 个月: 2_592_000_000,
65
+ 月: 2_592_000_000,
66
+ 年: 31_536_000_000,
67
+ }
68
+
69
+ const RELATIVE_DATE =
70
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
71
+
72
+ const ABSOLUTE_DATE = [
73
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
74
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
75
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
76
+ // ISO 8601 with a time component, which Bocha stamps on both of its date
77
+ // fields -- with a UTC+8 offset on `datePublished`, and with a "Z" that
78
+ // lies on `dateLastCrawled`.
79
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
80
+ ]
81
+
82
+ function parseSearchDate(value: string | undefined): number {
83
+ const text = value?.trim()
84
+ if (!text) return NaN
85
+
86
+ const relative = RELATIVE_DATE.exec(text)
87
+ if (relative) {
88
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
89
+ const amount = Number(relative[1])
90
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
91
+ return Date.now() - amount * unit
92
+ }
93
+
94
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
95
+ // parser invents a year for strings like "Nov 1", which would date the result
96
+ // to 2001.
97
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
98
+ }
99
+
100
+ // `dateLastCrawled` is Bocha's misnamed publish time: the field name is a Bing
101
+ // compatibility shim, and Bocha's docs date the correction to a v2 that has not
102
+ // shipped. It also stamps a UTC+8 wall clock with a "Z" -- live responses carry
103
+ // both date fields on the same wall clock, one honest (`datePublished`, with
104
+ // its `+08:00` offset) and one lying (`dateLastCrawled`, with a `Z`), which is
105
+ // why the honest one is preferred above and this fallback rewrites the "Z" to
106
+ // "+08:00" before parsing, the workaround the docs themselves recommend. When
107
+ // v2 lands, this rewrite is what has to go with it.
108
+ function publishedAt(item: {
109
+ datePublished?: string | null | undefined
110
+ dateLastCrawled?: string | null | undefined
111
+ }): number {
112
+ const fromPublishDate = parseSearchDate(item.datePublished ?? undefined)
113
+ if (Number.isFinite(fromPublishDate)) return fromPublishDate
114
+ return parseSearchDate(item.dateLastCrawled?.replace(/Z$/, "+08:00"))
115
+ }
116
+
117
+ function toResult(item: {
118
+ name?: string | null | undefined
119
+ url: string
120
+ snippet?: string | null | undefined
121
+ summary?: string | null | undefined
122
+ datePublished?: string | null | undefined
123
+ dateLastCrawled?: string | null | undefined
124
+ }) {
125
+ const published = publishedAt(item)
126
+ return {
127
+ url: item.url,
128
+ // Bocha always fills `name` in its examples, but the URL is the one field
129
+ // the schema insists on, so it is also the one thing a title can fall back
130
+ // to rather than leave the result untitled.
131
+ title: item.name ?? item.url,
132
+ // `summary` is the generated abstract, present only when it was asked for
133
+ // and could be written; `snippet` is the excerpt that is always there.
134
+ // `||`, not `??`: an empty `summary` means Bocha declined to write one,
135
+ // and an empty string is not an answer worth handing the agent.
136
+ content: item.summary || item.snippet || "",
137
+ time: Number.isFinite(published) ? { published } : {},
138
+ }
139
+ }
140
+
141
+ type SearchResult = ReturnType<typeof toResult>
142
+
143
+ // Bocha bills per search, at a tiered rate, so a repeat query is a dispatch
144
+ // worth skipping. The knobs trade money for context: `BOCHA_SUMMARY` in
145
+ // particular swaps the free snippet for a generated summary, which is dearer
146
+ // per call than the search that produced it. Knobs are read once at load time,
147
+ // so restart after changing them.
148
+ //
149
+ // BOCHA_CACHE_TTL ms to reuse a response for the same query (0 disables)
150
+ // BOCHA_COUNT results per search, clamped to 1-50 (the API's range)
151
+ // BOCHA_FRESHNESS "noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear",
152
+ // a "YYYY-MM-DD" date, or a "YYYY-MM-DD..YYYY-MM-DD"
153
+ // range. Unset omits the parameter, which is the same
154
+ // thing: the docs recommend noLimit because the search
155
+ // rewrites time ranges itself, and a pinned range often
156
+ // finds nothing within it.
157
+ // BOCHA_SUMMARY true swaps each snippet for a generated summary --
158
+ // more context to read, at the higher per-call price
159
+ // BOCHA_TIMEOUT ms to wait before failing (0 removes the deadline)
160
+ // OSP_BOCHA_API_KEY required; from https://open.bocha.cn
161
+ const CACHE_TTL = envInt("BOCHA_CACHE_TTL", 600_000)
162
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
163
+ // no deadline of its own around `execute`, so this is the only one that exists.
164
+ const SEARCH_TIMEOUT = envInt("BOCHA_TIMEOUT", 25_000)
165
+ const COUNT = Math.min(Math.max(envInt("BOCHA_COUNT", 10), 1), 50)
166
+ const FRESHNESS = envStr("BOCHA_FRESHNESS")
167
+ const SUMMARY = envStr("BOCHA_SUMMARY") === "true"
168
+
169
+ const SEARCH_URL = "https://api.bocha.cn/v1/web-search"
170
+
171
+ const CACHE_LIMIT = 200
172
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
173
+
174
+ function cacheKey(query: string): string {
175
+ return query.trim().replace(/\s+/g, " ")
176
+ }
177
+
178
+ function readCache(queryKey: string): SearchResult[] | undefined {
179
+ if (CACHE_TTL <= 0) return undefined
180
+ const hit = cache.get(queryKey)
181
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
182
+ }
183
+
184
+ function writeCache(queryKey: string, results: SearchResult[]): void {
185
+ if (CACHE_TTL <= 0) return
186
+ cache.set(queryKey, { at: Date.now(), results })
187
+ if (cache.size > CACHE_LIMIT) {
188
+ const oldest = cache.keys().next().value
189
+ if (oldest !== undefined) cache.delete(oldest)
190
+ }
191
+ }
192
+
193
+ // Bocha's failures arrive as `{"code","message","log_id"}`, and the `log_id`
194
+ // is what their support needs to find the call again. A body in any other
195
+ // shape still stands in as the detail verbatim.
196
+ function describeFailure(detail: string): string {
197
+ try {
198
+ const body: unknown = JSON.parse(detail)
199
+ if (typeof body === "object" && body !== null) {
200
+ const { message, log_id: logID } = body as { message?: unknown; log_id?: unknown }
201
+ if (typeof message === "string" && message !== "") {
202
+ return message + (typeof logID === "string" && logID !== "" ? ` (log_id ${logID})` : "")
203
+ }
204
+ }
205
+ } catch {
206
+ // Not JSON: the raw body is the only detail there is.
207
+ }
208
+ return detail
209
+ }
210
+
211
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
212
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
213
+ // removes the deadline but never the cancellation, because
214
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
215
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
216
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
217
+ }
218
+
219
+ export const effect = (ctx: Plugin.Context) =>
220
+ Effect.gen(function* () {
221
+ // Make "Bocha" show up in /connect with an API-key method.
222
+ yield* ctx.integration.transform((editor) => {
223
+ editor.update(ID, (integration) => {
224
+ integration.name = "Bocha Search (osp)"
225
+ })
226
+ editor.method.update({
227
+ integrationID: ID,
228
+ method: { type: "key", label: "Bocha API key" },
229
+ })
230
+ editor.method.update({
231
+ integrationID: ID,
232
+ method: { type: "env", names: ["OSP_BOCHA_API_KEY"] },
233
+ })
234
+ })
235
+
236
+ // The host instantiates this API's failure channel as `unknown`, which would
237
+ // make the whole plugin effect unrunnable. A credential read must never take
238
+ // the plugin down: a failed read degrades to "no stored key", and the
239
+ // environment fallback below applies exactly as it does when the credential
240
+ // simply does not exist.
241
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
242
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
243
+
244
+ // The key comes from the /connect credential, falling back to the
245
+ // environment. Resolved per call so a rotated key needs no restart.
246
+ const resolveKey = () =>
247
+ Effect.gen(function* () {
248
+ const connection = yield* soften(ctx.integration.connection.active(ID))
249
+ const credential = connection
250
+ ? yield* soften(ctx.integration.connection.resolve(connection))
251
+ : undefined
252
+ return credential?.type === "key" ? credential.key : process.env.OSP_BOCHA_API_KEY
253
+ })
254
+
255
+ // Bocha has no anonymous tier, so the provider binds only while a key
256
+ // exists. Deleting the key unbinds it, and `random` can never pick a
257
+ // keyless Bocha. The transform replays on reload and reads `bound` as it
258
+ // stands then.
259
+ let bound = Boolean(yield* resolveKey())
260
+
261
+ yield* ctx.websearch.transform((editor) => {
262
+ if (!bound) return
263
+ editor.add({
264
+ id: ID,
265
+ name: "Bocha Search",
266
+ execute: ({ query }) =>
267
+ Effect.gen(function* () {
268
+ const queryKey = cacheKey(query)
269
+ const cached = readCache(queryKey)
270
+ if (cached) return cached
271
+
272
+ // Rechecked per call: binding can lag a key deletion by the
273
+ // event round-trip, so this keeps that window explicit.
274
+ const key = yield* resolveKey()
275
+ if (!key) {
276
+ return yield* Effect.fail(
277
+ new Error("Connect Bocha in /connect, or set OSP_BOCHA_API_KEY"),
278
+ )
279
+ }
280
+
281
+ const response = yield* Effect.tryPromise({
282
+ try: (signal) =>
283
+ fetch(SEARCH_URL, {
284
+ method: "POST",
285
+ headers: {
286
+ Accept: "application/json",
287
+ "Content-Type": "application/json",
288
+ Authorization: "Bearer " + key,
289
+ },
290
+ body: JSON.stringify({
291
+ query: queryKey,
292
+ count: COUNT,
293
+ summary: SUMMARY,
294
+ // Omitted rather than sent as "noLimit": the two mean the
295
+ // same thing, and the docs describe noLimit as the search
296
+ // rewriting time ranges on its own.
297
+ ...(FRESHNESS ? { freshness: FRESHNESS } : {}),
298
+ }),
299
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
300
+ }),
301
+ // A timeout is its own failure: naming it stops the user hunting
302
+ // for a key problem that was never there.
303
+ catch: (error) =>
304
+ error instanceof Error && error.name === "TimeoutError"
305
+ ? new Error("Bocha search timed out after " + SEARCH_TIMEOUT + "ms")
306
+ : new Error("Bocha request failed: " + String(error)),
307
+ })
308
+
309
+ if (!response.ok) {
310
+ const detail = yield* Effect.tryPromise({
311
+ try: () => response.text(),
312
+ catch: () => new Error("response body unavailable"),
313
+ })
314
+ const described = describeFailure(String(detail))
315
+
316
+ // Bocha names each refusal in its own body, and the three below
317
+ // carry a fix the user can act on -- a status code would send
318
+ // them hunting for an auth problem that was really a balance.
319
+ if (response.status === 401) {
320
+ return yield* Effect.fail(
321
+ new Error(
322
+ "Bocha rejected the API key (HTTP 401): " +
323
+ described +
324
+ ". Connect Bocha in /connect or set OSP_BOCHA_API_KEY to a key from https://open.bocha.cn",
325
+ ),
326
+ )
327
+ }
328
+ if (response.status === 403) {
329
+ return yield* Effect.fail(
330
+ new Error(
331
+ "Bocha balance is exhausted (HTTP 403): " +
332
+ described +
333
+ ". Top up at https://open.bocha.cn",
334
+ ),
335
+ )
336
+ }
337
+ if (response.status === 429) {
338
+ const retry = response.headers.get("Retry-After")
339
+ return yield* Effect.fail(
340
+ new Error(
341
+ "Bocha rate limited the request (HTTP 429)" +
342
+ (retry ? ", retry after " + retry + "s" : "") +
343
+ ": " +
344
+ described +
345
+ ". The request cap scales with the account's recharge amount",
346
+ ),
347
+ )
348
+ }
349
+ return yield* Effect.fail(
350
+ new Error("Bocha API error " + response.status + ": " + described),
351
+ )
352
+ }
353
+
354
+ const json = yield* Effect.tryPromise({
355
+ try: () => response.json(),
356
+ catch: (error) => new Error("Invalid Bocha response: " + String(error)),
357
+ })
358
+
359
+ const body = yield* Schema.decodeUnknownEffect(BochaResponse)(json)
360
+ const results = (body.data?.webPages?.value ?? []).map(toResult)
361
+
362
+ writeCache(queryKey, results)
363
+ return results
364
+ }),
365
+ })
366
+ })
367
+
368
+ // Rebind on credential changes: connect, delete, or rotate a key and the
369
+ // provider joins or leaves the websearch pool without a restart. Env-var
370
+ // keys cannot change under a running process, so they bind at startup.
371
+ yield* Effect.forkScoped(
372
+ ctx.event.subscribe().pipe(
373
+ Stream.filter(
374
+ (event) =>
375
+ event.type === "credential.updated" ||
376
+ event.type === "credential.switched" ||
377
+ event.type === "integration.updated",
378
+ ),
379
+ Stream.mapEffect(() =>
380
+ Effect.gen(function* () {
381
+ const next = Boolean(yield* resolveKey())
382
+ if (next === bound) return
383
+ bound = next
384
+ yield* ctx.websearch.reload()
385
+ }),
386
+ ),
387
+ Stream.runDrain,
388
+ ),
389
+ )
390
+ })
391
+
392
+ export default Plugin.define({ id: "bocha-search", effect })
@@ -114,12 +114,17 @@ type SearchResult = ReturnType<typeof toResult>
114
114
  // BRAVE_LANG result language code, e.g. "en", "zh-hans", "pt-br"
115
115
  // BRAVE_FRESHNESS "pd" (24h) "pw" (7d) "pm" (31d) "py" (365d) or a
116
116
  // "YYYY-MM-DDtoYYYY-MM-DD" range
117
- // BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
117
+ // BRAVE_TIMEOUT ms to wait for Brave before failing (0 removes the
118
+ // deadline; cancellation still applies)
119
+ // OSP_BRAVE_SEARCH_API_KEY required; from https://brave.com/search/api/
118
120
  const CACHE_TTL = envInt("BRAVE_CACHE_TTL", 600_000)
119
121
  const SEARCH_SIZE = Math.min(Math.max(envInt("BRAVE_NUM", 8), 1), 20)
120
122
  const COUNTRY = envStr("BRAVE_COUNTRY")
121
123
  const SEARCH_LANG = envStr("BRAVE_LANG")
122
124
  const FRESHNESS = envStr("BRAVE_FRESHNESS")
125
+ // OpenCode's own providers each bound themselves at 25s, and the host adds no
126
+ // deadline of its own around `execute`, so this is the only one that exists.
127
+ const SEARCH_TIMEOUT = envInt("BRAVE_TIMEOUT", 25_000)
123
128
 
124
129
  const CACHE_LIMIT = 200
125
130
  const cache = new Map<string, { at: number; results: SearchResult[] }>()
@@ -143,6 +148,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
143
148
  }
144
149
  }
145
150
 
151
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
152
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
153
+ // removes the deadline but never the cancellation, because
154
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
155
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
156
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
157
+ }
158
+
146
159
  export const effect = (ctx: Plugin.Context) =>
147
160
  Effect.gen(function* () {
148
161
  // Make "Brave Search" show up in /connect with an API-key method.
@@ -156,7 +169,7 @@ export const effect = (ctx: Plugin.Context) =>
156
169
  })
157
170
  editor.method.update({
158
171
  integrationID: ID,
159
- method: { type: "env", names: ["BRAVE_SEARCH_API_KEY"] },
172
+ method: { type: "env", names: ["OSP_BRAVE_SEARCH_API_KEY"] },
160
173
  })
161
174
  })
162
175
 
@@ -176,7 +189,7 @@ export const effect = (ctx: Plugin.Context) =>
176
189
  const credential = connection
177
190
  ? yield* soften(ctx.integration.connection.resolve(connection))
178
191
  : undefined
179
- return credential?.type === "key" ? credential.key : process.env.BRAVE_SEARCH_API_KEY
192
+ return credential?.type === "key" ? credential.key : process.env.OSP_BRAVE_SEARCH_API_KEY
180
193
  })
181
194
 
182
195
  // Brave is bound to websearch only while a key exists. Deleting the key
@@ -200,7 +213,7 @@ export const effect = (ctx: Plugin.Context) =>
200
213
  const key = yield* resolveKey()
201
214
  if (!key) {
202
215
  return yield* Effect.fail(
203
- new Error("Connect Brave in /connect, or set BRAVE_SEARCH_API_KEY"),
216
+ new Error("Connect Brave in /connect, or set OSP_BRAVE_SEARCH_API_KEY"),
204
217
  )
205
218
  }
206
219
 
@@ -212,14 +225,20 @@ export const effect = (ctx: Plugin.Context) =>
212
225
  if (FRESHNESS) url.searchParams.set("freshness", FRESHNESS)
213
226
 
214
227
  const response = yield* Effect.tryPromise({
215
- try: () =>
228
+ try: (signal) =>
216
229
  fetch(url, {
217
230
  headers: {
218
231
  Accept: "application/json",
219
232
  "X-Subscription-Token": key,
220
233
  },
234
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
221
235
  }),
222
- catch: (error) => new Error("Brave request failed: " + String(error)),
236
+ // A timeout is its own failure: naming it stops the user hunting
237
+ // for a key problem that was never there.
238
+ catch: (error) =>
239
+ error instanceof Error && error.name === "TimeoutError"
240
+ ? new Error("Brave search timed out after " + SEARCH_TIMEOUT + "ms")
241
+ : new Error("Brave request failed: " + String(error)),
223
242
  })
224
243
 
225
244
  if (!response.ok) {