opencode-search-plugin 3.2.0 → 3.3.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.
@@ -0,0 +1,426 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // Zhipu answers with `search_result` at the top level, alongside passengers
5
+ // this provider never reads: `id`/`created`/`request_id` name the call and
6
+ // `search_intent` echoes the query rewrite (present only when intent
7
+ // recognition ran). Each hit carries `media` (site name), `icon` and `refer`
8
+ // (the citation index the chat product stamps its answers with) next to the
9
+ // four fields mapped below. The docs show every hit with a `link`, but a
10
+ // search list is only as good as its worst hit: all four decode as nullable
11
+ // and a hit without a URL is dropped, so one malformed row cannot fail the
12
+ // whole list.
13
+ const ZhipuHit = Schema.Struct({
14
+ title: Schema.optional(Schema.NullOr(Schema.String)),
15
+ content: Schema.optional(Schema.NullOr(Schema.String)),
16
+ link: Schema.optional(Schema.NullOr(Schema.String)),
17
+ // "2025-05-23" in the documented examples, or absent when unknown.
18
+ publish_date: Schema.optional(Schema.NullOr(Schema.String)),
19
+ })
20
+
21
+ const ZhipuResponse = Schema.Struct({
22
+ // Absent or null when the search found nothing.
23
+ search_result: Schema.optional(Schema.NullOr(Schema.Array(ZhipuHit))),
24
+ })
25
+
26
+ // The /connect integration ID, and the websearch provider ID it binds to.
27
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
28
+ // vendor plugin's own entry are bare vendor names, so `zhipu` would collide
29
+ // with them, and with the opencode.db row keyed by integration ID.
30
+ const ID = "osp-zhipu"
31
+
32
+ function envInt(name: string, fallback: number): number {
33
+ const raw = (process.env[name] ?? "").trim()
34
+ if (raw === "") return fallback
35
+ const value = Number(raw)
36
+ return Number.isFinite(value) && value >= 0 ? value : fallback
37
+ }
38
+
39
+ function envStr(name: string): string {
40
+ return (process.env[name] ?? "").trim()
41
+ }
42
+
43
+ // Zhipu stamps `publish_date` as a bare calendar date ("2025-05-23"), a shape
44
+ // the parser below already accepts as-is. The relative-date tables still ship
45
+ // byte-identical with the other providers in this package -- the parser comes
46
+ // from the same shared block, and `test/structure.test.ts` compares it byte
47
+ // for byte.
48
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
49
+ const RELATIVE_MS: Record<string, number> = {
50
+ minute: 60_000,
51
+ hour: 3_600_000,
52
+ day: 86_400_000,
53
+ week: 604_800_000,
54
+ month: 2_592_000_000,
55
+ year: 31_536_000_000,
56
+ 分钟: 60_000,
57
+ 小时: 3_600_000,
58
+ 天: 86_400_000,
59
+ 周: 604_800_000,
60
+ 个月: 2_592_000_000,
61
+ 月: 2_592_000_000,
62
+ 年: 31_536_000_000,
63
+ }
64
+
65
+ const RELATIVE_DATE =
66
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
67
+
68
+ const ABSOLUTE_DATE = [
69
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
70
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
71
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
72
+ // ISO 8601 with a time component, which some providers return verbatim.
73
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
74
+ ]
75
+
76
+ function parseSearchDate(value: string | undefined): number {
77
+ const text = value?.trim()
78
+ if (!text) return NaN
79
+
80
+ const relative = RELATIVE_DATE.exec(text)
81
+ if (relative) {
82
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
83
+ const amount = Number(relative[1])
84
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
85
+ return Date.now() - amount * unit
86
+ }
87
+
88
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
89
+ // parser invents a year for strings like "Nov 1", which would date the result
90
+ // to 2001.
91
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
92
+ }
93
+
94
+ // `publish_date` is the only time field in a hit, and it is a bare calendar
95
+ // date in the documented examples -- parsed by the shared block above, and
96
+ // left as no `time` at all when absent or unparseable.
97
+ function toResult(item: {
98
+ title?: string | null | undefined
99
+ content?: string | null | undefined
100
+ link?: string | null | undefined
101
+ publish_date?: string | null | undefined
102
+ }) {
103
+ // A hit without a URL cannot be opened, cited, or read as a source, so it
104
+ // leaves the list instead of reaching the agent as a dead card.
105
+ if (!item.link) return undefined
106
+ const published = parseSearchDate(item.publish_date ?? undefined)
107
+ return {
108
+ url: item.link,
109
+ // The docs always fill `title` in their examples, but `link` is the field
110
+ // the mapping insists on, so it is also the one thing a title can fall
111
+ // back to rather than leave the result untitled.
112
+ title: item.title || item.link,
113
+ // `content` is the excerpt Zhipu derived for the hit. `||`, not `??`: an
114
+ // empty string means there was no excerpt worth reading, and an empty
115
+ // string is not an answer worth handing the agent.
116
+ content: item.content || "",
117
+ time: Number.isFinite(published) ? { published } : {},
118
+ }
119
+ }
120
+
121
+ type SearchResult = NonNullable<ReturnType<typeof toResult>>
122
+
123
+ // Zhipu bills per search -- ¥0.01 on Search-Std, ¥0.03 on Search-Pro, ¥0.05 on
124
+ // the Sogou and Quark engines, $0.01 on the international endpoint -- with no
125
+ // free tier and no anonymous tier, so a repeat query is a dispatch worth
126
+ // skipping. Knobs are read once at load time, so restart after changing them.
127
+ //
128
+ // ZHIPU_CACHE_TTL ms to reuse a response for the same query (0 disables)
129
+ // ZHIPU_COUNT results per search, clamped to 1-50 (the API's range)
130
+ // ZHIPU_ENGINE search_std (default, ¥0.01/search), search_pro (¥0.03,
131
+ // higher recall), search_pro_sogou, search_pro_quark
132
+ // (¥0.05 each); on ZHIPU_SITE=intl the only engine the
133
+ // docs accept is search-prime
134
+ // ZHIPU_RECENCY oneDay, oneWeek, oneMonth, oneYear, noLimit; unset
135
+ // omits the parameter, which is the API's noLimit default
136
+ // ZHIPU_CONTENT_SIZE medium or high; unset omits it (the API defaults to
137
+ // medium). high sends fuller excerpts at the same price
138
+ // ZHIPU_SITE cn (default) for open.bigmodel.cn, intl for api.z.ai --
139
+ // separate accounts, keys and prices, not interchangeable
140
+ // ZHIPU_TIMEOUT ms to wait before failing (0 removes the deadline)
141
+ // OSP_ZHIPU_API_KEY required; from https://bigmodel.cn/usercenter/proj-mgmt/apikeys
142
+ const CACHE_TTL = envInt("ZHIPU_CACHE_TTL", 600_000)
143
+ // OpenCode's own providers each bounded themselves at 25s, and the host adds
144
+ // no deadline of its own around `execute`, so this is the only one that exists.
145
+ const SEARCH_TIMEOUT = envInt("ZHIPU_TIMEOUT", 25_000)
146
+ const COUNT = Math.min(Math.max(envInt("ZHIPU_COUNT", 10), 1), 50)
147
+ const SITE = envStr("ZHIPU_SITE") === "intl" ? "intl" : "cn"
148
+ const ENGINE = envStr("ZHIPU_ENGINE") || (SITE === "intl" ? "search-prime" : "search_std")
149
+ const RECENCY = envStr("ZHIPU_RECENCY")
150
+ const CONTENT_SIZE = envStr("ZHIPU_CONTENT_SIZE")
151
+
152
+ // Two endpoints, two account systems: a cn key is not an intl key, and the
153
+ // engine enums and prices differ with the host. The path is shared.
154
+ const SEARCH_URL =
155
+ SITE === "intl"
156
+ ? "https://api.z.ai/api/paas/v4/web_search"
157
+ : "https://open.bigmodel.cn/api/paas/v4/web_search"
158
+
159
+ // The spec caps `search_query` at 70 characters (`maxLength: 70`), so a longer
160
+ // query is cut here rather than sent to fail as a 400. Cut by code point, so
161
+ // a CJK character or an emoji is never sliced through its halves. The cache
162
+ // still keys on the full query: two queries that differ only past character
163
+ // 70 are two separate answers the agent gave, even if they bill alike.
164
+ function toSearchQuery(queryKey: string): string {
165
+ return [...queryKey].slice(0, 70).join("")
166
+ }
167
+
168
+ // `count` is documented for `search_std`, `search_pro` and `search_pro_sogou`
169
+ // on the cn endpoint, and for every engine on the intl one, so it is omitted
170
+ // on `search_pro_quark` -- whose row omits it -- rather than sent to be
171
+ // ignored. Sogou accepts only five sizes (10/20/30/40/50), so a knob value
172
+ // snaps to the nearest of them instead of arriving as a rejected 15.
173
+ function countFor(engine: string, count: number): number | undefined {
174
+ if (engine === "search_pro_quark") return undefined
175
+ if (engine === "search_pro_sogou") {
176
+ return Math.min(Math.max(Math.round(count / 10) * 10, 10), 50)
177
+ }
178
+ return count
179
+ }
180
+
181
+ const CACHE_LIMIT = 200
182
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
183
+
184
+ function cacheKey(query: string): string {
185
+ return query.trim().replace(/\s+/g, " ")
186
+ }
187
+
188
+ function readCache(queryKey: string): SearchResult[] | undefined {
189
+ if (CACHE_TTL <= 0) return undefined
190
+ const hit = cache.get(queryKey)
191
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
192
+ }
193
+
194
+ function writeCache(queryKey: string, results: SearchResult[]): void {
195
+ if (CACHE_TTL <= 0) return
196
+ cache.set(queryKey, { at: Date.now(), results })
197
+ if (cache.size > CACHE_LIMIT) {
198
+ const oldest = cache.keys().next().value
199
+ if (oldest !== undefined) cache.delete(oldest)
200
+ }
201
+ }
202
+
203
+ // Zhipu does not document this endpoint's refusal body, so the detail is read
204
+ // for whichever shape it actually arrives in: the OpenAI-compatible
205
+ // `{"error":{"code","message"}}` the platform's chat API answers with, or a
206
+ // flat `{"code","message"}` carrying the search-specific codes (1701: search
207
+ // concurrency at its cap, 1702: no usable engine, 1703: the engine returned
208
+ // nothing). A body in any other shape stands in as the detail verbatim.
209
+ function describeFailure(detail: string): string {
210
+ try {
211
+ const body: unknown = JSON.parse(detail)
212
+ if (typeof body === "object" && body !== null) {
213
+ const record = body as Record<string, unknown>
214
+ const source = (
215
+ typeof record.error === "object" && record.error !== null ? record.error : record
216
+ ) as Record<string, unknown>
217
+ const message =
218
+ typeof source.message === "string" && source.message !== ""
219
+ ? source.message
220
+ : typeof source.msg === "string" && source.msg !== ""
221
+ ? source.msg
222
+ : ""
223
+ if (message !== "") {
224
+ const code = source.code
225
+ const named =
226
+ code === undefined || code === null || code === "" ? "" : ` (code ${String(code)})`
227
+ return message + named
228
+ }
229
+ }
230
+ } catch {
231
+ // Not JSON: the raw body is the only detail there is.
232
+ }
233
+ return detail
234
+ }
235
+
236
+ // A hung upstream must not hang the agent: the deadline bounds it, and the
237
+ // thunk's signal carries host cancellation into the socket. `timeout <= 0`
238
+ // removes the deadline but never the cancellation, because
239
+ // `AbortSignal.timeout(0)` aborts instantly rather than "never".
240
+ function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
241
+ return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
242
+ }
243
+
244
+ export const effect = (ctx: Plugin.Context) =>
245
+ Effect.gen(function* () {
246
+ // Make "Zhipu" show up in /connect with an API-key method.
247
+ yield* ctx.integration.transform((editor) => {
248
+ editor.update(ID, (integration) => {
249
+ integration.name = "Zhipu Search (osp)"
250
+ })
251
+ editor.method.update({
252
+ integrationID: ID,
253
+ method: { type: "key", label: "Zhipu API key" },
254
+ })
255
+ editor.method.update({
256
+ integrationID: ID,
257
+ method: { type: "env", names: ["OSP_ZHIPU_API_KEY"] },
258
+ })
259
+ })
260
+
261
+ // The host instantiates this API's failure channel as `unknown`, which would
262
+ // make the whole plugin effect unrunnable. A credential read must never take
263
+ // the plugin down: a failed read degrades to "no stored key", and the
264
+ // environment fallback below applies exactly as it does when the credential
265
+ // simply does not exist.
266
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
267
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
268
+
269
+ // The key comes from the /connect credential, falling back to the
270
+ // environment. Resolved per call so a rotated key needs no restart.
271
+ const resolveKey = () =>
272
+ Effect.gen(function* () {
273
+ const connection = yield* soften(ctx.integration.connection.active(ID))
274
+ const credential = connection
275
+ ? yield* soften(ctx.integration.connection.resolve(connection))
276
+ : undefined
277
+ return credential?.type === "key" ? credential.key : process.env.OSP_ZHIPU_API_KEY
278
+ })
279
+
280
+ // Zhipu has no anonymous tier, so the provider binds only while a key
281
+ // exists. Deleting the key unbinds it, and `random` can never pick a
282
+ // keyless Zhipu. The transform replays on reload and reads `bound` as it
283
+ // stands then.
284
+ let bound = Boolean(yield* resolveKey())
285
+
286
+ yield* ctx.websearch.transform((editor) => {
287
+ if (!bound) return
288
+ editor.add({
289
+ id: ID,
290
+ name: "Zhipu Search",
291
+ execute: ({ query }) =>
292
+ Effect.gen(function* () {
293
+ const queryKey = cacheKey(query)
294
+ const cached = readCache(queryKey)
295
+ if (cached) return cached
296
+
297
+ // Rechecked per call: binding can lag a key deletion by the
298
+ // event round-trip, so this keeps that window explicit.
299
+ const key = yield* resolveKey()
300
+ if (!key) {
301
+ return yield* Effect.fail(
302
+ new Error("Connect Zhipu Search in /connect, or set OSP_ZHIPU_API_KEY"),
303
+ )
304
+ }
305
+
306
+ const response = yield* Effect.tryPromise({
307
+ try: (signal) =>
308
+ fetch(SEARCH_URL, {
309
+ method: "POST",
310
+ headers: {
311
+ Accept: "application/json",
312
+ "Content-Type": "application/json",
313
+ Authorization: "Bearer " + key,
314
+ },
315
+ body: JSON.stringify({
316
+ // Cut to the spec's 70-character ceiling (`maxLength: 70`)
317
+ // before dispatch.
318
+ search_query: toSearchQuery(queryKey),
319
+ search_engine: ENGINE,
320
+ // Intent recognition rewrites a query into a search phrase;
321
+ // the agent already writes one, and the cn endpoint marks
322
+ // this field required with a documented default of `false`.
323
+ // The intl endpoint documents no such field, so it never
324
+ // crosses the site boundary.
325
+ ...(SITE === "cn" ? { search_intent: false } : {}),
326
+ // Omitted where the engine's own docs leave `count` out
327
+ // (Quark), and snapped to Sogou's five accepted sizes.
328
+ ...(countFor(ENGINE, COUNT) === undefined
329
+ ? {}
330
+ : { count: countFor(ENGINE, COUNT) }),
331
+ // Omitted rather than sent as "noLimit": the two mean the
332
+ // same thing, and noLimit is the documented default.
333
+ ...(RECENCY ? { search_recency_filter: RECENCY } : {}),
334
+ // Snippet length, not price -- `high` sends fuller excerpts
335
+ // for the same per-search fee. cn-only, like `search_intent`.
336
+ ...(SITE === "cn" && CONTENT_SIZE ? { content_size: CONTENT_SIZE } : {}),
337
+ }),
338
+ signal: timedSignal(signal, SEARCH_TIMEOUT),
339
+ }),
340
+ // A timeout is its own failure: naming it stops the user hunting
341
+ // for a key problem that was never there.
342
+ catch: (error) =>
343
+ error instanceof Error && error.name === "TimeoutError"
344
+ ? new Error("Zhipu search timed out after " + SEARCH_TIMEOUT + "ms")
345
+ : new Error("Zhipu request failed: " + String(error)),
346
+ })
347
+
348
+ if (!response.ok) {
349
+ const detail = yield* Effect.tryPromise({
350
+ try: () => response.text(),
351
+ catch: () => new Error("response body unavailable"),
352
+ })
353
+ const described = describeFailure(String(detail))
354
+
355
+ // The two below carry a fix the user can act on -- a bare status
356
+ // code would send them hunting for an auth problem that was
357
+ // really a cap, or the other way round.
358
+ if (response.status === 401) {
359
+ return yield* Effect.fail(
360
+ new Error(
361
+ "Zhipu rejected the API key (HTTP 401): " +
362
+ described +
363
+ ". Connect Zhipu Search in /connect or set OSP_ZHIPU_API_KEY to a key from https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
364
+ ),
365
+ )
366
+ }
367
+ if (response.status === 429) {
368
+ const retry = response.headers.get("Retry-After")
369
+ return yield* Effect.fail(
370
+ new Error(
371
+ "Zhipu rate limited the request (HTTP 429)" +
372
+ (retry ? ", retry after " + retry + "s" : "") +
373
+ ": " +
374
+ described +
375
+ ". Concurrent searches are capped per account (the API's code 1701); retry shortly or run fewer searches at once",
376
+ ),
377
+ )
378
+ }
379
+ return yield* Effect.fail(
380
+ new Error("Zhipu API error " + response.status + ": " + described),
381
+ )
382
+ }
383
+
384
+ const json = yield* Effect.tryPromise({
385
+ try: () => response.json(),
386
+ catch: (error) => new Error("Invalid Zhipu response: " + String(error)),
387
+ })
388
+
389
+ const body = yield* Schema.decodeUnknownEffect(ZhipuResponse)(json)
390
+ const results = (body.search_result ?? [])
391
+ .map(toResult)
392
+ .filter((result): result is SearchResult => result !== undefined)
393
+
394
+ // Zhipu charges per dispatch, so a result that never enters the
395
+ // cache is a query paid for again on every repeat.
396
+ writeCache(queryKey, results)
397
+ return results
398
+ }),
399
+ })
400
+ })
401
+
402
+ // Rebind on credential changes: connect, delete, or rotate a key and the
403
+ // provider joins or leaves the websearch pool without a restart. Env-var
404
+ // keys cannot change under a running process, so they bind at startup.
405
+ yield* Effect.forkScoped(
406
+ ctx.event.subscribe().pipe(
407
+ Stream.filter(
408
+ (event) =>
409
+ event.type === "credential.updated" ||
410
+ event.type === "credential.switched" ||
411
+ event.type === "integration.updated",
412
+ ),
413
+ Stream.mapEffect(() =>
414
+ Effect.gen(function* () {
415
+ const next = Boolean(yield* resolveKey())
416
+ if (next === bound) return
417
+ bound = next
418
+ yield* ctx.websearch.reload()
419
+ }),
420
+ ),
421
+ Stream.runDrain,
422
+ ),
423
+ )
424
+ })
425
+
426
+ export default Plugin.define({ id: "zhipu-search", effect })