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.
@@ -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 })