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
|
@@ -112,8 +112,12 @@ type SearchResult = ReturnType<typeof toResult>
|
|
|
112
112
|
//
|
|
113
113
|
// DEEPSEEK_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
114
114
|
// DEEPSEEK_COUNT results per search, clamped to 1-50
|
|
115
|
-
//
|
|
115
|
+
// DEEPSEEK_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
116
|
+
// OSP_DEEPSEEK_API_KEY required; from https://platform.deepseek.com
|
|
116
117
|
const CACHE_TTL = envInt("DEEPSEEK_CACHE_TTL", 600_000)
|
|
118
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
119
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
120
|
+
const SEARCH_TIMEOUT = envInt("DEEPSEEK_TIMEOUT", 25_000)
|
|
117
121
|
const COUNT = Math.min(Math.max(envInt("DEEPSEEK_COUNT", 10), 1), 50)
|
|
118
122
|
|
|
119
123
|
const CACHE_LIMIT = 200
|
|
@@ -138,6 +142,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
146
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
147
|
+
// removes the deadline but never the cancellation, because
|
|
148
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
149
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
150
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
export const effect = (ctx: Plugin.Context) =>
|
|
142
154
|
Effect.gen(function* () {
|
|
143
155
|
// Make "DeepSeek" show up in /connect with an API-key method.
|
|
@@ -151,7 +163,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
151
163
|
})
|
|
152
164
|
editor.method.update({
|
|
153
165
|
integrationID: ID,
|
|
154
|
-
method: { type: "env", names: ["
|
|
166
|
+
method: { type: "env", names: ["OSP_DEEPSEEK_API_KEY"] },
|
|
155
167
|
})
|
|
156
168
|
})
|
|
157
169
|
|
|
@@ -171,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
171
183
|
const credential = connection
|
|
172
184
|
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
173
185
|
: undefined
|
|
174
|
-
return credential?.type === "key" ? credential.key : process.env.
|
|
186
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_DEEPSEEK_API_KEY
|
|
175
187
|
})
|
|
176
188
|
|
|
177
189
|
// DeepSeek is bound to websearch only while a key exists. Deleting the key
|
|
@@ -195,12 +207,12 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
195
207
|
const key = yield* resolveKey()
|
|
196
208
|
if (!key) {
|
|
197
209
|
return yield* Effect.fail(
|
|
198
|
-
new Error("Connect DeepSeek in /connect, or set
|
|
210
|
+
new Error("Connect DeepSeek in /connect, or set OSP_DEEPSEEK_API_KEY"),
|
|
199
211
|
)
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
const response = yield* Effect.tryPromise({
|
|
203
|
-
try: () =>
|
|
215
|
+
try: (signal) =>
|
|
204
216
|
fetch("https://api.deepseek.com/v1/tools/search", {
|
|
205
217
|
method: "POST",
|
|
206
218
|
headers: {
|
|
@@ -212,8 +224,14 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
212
224
|
query: queryKey,
|
|
213
225
|
count: COUNT,
|
|
214
226
|
}),
|
|
227
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
215
228
|
}),
|
|
216
|
-
|
|
229
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
230
|
+
// for a key problem that was never there.
|
|
231
|
+
catch: (error) =>
|
|
232
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
233
|
+
? new Error("DeepSeek search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
234
|
+
: new Error("DeepSeek request failed: " + String(error)),
|
|
217
235
|
})
|
|
218
236
|
|
|
219
237
|
if (!response.ok) {
|
package/plugins/jina-search.ts
CHANGED
|
@@ -77,13 +77,24 @@ function envStr(name: string): string {
|
|
|
77
77
|
// JINA_GL country code for results, e.g. "us", "cn"
|
|
78
78
|
// JINA_HL language code for results, e.g. "en", "zh-cn"
|
|
79
79
|
// JINA_LOCATION city-level origin to simulate, e.g. "Berlin"
|
|
80
|
-
//
|
|
80
|
+
// JINA_TIMEOUT ms to wait before failing (0 removes the deadline).
|
|
81
|
+
// Defaults to 25000, or 60000 when JINA_INCLUDE_CONTENT
|
|
82
|
+
// is on, because whole-page fetches legitimately take
|
|
83
|
+
// far longer than a snippet search
|
|
84
|
+
// OSP_JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
|
|
81
85
|
const CACHE_TTL = envInt("JINA_CACHE_TTL", 600_000)
|
|
82
86
|
const INCLUDE_CONTENT = envStr("JINA_INCLUDE_CONTENT") === "true"
|
|
83
87
|
const SEARCH_SIZE = Math.min(Math.max(envInt("JINA_NUM", 0), 0), 100)
|
|
84
88
|
const GL = envStr("JINA_GL")
|
|
85
89
|
const HL = envStr("JINA_HL")
|
|
86
90
|
const LOCATION = envStr("JINA_LOCATION")
|
|
91
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
92
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
93
|
+
// Jina is the one provider whose default has to vary: asking for page content
|
|
94
|
+
// turns a snippet search into a crawl, and a shared budget would either cut
|
|
95
|
+
// that off or be padded so high that a genuine hang takes a minute to surface.
|
|
96
|
+
const DEFAULT_TIMEOUT = INCLUDE_CONTENT ? 60_000 : 25_000
|
|
97
|
+
const SEARCH_TIMEOUT = envInt("JINA_TIMEOUT", DEFAULT_TIMEOUT)
|
|
87
98
|
|
|
88
99
|
const CACHE_LIMIT = 200
|
|
89
100
|
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
@@ -107,6 +118,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
122
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
123
|
+
// removes the deadline but never the cancellation, because
|
|
124
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
125
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
126
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
export const effect = (ctx: Plugin.Context) =>
|
|
111
130
|
Effect.gen(function* () {
|
|
112
131
|
// Make "Jina" show up in /connect with an API-key method.
|
|
@@ -120,7 +139,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
120
139
|
})
|
|
121
140
|
editor.method.update({
|
|
122
141
|
integrationID: ID,
|
|
123
|
-
method: { type: "env", names: ["
|
|
142
|
+
method: { type: "env", names: ["OSP_JINA_API_KEY"] },
|
|
124
143
|
})
|
|
125
144
|
})
|
|
126
145
|
|
|
@@ -140,7 +159,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
140
159
|
const credential = connection
|
|
141
160
|
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
142
161
|
: undefined
|
|
143
|
-
return credential?.type === "key" ? credential.key : process.env.
|
|
162
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_JINA_API_KEY
|
|
144
163
|
})
|
|
145
164
|
|
|
146
165
|
// Jina is bound to websearch only while a key exists. Deleting the key
|
|
@@ -164,12 +183,12 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
164
183
|
const key = yield* resolveKey()
|
|
165
184
|
if (!key) {
|
|
166
185
|
return yield* Effect.fail(
|
|
167
|
-
new Error("Connect Jina in /connect, or set
|
|
186
|
+
new Error("Connect Jina in /connect, or set OSP_JINA_API_KEY"),
|
|
168
187
|
)
|
|
169
188
|
}
|
|
170
189
|
|
|
171
190
|
const response = yield* Effect.tryPromise({
|
|
172
|
-
try: () =>
|
|
191
|
+
try: (signal) =>
|
|
173
192
|
fetch("https://s.jina.ai/", {
|
|
174
193
|
method: "POST",
|
|
175
194
|
headers: {
|
|
@@ -188,8 +207,14 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
188
207
|
...(HL ? { hl: HL } : {}),
|
|
189
208
|
...(LOCATION ? { location: LOCATION } : {}),
|
|
190
209
|
}),
|
|
210
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
191
211
|
}),
|
|
192
|
-
|
|
212
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
213
|
+
// for a key problem that was never there.
|
|
214
|
+
catch: (error) =>
|
|
215
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
216
|
+
? new Error("Jina search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
217
|
+
: new Error("Jina request failed: " + String(error)),
|
|
193
218
|
})
|
|
194
219
|
|
|
195
220
|
if (!response.ok) {
|
|
@@ -122,8 +122,12 @@ function envStr(name: string): string {
|
|
|
122
122
|
// leave unset and the organization's default applies
|
|
123
123
|
// KEENABLE_TITLE app name sent as X-Keenable-Title, which the keyless
|
|
124
124
|
// endpoint requires to attribute traffic (max 256 chars)
|
|
125
|
-
//
|
|
125
|
+
// KEENABLE_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
126
|
+
// OSP_KEENABLE_API_KEY optional; keyless access works without it
|
|
126
127
|
const CACHE_TTL = envInt("KEENABLE_CACHE_TTL", 600_000)
|
|
128
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
129
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
130
|
+
const SEARCH_TIMEOUT = envInt("KEENABLE_TIMEOUT", 25_000)
|
|
127
131
|
const SEARCH_SIZE = Math.min(Math.max(envInt("KEENABLE_MAX_RESULTS", 10), 1), 50)
|
|
128
132
|
const MODE = envStr("KEENABLE_MODE")
|
|
129
133
|
const TITLE = (envStr("KEENABLE_TITLE") || "opencode-search-plugin").slice(0, 256)
|
|
@@ -153,6 +157,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
161
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
162
|
+
// removes the deadline but never the cancellation, because
|
|
163
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
164
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
165
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
// Unlike the key-requiring providers in this repo, Keenable never gates on
|
|
157
169
|
// `bound`: keyless access is a first-class mode, so the provider is always in
|
|
158
170
|
// the pool. The key is still read per call, which is why no credential-event
|
|
@@ -171,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
171
183
|
})
|
|
172
184
|
editor.method.update({
|
|
173
185
|
integrationID: ID,
|
|
174
|
-
method: { type: "env", names: ["
|
|
186
|
+
method: { type: "env", names: ["OSP_KEENABLE_API_KEY"] },
|
|
175
187
|
})
|
|
176
188
|
})
|
|
177
189
|
|
|
@@ -200,7 +212,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
200
212
|
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
201
213
|
: undefined
|
|
202
214
|
const key =
|
|
203
|
-
credential?.type === "key" ? credential.key : process.env.
|
|
215
|
+
credential?.type === "key" ? credential.key : process.env.OSP_KEENABLE_API_KEY
|
|
204
216
|
|
|
205
217
|
const headers: Record<string, string> = {
|
|
206
218
|
Accept: "application/json",
|
|
@@ -213,7 +225,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
213
225
|
if (key) headers["X-API-Key"] = key
|
|
214
226
|
|
|
215
227
|
const response = yield* Effect.tryPromise({
|
|
216
|
-
try: () =>
|
|
228
|
+
try: (signal) =>
|
|
217
229
|
fetch(key ? SEARCH_URL : PUBLIC_URL, {
|
|
218
230
|
method: "POST",
|
|
219
231
|
headers,
|
|
@@ -222,8 +234,14 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
222
234
|
max_results: SEARCH_SIZE,
|
|
223
235
|
...(MODE ? { mode: MODE } : {}),
|
|
224
236
|
}),
|
|
237
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
225
238
|
}),
|
|
226
|
-
|
|
239
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
240
|
+
// for a key problem that was never there.
|
|
241
|
+
catch: (error) =>
|
|
242
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
243
|
+
? new Error("Keenable search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
244
|
+
: new Error("Keenable request failed: " + String(error)),
|
|
227
245
|
})
|
|
228
246
|
|
|
229
247
|
if (!response.ok) {
|
|
@@ -235,7 +253,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
235
253
|
new Error(
|
|
236
254
|
"Keenable credits exhausted (HTTP 402): the free monthly " +
|
|
237
255
|
"allowance of 100,000 requests is spent. Connect Keenable " +
|
|
238
|
-
"in /connect or set
|
|
256
|
+
"in /connect or set OSP_KEENABLE_API_KEY to buy credits, or " +
|
|
239
257
|
"wait for the allowance to reset.",
|
|
240
258
|
),
|
|
241
259
|
)
|
|
@@ -249,7 +267,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
249
267
|
(key
|
|
250
268
|
? ""
|
|
251
269
|
: ". The keyless pool allows 1,000 requests an hour and " +
|
|
252
|
-
"10 a second per IP; set
|
|
270
|
+
"10 a second per IP; set OSP_KEENABLE_API_KEY to spend your " +
|
|
253
271
|
"own 100,000-request monthly allowance instead."),
|
|
254
272
|
),
|
|
255
273
|
)
|
package/plugins/linkup-search.ts
CHANGED
|
@@ -54,8 +54,12 @@ function envStr(name: string): string {
|
|
|
54
54
|
// vendor's recommended default for agents), "standard"
|
|
55
55
|
// (single agentic pass) or "deep" (several iterations)
|
|
56
56
|
// LINKUP_MAX_RESULTS results per search, clamped to 1-100
|
|
57
|
-
//
|
|
57
|
+
// LINKUP_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
58
|
+
// OSP_LINKUP_API_KEY required; from https://app.linkup.so
|
|
58
59
|
const CACHE_TTL = envInt("LINKUP_CACHE_TTL", 600_000)
|
|
60
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
61
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
62
|
+
const SEARCH_TIMEOUT = envInt("LINKUP_TIMEOUT", 25_000)
|
|
59
63
|
const SEARCH_SIZE = Math.min(Math.max(envInt("LINKUP_MAX_RESULTS", 10), 1), 100)
|
|
60
64
|
const DEPTH = ["flash", "fast", "standard", "deep"].includes(envStr("LINKUP_DEPTH"))
|
|
61
65
|
? envStr("LINKUP_DEPTH")
|
|
@@ -83,6 +87,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
91
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
92
|
+
// removes the deadline but never the cancellation, because
|
|
93
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
94
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
95
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
export const effect = (ctx: Plugin.Context) =>
|
|
87
99
|
Effect.gen(function* () {
|
|
88
100
|
// Make "Linkup" show up in /connect with an API-key method.
|
|
@@ -96,7 +108,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
96
108
|
})
|
|
97
109
|
editor.method.update({
|
|
98
110
|
integrationID: ID,
|
|
99
|
-
method: { type: "env", names: ["
|
|
111
|
+
method: { type: "env", names: ["OSP_LINKUP_API_KEY"] },
|
|
100
112
|
})
|
|
101
113
|
})
|
|
102
114
|
|
|
@@ -116,7 +128,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
116
128
|
const credential = connection
|
|
117
129
|
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
118
130
|
: undefined
|
|
119
|
-
return credential?.type === "key" ? credential.key : process.env.
|
|
131
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_LINKUP_API_KEY
|
|
120
132
|
})
|
|
121
133
|
|
|
122
134
|
// Linkup is bound to websearch only while a key exists. Deleting the key
|
|
@@ -140,12 +152,12 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
140
152
|
const key = yield* resolveKey()
|
|
141
153
|
if (!key) {
|
|
142
154
|
return yield* Effect.fail(
|
|
143
|
-
new Error("Connect Linkup in /connect, or set
|
|
155
|
+
new Error("Connect Linkup in /connect, or set OSP_LINKUP_API_KEY"),
|
|
144
156
|
)
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
const response = yield* Effect.tryPromise({
|
|
148
|
-
try: () =>
|
|
160
|
+
try: (signal) =>
|
|
149
161
|
fetch("https://api.linkup.so/v1/search", {
|
|
150
162
|
method: "POST",
|
|
151
163
|
headers: {
|
|
@@ -159,8 +171,14 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
159
171
|
outputType: "searchResults",
|
|
160
172
|
maxResults: SEARCH_SIZE,
|
|
161
173
|
}),
|
|
174
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
162
175
|
}),
|
|
163
|
-
|
|
176
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
177
|
+
// for a key problem that was never there.
|
|
178
|
+
catch: (error) =>
|
|
179
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
180
|
+
? new Error("Linkup search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
181
|
+
: new Error("Linkup request failed: " + String(error)),
|
|
164
182
|
})
|
|
165
183
|
|
|
166
184
|
if (!response.ok) {
|
package/plugins/metaso-search.ts
CHANGED
|
@@ -111,8 +111,12 @@ type SearchResult = ReturnType<typeof toResult>
|
|
|
111
111
|
// METASO_INCLUDE_RAW_CONTENT true returns the full source text of every hit: same
|
|
112
112
|
// search cost, far more context tokens. Only worth it
|
|
113
113
|
// when you would otherwise webfetch those hits anyway.
|
|
114
|
-
//
|
|
114
|
+
// METASO_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
115
|
+
// OSP_METASO_API_KEY required; from https://metaso.cn
|
|
115
116
|
const CACHE_TTL = envInt("METASO_CACHE_TTL", 600_000)
|
|
117
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
118
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
119
|
+
const SEARCH_TIMEOUT = envInt("METASO_TIMEOUT", 25_000)
|
|
116
120
|
const SEARCH_SIZE = Math.min(Math.max(envInt("METASO_SEARCH_SIZE", 20), 1), 100)
|
|
117
121
|
const INCLUDE_RAW_CONTENT = envStr("METASO_INCLUDE_RAW_CONTENT") === "true"
|
|
118
122
|
|
|
@@ -138,6 +142,14 @@ function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
146
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
147
|
+
// removes the deadline but never the cancellation, because
|
|
148
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
149
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
150
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
export const effect = (ctx: Plugin.Context) =>
|
|
142
154
|
Effect.gen(function* () {
|
|
143
155
|
// Make "Metaso" show up in /connect with an API-key method.
|
|
@@ -151,7 +163,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
151
163
|
})
|
|
152
164
|
editor.method.update({
|
|
153
165
|
integrationID: ID,
|
|
154
|
-
method: { type: "env", names: ["
|
|
166
|
+
method: { type: "env", names: ["OSP_METASO_API_KEY"] },
|
|
155
167
|
})
|
|
156
168
|
})
|
|
157
169
|
|
|
@@ -171,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
171
183
|
const credential = connection
|
|
172
184
|
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
173
185
|
: undefined
|
|
174
|
-
return credential?.type === "key" ? credential.key : process.env.
|
|
186
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_METASO_API_KEY
|
|
175
187
|
})
|
|
176
188
|
|
|
177
189
|
// Metaso is bound to websearch only while a key exists. Deleting the key
|
|
@@ -195,12 +207,12 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
195
207
|
const key = yield* resolveKey()
|
|
196
208
|
if (!key) {
|
|
197
209
|
return yield* Effect.fail(
|
|
198
|
-
new Error("Connect Metaso in /connect, or set
|
|
210
|
+
new Error("Connect Metaso in /connect, or set OSP_METASO_API_KEY"),
|
|
199
211
|
)
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
const response = yield* Effect.tryPromise({
|
|
203
|
-
try: () =>
|
|
215
|
+
try: (signal) =>
|
|
204
216
|
fetch("https://metaso.cn/api/v1/search", {
|
|
205
217
|
method: "POST",
|
|
206
218
|
headers: {
|
|
@@ -216,8 +228,14 @@ export const effect = (ctx: Plugin.Context) =>
|
|
|
216
228
|
includeRawContent: INCLUDE_RAW_CONTENT,
|
|
217
229
|
conciseSnippet: true,
|
|
218
230
|
}),
|
|
231
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
219
232
|
}),
|
|
220
|
-
|
|
233
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
234
|
+
// for a key problem that was never there.
|
|
235
|
+
catch: (error) =>
|
|
236
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
237
|
+
? new Error("Metaso search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
238
|
+
: new Error("Metaso request failed: " + String(error)),
|
|
221
239
|
})
|
|
222
240
|
|
|
223
241
|
if (!response.ok) {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Ollama's hosted search answers with the flattest envelope in this package:
|
|
5
|
+
// `results` carries the hits, each a title, a URL and the page's own markdown
|
|
6
|
+
// -- hundreds to tens of thousands of characters, because `content` is the
|
|
7
|
+
// fetched page, not a snippet -- with no wrapper fields and, unlike every
|
|
8
|
+
// other provider here, no dates. Every field but the URL is optional, and a
|
|
9
|
+
// missing one means "Ollama provided none", not an error.
|
|
10
|
+
const OllamaResponse = Schema.Struct({
|
|
11
|
+
results: Schema.optional(
|
|
12
|
+
Schema.Array(
|
|
13
|
+
Schema.Struct({
|
|
14
|
+
title: Schema.optional(Schema.String),
|
|
15
|
+
url: Schema.String,
|
|
16
|
+
content: Schema.optional(Schema.String),
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
23
|
+
// The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
|
|
24
|
+
// vendor plugin's own entry are bare vendor names, and OpenCode's own `ollama`
|
|
25
|
+
// model provider already registers one, so the bare name is spoken for -- the
|
|
26
|
+
// prefix is what keeps this websearch entry and its /connect row distinct from
|
|
27
|
+
// that model provider, and from the opencode.db row keyed by integration ID.
|
|
28
|
+
const ID = "osp-ollama"
|
|
29
|
+
|
|
30
|
+
// Ollama returns no publication date, so results carry an empty time record.
|
|
31
|
+
function toResult(item: {
|
|
32
|
+
title?: string | undefined
|
|
33
|
+
url: string
|
|
34
|
+
content?: string | undefined
|
|
35
|
+
}) {
|
|
36
|
+
return {
|
|
37
|
+
url: item.url,
|
|
38
|
+
// The URL is the one field the schema insists on, so it is also the one
|
|
39
|
+
// thing a title can fall back to rather than leave the result untitled.
|
|
40
|
+
title: item.title || item.url,
|
|
41
|
+
content: item.content ?? "",
|
|
42
|
+
time: {},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
47
|
+
|
|
48
|
+
function envInt(name: string, fallback: number): number {
|
|
49
|
+
const raw = (process.env[name] ?? "").trim()
|
|
50
|
+
if (raw === "") return fallback
|
|
51
|
+
const value = Number(raw)
|
|
52
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function envStr(name: string): string {
|
|
56
|
+
return (process.env[name] ?? "").trim()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Web search rides on an Ollama account: the API is hosted at ollama.com, not
|
|
60
|
+
// served by the local model runner (the official SDKs hardcode that host, and
|
|
61
|
+
// the local server neither proxies this endpoint nor reads this key). A
|
|
62
|
+
// generous free tier covers individual use, and the API caps a search at 10
|
|
63
|
+
// results, which is the clamp below. Knobs are read once at load time, so
|
|
64
|
+
// restart after changing them.
|
|
65
|
+
//
|
|
66
|
+
// OLLAMA_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
67
|
+
// OLLAMA_MAX_RESULTS results per search, clamped to 1-10 (the API's range).
|
|
68
|
+
// `content` is a whole page of markdown, not a snippet,
|
|
69
|
+
// so the default of 5 already means thousands of
|
|
70
|
+
// characters of context per search
|
|
71
|
+
// OLLAMA_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
72
|
+
// OSP_OLLAMA_API_KEY required; from https://ollama.com/settings/keys.
|
|
73
|
+
// Namespaced away from the vendor-canonical
|
|
74
|
+
// OLLAMA_API_KEY, which other tools -- and OpenCode's
|
|
75
|
+
// own config examples -- also read, sometimes holding
|
|
76
|
+
// a bearer token for an Ollama-compatible proxy
|
|
77
|
+
// rather than an account key. A /connect credential
|
|
78
|
+
// still outranks this variable.
|
|
79
|
+
const CACHE_TTL = envInt("OLLAMA_CACHE_TTL", 600_000)
|
|
80
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
81
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
82
|
+
const SEARCH_TIMEOUT = envInt("OLLAMA_TIMEOUT", 25_000)
|
|
83
|
+
const MAX_RESULTS = Math.min(Math.max(envInt("OLLAMA_MAX_RESULTS", 5), 1), 10)
|
|
84
|
+
|
|
85
|
+
const SEARCH_URL = "https://ollama.com/api/web_search"
|
|
86
|
+
|
|
87
|
+
const CACHE_LIMIT = 200
|
|
88
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
89
|
+
|
|
90
|
+
function cacheKey(query: string): string {
|
|
91
|
+
return query.trim().replace(/\s+/g, " ")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
95
|
+
if (CACHE_TTL <= 0) return undefined
|
|
96
|
+
const hit = cache.get(queryKey)
|
|
97
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
101
|
+
if (CACHE_TTL <= 0) return
|
|
102
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
103
|
+
if (cache.size > CACHE_LIMIT) {
|
|
104
|
+
const oldest = cache.keys().next().value
|
|
105
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
110
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
111
|
+
// removes the deadline but never the cancellation, because
|
|
112
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
113
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
114
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
// Make "Ollama" show up in /connect with an API-key method.
|
|
120
|
+
yield* ctx.integration.transform((editor) => {
|
|
121
|
+
editor.update(ID, (integration) => {
|
|
122
|
+
integration.name = "Ollama Search (osp)"
|
|
123
|
+
})
|
|
124
|
+
editor.method.update({
|
|
125
|
+
integrationID: ID,
|
|
126
|
+
method: { type: "key", label: "Ollama API key" },
|
|
127
|
+
})
|
|
128
|
+
editor.method.update({
|
|
129
|
+
integrationID: ID,
|
|
130
|
+
method: { type: "env", names: ["OSP_OLLAMA_API_KEY"] },
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
135
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
136
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
137
|
+
// environment fallback below applies exactly as it does when the credential
|
|
138
|
+
// simply does not exist.
|
|
139
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
140
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
141
|
+
|
|
142
|
+
// The key comes from the /connect credential, falling back to the
|
|
143
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
144
|
+
const resolveKey = () =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
147
|
+
const credential = connection
|
|
148
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
149
|
+
: undefined
|
|
150
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_OLLAMA_API_KEY
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Web search requires an Ollama account, so the provider binds only while
|
|
154
|
+
// a key exists. Deleting the key unbinds it, and `random` can never pick a
|
|
155
|
+
// keyless Ollama. The transform replays on reload and reads `bound` as it
|
|
156
|
+
// stands then.
|
|
157
|
+
let bound = Boolean(yield* resolveKey())
|
|
158
|
+
|
|
159
|
+
yield* ctx.websearch.transform((editor) => {
|
|
160
|
+
if (!bound) return
|
|
161
|
+
editor.add({
|
|
162
|
+
id: ID,
|
|
163
|
+
name: "Ollama Search",
|
|
164
|
+
execute: ({ query }) =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const queryKey = cacheKey(query)
|
|
167
|
+
const cached = readCache(queryKey)
|
|
168
|
+
if (cached) return cached
|
|
169
|
+
|
|
170
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
171
|
+
// event round-trip, so this keeps that window explicit.
|
|
172
|
+
const key = yield* resolveKey()
|
|
173
|
+
if (!key) {
|
|
174
|
+
return yield* Effect.fail(
|
|
175
|
+
new Error("Connect Ollama in /connect, or set OSP_OLLAMA_API_KEY"),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const response = yield* Effect.tryPromise({
|
|
180
|
+
try: (signal) =>
|
|
181
|
+
fetch(SEARCH_URL, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: {
|
|
184
|
+
Accept: "application/json",
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
Authorization: "Bearer " + key,
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
query: queryKey,
|
|
190
|
+
max_results: MAX_RESULTS,
|
|
191
|
+
}),
|
|
192
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
193
|
+
}),
|
|
194
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
195
|
+
// for a key problem that was never there.
|
|
196
|
+
catch: (error) =>
|
|
197
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
198
|
+
? new Error("Ollama search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
199
|
+
: new Error("Ollama request failed: " + String(error)),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
// A bad key answers 401 with `{"error":"Unauthorized"}` (checked
|
|
204
|
+
// against the live API); the body rides along verbatim, so any
|
|
205
|
+
// shape Ollama adds later is reported rather than mistranslated.
|
|
206
|
+
const detail = yield* Effect.tryPromise({
|
|
207
|
+
try: () => response.text(),
|
|
208
|
+
catch: () => new Error("response body unavailable"),
|
|
209
|
+
})
|
|
210
|
+
return yield* Effect.fail(
|
|
211
|
+
new Error("Ollama API error " + response.status + ": " + detail),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const json = yield* Effect.tryPromise({
|
|
216
|
+
try: () => response.json(),
|
|
217
|
+
catch: (error) => new Error("Invalid Ollama response: " + String(error)),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const body = yield* Schema.decodeUnknownEffect(OllamaResponse)(json)
|
|
221
|
+
const results = (body.results ?? []).map(toResult)
|
|
222
|
+
|
|
223
|
+
writeCache(queryKey, results)
|
|
224
|
+
return results
|
|
225
|
+
}),
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
230
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
231
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
232
|
+
yield* Effect.forkScoped(
|
|
233
|
+
ctx.event.subscribe().pipe(
|
|
234
|
+
Stream.filter(
|
|
235
|
+
(event) =>
|
|
236
|
+
event.type === "credential.updated" ||
|
|
237
|
+
event.type === "credential.switched" ||
|
|
238
|
+
event.type === "integration.updated",
|
|
239
|
+
),
|
|
240
|
+
Stream.mapEffect(() =>
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const next = Boolean(yield* resolveKey())
|
|
243
|
+
if (next === bound) return
|
|
244
|
+
bound = next
|
|
245
|
+
yield* ctx.websearch.reload()
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
Stream.runDrain,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
export default Plugin.define({ id: "ollama-search", effect })
|