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/README.md +87 -46
- package/README.zh-CN.md +85 -44
- package/index.ts +5 -1
- package/package.json +5 -2
- package/plugins/anysearch-search.ts +24 -6
- package/plugins/bocha-search.ts +392 -0
- package/plugins/brave-search.ts +25 -6
- package/plugins/deepseek-search.ts +24 -6
- package/plugins/jina-search.ts +31 -6
- package/plugins/keenable-search.ts +25 -7
- package/plugins/linkup-search.ts +24 -6
- package/plugins/metaso-search.ts +24 -6
- package/plugins/ollama-search.ts +253 -0
- package/plugins/querit-search.ts +24 -6
- package/plugins/serpapi-search.ts +24 -6
- package/plugins/serper-search.ts +24 -6
- package/plugins/youcom-search.ts +24 -6
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
|
|
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": "
|
|
4
|
-
"description": "
|
|
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
|
-
//
|
|
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: ["
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 })
|
package/plugins/brave-search.ts
CHANGED
|
@@ -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
|
-
//
|
|
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: ["
|
|
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.
|
|
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
|
|
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
|
-
|
|
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) {
|