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.
@@ -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
- // DEEPSEEK_API_KEY required; from https://platform.deepseek.com
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: ["DEEPSEEK_API_KEY"] },
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.DEEPSEEK_API_KEY
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 DEEPSEEK_API_KEY"),
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
- catch: (error) => new Error("DeepSeek request failed: " + String(error)),
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) {
@@ -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
- // JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
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: ["JINA_API_KEY"] },
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.JINA_API_KEY
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 JINA_API_KEY"),
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
- catch: (error) => new Error("Jina request failed: " + String(error)),
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
- // KEENABLE_API_KEY optional; keyless access works without it
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: ["KEENABLE_API_KEY"] },
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.KEENABLE_API_KEY
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
- catch: (error) => new Error("Keenable request failed: " + String(error)),
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 KEENABLE_API_KEY to buy credits, or " +
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 KEENABLE_API_KEY to spend your " +
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
  )
@@ -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
- // LINKUP_API_KEY required; from https://app.linkup.so
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: ["LINKUP_API_KEY"] },
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.LINKUP_API_KEY
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 LINKUP_API_KEY"),
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
- catch: (error) => new Error("Linkup request failed: " + String(error)),
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) {
@@ -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
- // METASO_API_KEY required; from https://metaso.cn
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: ["METASO_API_KEY"] },
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.METASO_API_KEY
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 METASO_API_KEY"),
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
- catch: (error) => new Error("Metaso request failed: " + String(error)),
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 })