opencode-search-plugin 2.2.0 → 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.
@@ -55,7 +55,7 @@ function envStr(name: string): string {
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
- // LINKUP_API_KEY required; from https://app.linkup.so
58
+ // OSP_LINKUP_API_KEY required; from https://app.linkup.so
59
59
  const CACHE_TTL = envInt("LINKUP_CACHE_TTL", 600_000)
60
60
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
61
61
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -108,7 +108,7 @@ export const effect = (ctx: Plugin.Context) =>
108
108
  })
109
109
  editor.method.update({
110
110
  integrationID: ID,
111
- method: { type: "env", names: ["LINKUP_API_KEY"] },
111
+ method: { type: "env", names: ["OSP_LINKUP_API_KEY"] },
112
112
  })
113
113
  })
114
114
 
@@ -128,7 +128,7 @@ export const effect = (ctx: Plugin.Context) =>
128
128
  const credential = connection
129
129
  ? yield* soften(ctx.integration.connection.resolve(connection))
130
130
  : undefined
131
- return credential?.type === "key" ? credential.key : process.env.LINKUP_API_KEY
131
+ return credential?.type === "key" ? credential.key : process.env.OSP_LINKUP_API_KEY
132
132
  })
133
133
 
134
134
  // Linkup is bound to websearch only while a key exists. Deleting the key
@@ -152,7 +152,7 @@ export const effect = (ctx: Plugin.Context) =>
152
152
  const key = yield* resolveKey()
153
153
  if (!key) {
154
154
  return yield* Effect.fail(
155
- new Error("Connect Linkup in /connect, or set LINKUP_API_KEY"),
155
+ new Error("Connect Linkup in /connect, or set OSP_LINKUP_API_KEY"),
156
156
  )
157
157
  }
158
158
 
@@ -112,7 +112,7 @@ type SearchResult = ReturnType<typeof toResult>
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
- // METASO_API_KEY required; from https://metaso.cn
115
+ // OSP_METASO_API_KEY required; from https://metaso.cn
116
116
  const CACHE_TTL = envInt("METASO_CACHE_TTL", 600_000)
117
117
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
118
118
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -163,7 +163,7 @@ export const effect = (ctx: Plugin.Context) =>
163
163
  })
164
164
  editor.method.update({
165
165
  integrationID: ID,
166
- method: { type: "env", names: ["METASO_API_KEY"] },
166
+ method: { type: "env", names: ["OSP_METASO_API_KEY"] },
167
167
  })
168
168
  })
169
169
 
@@ -183,7 +183,7 @@ export const effect = (ctx: Plugin.Context) =>
183
183
  const credential = connection
184
184
  ? yield* soften(ctx.integration.connection.resolve(connection))
185
185
  : undefined
186
- return credential?.type === "key" ? credential.key : process.env.METASO_API_KEY
186
+ return credential?.type === "key" ? credential.key : process.env.OSP_METASO_API_KEY
187
187
  })
188
188
 
189
189
  // Metaso is bound to websearch only while a key exists. Deleting the key
@@ -207,7 +207,7 @@ export const effect = (ctx: Plugin.Context) =>
207
207
  const key = yield* resolveKey()
208
208
  if (!key) {
209
209
  return yield* Effect.fail(
210
- new Error("Connect Metaso in /connect, or set METASO_API_KEY"),
210
+ new Error("Connect Metaso in /connect, or set OSP_METASO_API_KEY"),
211
211
  )
212
212
  }
213
213
 
@@ -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 })
@@ -123,7 +123,7 @@ type SearchResult = ReturnType<typeof toResult>
123
123
  // QUERIT_CACHE_TTL ms to reuse a response for the same query (0 disables)
124
124
  // QUERIT_COUNT results per search; Querit's own limit applies
125
125
  // QUERIT_TIMEOUT ms to wait before failing (0 removes the deadline)
