opencode-search-plugin 3.1.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.
- package/README.md +171 -134
- package/README.zh-CN.md +176 -139
- package/index.ts +5 -1
- package/package.json +4 -2
- package/plugins/perplexity-search.ts +332 -0
- package/plugins/zhipu-search.ts +426 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Perplexity's Search API answers with the bare ranked pages an agent reads:
|
|
5
|
+
// `results[]` of {title, url, snippet, date, last_updated}, with no generated
|
|
6
|
+
// prose to strip. That is what separates it from the vendor's Agent API
|
|
7
|
+
// (`/v1/agent`, which answers in prose) and the Sonar API it retired in
|
|
8
|
+
// September 2026. Every field except `url` is optional -- a missing one means
|
|
9
|
+
// "the source provided none", not an error -- and `snippet` is documented as
|
|
10
|
+
// "" for PDFs and short pages, which the mapping below tolerates rather than
|
|
11
|
+
// dropping the row over.
|
|
12
|
+
const PerplexityPage = Schema.Struct({
|
|
13
|
+
title: Schema.optional(Schema.String),
|
|
14
|
+
url: Schema.String,
|
|
15
|
+
snippet: Schema.optional(Schema.String),
|
|
16
|
+
date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
17
|
+
last_updated: Schema.optional(Schema.NullOr(Schema.String)),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The envelope carries a required `id`, a `server_time` the quickstart shows
|
|
21
|
+
// as null, and -- in some documented examples only -- a `query_info` object
|
|
22
|
+
// absent from the published schema. None of them is read here; the fields
|
|
23
|
+
// this provider needs are the ones declared above.
|
|
24
|
+
const PerplexityResponse = Schema.Struct({
|
|
25
|
+
results: Schema.optional(Schema.Array(PerplexityPage)),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
29
|
+
// The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
|
|
30
|
+
// vendor plugin's own entry are bare vendor names, so `perplexity` would
|
|
31
|
+
// collide with them, and with the opencode.db row keyed by integration ID.
|
|
32
|
+
const ID = "osp-perplexity"
|
|
33
|
+
|
|
34
|
+
function envInt(name: string, fallback: number): number {
|
|
35
|
+
const raw = (process.env[name] ?? "").trim()
|
|
36
|
+
if (raw === "") return fallback
|
|
37
|
+
const value = Number(raw)
|
|
38
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function envStr(name: string): string {
|
|
42
|
+
return (process.env[name] ?? "").trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Perplexity stamps both of its date fields as `YYYY-MM-DD`, which the parser
|
|
46
|
+
// below accepts as-is. The relative-date tables still ship byte-identical with
|
|
47
|
+
// the other providers in this package -- the parser comes from the same shared
|
|
48
|
+
// block, and `test/structure.test.ts` compares it byte for byte.
|
|
49
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
50
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
51
|
+
minute: 60_000,
|
|
52
|
+
hour: 3_600_000,
|
|
53
|
+
day: 86_400_000,
|
|
54
|
+
week: 604_800_000,
|
|
55
|
+
month: 2_592_000_000,
|
|
56
|
+
year: 31_536_000_000,
|
|
57
|
+
分钟: 60_000,
|
|
58
|
+
小时: 3_600_000,
|
|
59
|
+
天: 86_400_000,
|
|
60
|
+
周: 604_800_000,
|
|
61
|
+
个月: 2_592_000_000,
|
|
62
|
+
月: 2_592_000_000,
|
|
63
|
+
年: 31_536_000_000,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RELATIVE_DATE =
|
|
67
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
68
|
+
|
|
69
|
+
const ABSOLUTE_DATE = [
|
|
70
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
71
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
72
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
73
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
74
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
function parseSearchDate(value: string | undefined): number {
|
|
78
|
+
const text = value?.trim()
|
|
79
|
+
if (!text) return NaN
|
|
80
|
+
|
|
81
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
82
|
+
if (relative) {
|
|
83
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
84
|
+
const amount = Number(relative[1])
|
|
85
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
86
|
+
return Date.now() - amount * unit
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
90
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
91
|
+
// to 2001.
|
|
92
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toResult(item: {
|
|
96
|
+
title?: string | undefined
|
|
97
|
+
url: string
|
|
98
|
+
snippet?: string | undefined
|
|
99
|
+
date?: string | null | undefined
|
|
100
|
+
last_updated?: string | null | undefined
|
|
101
|
+
}) {
|
|
102
|
+
// `date` is the publication date and `last_updated` the page's own revision;
|
|
103
|
+
// both answer "when is this from", and OpenCode's result carries a single
|
|
104
|
+
// slot, so the update date stands in when the page declares no publication
|
|
105
|
+
// date. A page that declares neither simply reports no time.
|
|
106
|
+
const published = parseSearchDate(item.date ?? item.last_updated ?? undefined)
|
|
107
|
+
return {
|
|
108
|
+
url: item.url,
|
|
109
|
+
// `url` is the one field the schema insists on, so it is also the one thing
|
|
110
|
+
// a title can fall back to rather than leave the result untitled.
|
|
111
|
+
title: item.title || item.url,
|
|
112
|
+
// `||`, not `??`: an empty snippet means Perplexity extracted nothing from
|
|
113
|
+
// the page (its own domain-filter example returns "" for every PDF), and an
|
|
114
|
+
// empty string still leaves a result worth reading for its link.
|
|
115
|
+
content: item.snippet || "",
|
|
116
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
121
|
+
|
|
122
|
+
// Perplexity charges per successful request, not per token: $5.00 per 1,000
|
|
123
|
+
// requests for `web`, $1.00 for `fast`. A repeat query is a fresh billed
|
|
124
|
+
// dispatch, so responses are cached per normalized query. Knobs are read once
|
|
125
|
+
// at load time, restart after changing them.
|
|
126
|
+
//
|
|
127
|
+
// PERPLEXITY_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
128
|
+
// PERPLEXITY_MAX_RESULTS results per search, clamped to 1-20
|
|
129
|
+
// PERPLEXITY_SEARCH_TYPE "web" (the API's default), "fast" (a fifth of the
|
|
130
|
+
// price, same response shape) or "people"; unset
|
|
131
|
+
// omits the field and lets the API default apply
|
|
132
|
+
// PERPLEXITY_RECENCY "hour", "day", "week", "month" or "year"
|
|
133
|
+
// PERPLEXITY_COUNTRY ISO 3166-1 alpha-2 code, e.g. "US"
|
|
134
|
+
// PERPLEXITY_LANGUAGE ISO 639-1 codes, comma-separated; at most 10 are
|
|
135
|
+
// sent, the stricter of the two limits the docs state
|
|
136
|
+
// PERPLEXITY_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
137
|
+
// OSP_PERPLEXITY_API_KEY required; from https://console.perplexity.ai/project/keys
|
|
138
|
+
const CACHE_TTL = envInt("PERPLEXITY_CACHE_TTL", 600_000)
|
|
139
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
140
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
141
|
+
const SEARCH_TIMEOUT = envInt("PERPLEXITY_TIMEOUT", 25_000)
|
|
142
|
+
// Web search takes at most 20 (`max_results` above 20 requires `search_type=
|
|
143
|
+
// people`), so the clamp stays inside what the default search type accepts.
|
|
144
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("PERPLEXITY_MAX_RESULTS", 10), 1), 20)
|
|
145
|
+
const SEARCH_TYPE = ["web", "fast", "people"].includes(envStr("PERPLEXITY_SEARCH_TYPE"))
|
|
146
|
+
? envStr("PERPLEXITY_SEARCH_TYPE")
|
|
147
|
+
: ""
|
|
148
|
+
const RECENCY = ["hour", "day", "week", "month", "year"].includes(
|
|
149
|
+
envStr("PERPLEXITY_RECENCY"),
|
|
150
|
+
)
|
|
151
|
+
? envStr("PERPLEXITY_RECENCY")
|
|
152
|
+
: ""
|
|
153
|
+
const COUNTRY = envStr("PERPLEXITY_COUNTRY")
|
|
154
|
+
// The spec allows 20 codes where the quickstart warns of 10; the stricter of
|
|
155
|
+
// the two documented limits is the one enforced here, so a request built from
|
|
156
|
+
// this knob cannot be the one that trips validation.
|
|
157
|
+
const LANGUAGE = envStr("PERPLEXITY_LANGUAGE")
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((code) => code.trim())
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.slice(0, 10)
|
|
162
|
+
|
|
163
|
+
const CACHE_LIMIT = 200
|
|
164
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
165
|
+
|
|
166
|
+
function cacheKey(query: string): string {
|
|
167
|
+
return query.trim().replace(/\s+/g, " ")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
171
|
+
if (CACHE_TTL <= 0) return undefined
|
|
172
|
+
const hit = cache.get(queryKey)
|
|
173
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
177
|
+
if (CACHE_TTL <= 0) return
|
|
178
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
179
|
+
if (cache.size > CACHE_LIMIT) {
|
|
180
|
+
const oldest = cache.keys().next().value
|
|
181
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
186
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
187
|
+
// removes the deadline but never the cancellation, because
|
|
188
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
189
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
190
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
// Make "Perplexity" show up in /connect with an API-key method.
|
|
196
|
+
yield* ctx.integration.transform((editor) => {
|
|
197
|
+
editor.update(ID, (integration) => {
|
|
198
|
+
integration.name = "Perplexity (osp)"
|
|
199
|
+
})
|
|
200
|
+
editor.method.update({
|
|
201
|
+
integrationID: ID,
|
|
202
|
+
method: { type: "key", label: "Perplexity API key" },
|
|
203
|
+
})
|
|
204
|
+
editor.method.update({
|
|
205
|
+
integrationID: ID,
|
|
206
|
+
method: { type: "env", names: ["OSP_PERPLEXITY_API_KEY"] },
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
211
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
212
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
213
|
+
// environment fallback below applies exactly as it does when the credential
|
|
214
|
+
// simply does not exist.
|
|
215
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
216
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
217
|
+
|
|
218
|
+
// The key comes from the /connect credential, falling back to the
|
|
219
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
220
|
+
const resolveKey = () =>
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
223
|
+
const credential = connection
|
|
224
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
225
|
+
: undefined
|
|
226
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_PERPLEXITY_API_KEY
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Perplexity has no anonymous tier -- `POST /search` answers 401 without a
|
|
230
|
+
// key -- so it is bound to websearch only while a key exists. Deleting the
|
|
231
|
+
// key unbinds the provider, so `random` can never pick a keyless Perplexity.
|
|
232
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
233
|
+
let bound = Boolean(yield* resolveKey())
|
|
234
|
+
|
|
235
|
+
yield* ctx.websearch.transform((editor) => {
|
|
236
|
+
if (!bound) return
|
|
237
|
+
editor.add({
|
|
238
|
+
id: ID,
|
|
239
|
+
name: "Perplexity",
|
|
240
|
+
execute: ({ query }) =>
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const queryKey = cacheKey(query)
|
|
243
|
+
const cached = readCache(queryKey)
|
|
244
|
+
if (cached) return cached
|
|
245
|
+
|
|
246
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
247
|
+
// event round-trip, so this keeps that window explicit.
|
|
248
|
+
const key = yield* resolveKey()
|
|
249
|
+
if (!key) {
|
|
250
|
+
return yield* Effect.fail(
|
|
251
|
+
new Error("Connect Perplexity in /connect, or set OSP_PERPLEXITY_API_KEY"),
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const response = yield* Effect.tryPromise({
|
|
256
|
+
try: (signal) =>
|
|
257
|
+
fetch("https://api.perplexity.ai/search", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: {
|
|
260
|
+
Accept: "application/json",
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
Authorization: "Bearer " + key,
|
|
263
|
+
},
|
|
264
|
+
body: JSON.stringify({
|
|
265
|
+
query: queryKey,
|
|
266
|
+
max_results: SEARCH_SIZE,
|
|
267
|
+
...(SEARCH_TYPE ? { search_type: SEARCH_TYPE } : {}),
|
|
268
|
+
...(RECENCY ? { search_recency_filter: RECENCY } : {}),
|
|
269
|
+
...(COUNTRY ? { country: COUNTRY } : {}),
|
|
270
|
+
...(LANGUAGE.length > 0 ? { search_language_filter: LANGUAGE } : {}),
|
|
271
|
+
}),
|
|
272
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
273
|
+
}),
|
|
274
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
275
|
+
// for a key problem that was never there.
|
|
276
|
+
catch: (error) =>
|
|
277
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
278
|
+
? new Error("Perplexity search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
279
|
+
: new Error("Perplexity request failed: " + String(error)),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
const detail = yield* Effect.tryPromise({
|
|
284
|
+
try: () => response.text(),
|
|
285
|
+
catch: () => new Error("response body unavailable"),
|
|
286
|
+
})
|
|
287
|
+
return yield* Effect.fail(
|
|
288
|
+
new Error("Perplexity API error " + response.status + ": " + detail),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const json = yield* Effect.tryPromise({
|
|
293
|
+
try: () => response.json(),
|
|
294
|
+
catch: (error) => new Error("Invalid Perplexity response: " + String(error)),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const body = yield* Schema.decodeUnknownEffect(PerplexityResponse)(json)
|
|
298
|
+
const results = (body.results ?? []).map(toResult)
|
|
299
|
+
|
|
300
|
+
// Perplexity charges per dispatch, so a response that never enters
|
|
301
|
+
// the cache is a query paid for again on every repeat.
|
|
302
|
+
writeCache(queryKey, results)
|
|
303
|
+
return results
|
|
304
|
+
}),
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
309
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
310
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
311
|
+
yield* Effect.forkScoped(
|
|
312
|
+
ctx.event.subscribe().pipe(
|
|
313
|
+
Stream.filter(
|
|
314
|
+
(event) =>
|
|
315
|
+
event.type === "credential.updated" ||
|
|
316
|
+
event.type === "credential.switched" ||
|
|
317
|
+
event.type === "integration.updated",
|
|
318
|
+
),
|
|
319
|
+
Stream.mapEffect(() =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const next = Boolean(yield* resolveKey())
|
|
322
|
+
if (next === bound) return
|
|
323
|
+
bound = next
|
|
324
|
+
yield* ctx.websearch.reload()
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
Stream.runDrain,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
export default Plugin.define({ id: "perplexity-search", effect })
|