126
- // QUERIT_API_KEY required; from https://querit.ai
126
+ // OSP_QUERIT_API_KEY required; from https://querit.ai
127
127
  const CACHE_TTL = envInt("QUERIT_CACHE_TTL", 600_000)
128
128
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
129
129
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -173,7 +173,7 @@ export const effect = (ctx: Plugin.Context) =>
173
173
  })
174
174
  editor.method.update({
175
175
  integrationID: ID,
176
- method: { type: "env", names: ["QUERIT_API_KEY"] },
176
+ method: { type: "env", names: ["OSP_QUERIT_API_KEY"] },
177
177
  })
178
178
  })
179
179
 
@@ -193,7 +193,7 @@ export const effect = (ctx: Plugin.Context) =>
193
193
  const credential = connection
194
194
  ? yield* soften(ctx.integration.connection.resolve(connection))
195
195
  : undefined
196
- return credential?.type === "key" ? credential.key : process.env.QUERIT_API_KEY
196
+ return credential?.type === "key" ? credential.key : process.env.OSP_QUERIT_API_KEY
197
197
  })
198
198
 
199
199
  // Querit is bound to websearch only while a key exists. Deleting the key
@@ -217,7 +217,7 @@ export const effect = (ctx: Plugin.Context) =>
217
217
  const key = yield* resolveKey()
218
218
  if (!key) {
219
219
  return yield* Effect.fail(
220
- new Error("Connect Querit in /connect, or set QUERIT_API_KEY"),
220
+ new Error("Connect Querit in /connect, or set OSP_QUERIT_API_KEY"),
221
221
  )
222
222
  }
223
223
 
@@ -112,7 +112,7 @@ type SearchResult = ReturnType<typeof toResult>
112
112
  // SERPAPI_HL language code for results, e.g. "en", "zh-cn"
113
113
  // SERPAPI_TBS advanced filters, e.g. "qdr:d" (past day), "qdr:y"
114
114
  // SERPAPI_TIMEOUT ms to wait before failing (0 removes the deadline)
115
- // SERPAPI_API_KEY required; from https://serpapi.com
115
+ // OSP_SERPAPI_API_KEY required; from https://serpapi.com
116
116
  //
117
117
  // There is deliberately no result-count knob: Google withdrew the `num`
118
118
  // parameter, so SerpApi cannot honour one and a count would be a lie.
@@ -167,7 +167,7 @@ export const effect = (ctx: Plugin.Context) =>
167
167
  })
168
168
  editor.method.update({
169
169
  integrationID: ID,
170
- method: { type: "env", names: ["SERPAPI_API_KEY"] },
170
+ method: { type: "env", names: ["OSP_SERPAPI_API_KEY"] },
171
171
  })
172
172
  })
173
173
 
@@ -187,7 +187,7 @@ export const effect = (ctx: Plugin.Context) =>
187
187
  const credential = connection
188
188
  ? yield* soften(ctx.integration.connection.resolve(connection))
189
189
  : undefined
190
- return credential?.type === "key" ? credential.key : process.env.SERPAPI_API_KEY
190
+ return credential?.type === "key" ? credential.key : process.env.OSP_SERPAPI_API_KEY
191
191
  })
192
192
 
193
193
  // SerpApi is bound to websearch only while a key exists. Deleting the key
@@ -211,7 +211,7 @@ export const effect = (ctx: Plugin.Context) =>
211
211
  const key = yield* resolveKey()
212
212
  if (!key) {
213
213
  return yield* Effect.fail(
214
- new Error("Connect SerpApi in /connect, or set SERPAPI_API_KEY"),
214
+ new Error("Connect SerpApi in /connect, or set OSP_SERPAPI_API_KEY"),
215
215
  )
216
216
  }
217
217
 
@@ -113,7 +113,7 @@ type SearchResult = ReturnType<typeof toResult>
113
113
  // SERPER_TBS time filter: "qdr:h" (hour) "qdr:d" (day) "qdr:w" (week)
114
114
  // "qdr:m" (month) "qdr:y" (year)
115
115
  // SERPER_TIMEOUT ms to wait before failing (0 removes the deadline)
116
- // SERPER_API_KEY required; from https://serper.dev
116
+ // OSP_SERPER_API_KEY required; from https://serper.dev
117
117
  const CACHE_TTL = envInt("SERPER_CACHE_TTL", 600_000)
118
118
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
119
119
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -166,7 +166,7 @@ export const effect = (ctx: Plugin.Context) =>
166
166
  })
167
167
  editor.method.update({
168
168
  integrationID: ID,
169
- method: { type: "env", names: ["SERPER_API_KEY"] },
169
+ method: { type: "env", names: ["OSP_SERPER_API_KEY"] },
170
170
  })
171
171
  })
172
172
 
@@ -186,7 +186,7 @@ export const effect = (ctx: Plugin.Context) =>
186
186
  const credential = connection
187
187
  ? yield* soften(ctx.integration.connection.resolve(connection))
188
188
  : undefined
189
- return credential?.type === "key" ? credential.key : process.env.SERPER_API_KEY
189
+ return credential?.type === "key" ? credential.key : process.env.OSP_SERPER_API_KEY
190
190
  })
191
191
 
192
192
  // Serper is bound to websearch only while a key exists. Deleting the key
@@ -210,7 +210,7 @@ export const effect = (ctx: Plugin.Context) =>
210
210
  const key = yield* resolveKey()
211
211
  if (!key) {
212
212
  return yield* Effect.fail(
213
- new Error("Connect Serper in /connect, or set SERPER_API_KEY"),
213
+ new Error("Connect Serper in /connect, or set OSP_SERPER_API_KEY"),
214
214
  )
215
215
  }
216
216
 
@@ -141,7 +141,7 @@ type SearchResult = ReturnType<typeof toResult>
141
141
  // YOUCOM_COUNTRY geographic focus, e.g. "US", "CN"
142
142
  // YOUCOM_FRESHNESS "day" | "week" | "month" | "year" or "YYYY-MM-DDtoYYYY-MM-DD"
143
143
  // YOUCOM_TIMEOUT ms to wait before failing (0 removes the deadline)
144
- // YDC_API_KEY required; You.com's canonical env var, shared with its SDKs
144
+ // OSP_YDC_API_KEY required; You.com's canonical env var, shared with its SDKs
145
145
  const CACHE_TTL = envInt("YOUCOM_CACHE_TTL", 600_000)
146
146
  // OpenCode's own providers each bounded themselves at 25s, and the host adds
147
147
  // no deadline of its own around `execute`, so this is the only one that exists.
@@ -197,7 +197,7 @@ export const effect = (ctx: Plugin.Context) =>
197
197
  })
198
198
  editor.method.update({
199
199
  integrationID: ID,
200
- method: { type: "env", names: ["YDC_API_KEY"] },
200
+ method: { type: "env", names: ["OSP_YDC_API_KEY"] },
201
201
  })
202
202
  })
203
203
 
@@ -217,7 +217,7 @@ export const effect = (ctx: Plugin.Context) =>
217
217
  const credential = connection
218
218
  ? yield* soften(ctx.integration.connection.resolve(connection))
219
219
  : undefined
220
- return credential?.type === "key" ? credential.key : process.env.YDC_API_KEY
220
+ return credential?.type === "key" ? credential.key : process.env.OSP_YDC_API_KEY
221
221
  })
222
222
 
223
223
  // You.com is bound to websearch only while a key exists. Deleting the key
@@ -241,7 +241,7 @@ export const effect = (ctx: Plugin.Context) =>
241
241
  const key = yield* resolveKey()
242
242
  if (!key) {
243
243
  return yield* Effect.fail(
244
- new Error("Connect You.com in /connect, or set YDC_API_KEY"),
244
+ new Error("Connect You.com in /connect, or set OSP_YDC_API_KEY"),
245
245
  )
246
246
  }
247
247