opencode-search-plugin 0.0.0-stage → 1.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.
@@ -0,0 +1,269 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // SerpApi returns Google's organic results as JSON. Only `organic_results`
5
+ // carries links an agent reads; answer boxes and knowledge graphs have no
6
+ // source URL, so they stay out. Every result field is optional -- a missing one
7
+ // means "Google provided none", not an error.
8
+ const SerpApiResponse = Schema.Struct({
9
+ organic_results: Schema.optional(
10
+ Schema.Array(
11
+ Schema.Struct({
12
+ title: Schema.String,
13
+ link: Schema.String,
14
+ snippet: Schema.optional(Schema.String),
15
+ date: Schema.optional(Schema.String),
16
+ }),
17
+ ),
18
+ ),
19
+ })
20
+
21
+ // The /connect integration ID, and the websearch provider ID it binds to.
22
+ const ID = "serpapi"
23
+
24
+ function envInt(name: string, fallback: number): number {
25
+ const raw = (process.env[name] ?? "").trim()
26
+ if (raw === "") return fallback
27
+ const value = Number(raw)
28
+ return Number.isFinite(value) && value >= 0 ? value : fallback
29
+ }
30
+
31
+ function envStr(name: string): string {
32
+ return (process.env[name] ?? "").trim()
33
+ }
34
+
35
+ // Search engines hand out dates in three flavours: relative ("28 minutes ago"),
36
+ // absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
37
+ // relative ("4天前"). Relative values are rounded upstream, so month/year use
38
+ // 30/365 days. ISO 8601 timestamps are accepted as-is.
39
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
40
+ const RELATIVE_MS: Record<string, number> = {
41
+ minute: 60_000,
42
+ hour: 3_600_000,
43
+ day: 86_400_000,
44
+ week: 604_800_000,
45
+ month: 2_592_000_000,
46
+ year: 31_536_000_000,
47
+ 分钟: 60_000,
48
+ 小时: 3_600_000,
49
+ 天: 86_400_000,
50
+ 周: 604_800_000,
51
+ 个月: 2_592_000_000,
52
+ 月: 2_592_000_000,
53
+ 年: 31_536_000_000,
54
+ }
55
+
56
+ const RELATIVE_DATE =
57
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
58
+
59
+ const ABSOLUTE_DATE = [
60
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
61
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
62
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
63
+ // ISO 8601 with a time component, which some providers return verbatim.
64
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
65
+ ]
66
+
67
+ function parseSearchDate(value: string | undefined): number {
68
+ const text = value?.trim()
69
+ if (!text) return NaN
70
+
71
+ const relative = RELATIVE_DATE.exec(text)
72
+ if (relative) {
73
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
74
+ const amount = Number(relative[1])
75
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
76
+ return Date.now() - amount * unit
77
+ }
78
+
79
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
80
+ // parser invents a year for strings like "Nov 1", which would date the result
81
+ // to 2001.
82
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
83
+ }
84
+
85
+ function toResult(item: {
86
+ title: string
87
+ link: string
88
+ snippet?: string | undefined
89
+ date?: string | undefined
90
+ }) {
91
+ const published = parseSearchDate(item.date)
92
+ return {
93
+ url: item.link,
94
+ title: item.title,
95
+ content: item.snippet ?? "",
96
+ time: Number.isFinite(published) ? { published } : {},
97
+ }
98
+ }
99
+
100
+ type SearchResult = ReturnType<typeof toResult>
101
+
102
+ // SerpApi charges per search, and a repeat query is a fresh charge. SerpApi
103
+ // also keeps its own identical-query cache for an hour, which it does not bill,
104
+ // so a client-side cache mainly saves the round trip. Knobs are read once at
105
+ // load time, restart after changing them.
106
+ //
107
+ // SERPAPI_CACHE_TTL ms to reuse a response for the same query (0 disables)
108
+ // SERPAPI_GL country code for results, e.g. "us", "cn"
109
+ // SERPAPI_HL language code for results, e.g. "en", "zh-cn"
110
+ // SERPAPI_TBS advanced filters, e.g. "qdr:d" (past day), "qdr:y"
111
+ // SERPAPI_API_KEY required; from https://serpapi.com
112
+ //
113
+ // There is deliberately no result-count knob: Google withdrew the `num`
114
+ // parameter, so SerpApi cannot honour one and a count would be a lie.
115
+ const CACHE_TTL = envInt("SERPAPI_CACHE_TTL", 600_000)
116
+ const GL = envStr("SERPAPI_GL")
117
+ const HL = envStr("SERPAPI_HL")
118
+ const TBS = envStr("SERPAPI_TBS")
119
+
120
+ const CACHE_LIMIT = 200
121
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
122
+
123
+ function cacheKey(query: string): string {
124
+ return query.trim().replace(/\s+/g, " ")
125
+ }
126
+
127
+ function readCache(queryKey: string): SearchResult[] | undefined {
128
+ if (CACHE_TTL <= 0) return undefined
129
+ const hit = cache.get(queryKey)
130
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
131
+ }
132
+
133
+ function writeCache(queryKey: string, results: SearchResult[]): void {
134
+ if (CACHE_TTL <= 0) return
135
+ cache.set(queryKey, { at: Date.now(), results })
136
+ if (cache.size > CACHE_LIMIT) {
137
+ const oldest = cache.keys().next().value
138
+ if (oldest !== undefined) cache.delete(oldest)
139
+ }
140
+ }
141
+
142
+ export const effect = (ctx: Plugin.Context) =>
143
+ Effect.gen(function* () {
144
+ // Make "SerpApi" show up in /connect with an API-key method.
145
+ yield* ctx.integration.transform((editor) => {
146
+ editor.update(ID, (integration) => {
147
+ integration.name = "SerpApi"
148
+ })
149
+ editor.method.update({
150
+ integrationID: ID,
151
+ method: { type: "key", label: "SerpApi API key" },
152
+ })
153
+ editor.method.update({
154
+ integrationID: ID,
155
+ method: { type: "env", names: ["SERPAPI_API_KEY"] },
156
+ })
157
+ })
158
+
159
+ // The host instantiates this API's failure channel as `unknown`, which would
160
+ // make the whole plugin effect unrunnable. A credential read must never take
161
+ // the plugin down: a failed read degrades to "no stored key", and the
162
+ // environment fallback below applies exactly as it does when the credential
163
+ // simply does not exist.
164
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
165
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
166
+
167
+ // The key comes from the /connect credential, falling back to the
168
+ // environment. Resolved per call so a rotated key needs no restart.
169
+ const resolveKey = () =>
170
+ Effect.gen(function* () {
171
+ const connection = yield* soften(ctx.integration.connection.active(ID))
172
+ const credential = connection
173
+ ? yield* soften(ctx.integration.connection.resolve(connection))
174
+ : undefined
175
+ return credential?.type === "key" ? credential.key : process.env.SERPAPI_API_KEY
176
+ })
177
+
178
+ // SerpApi is bound to websearch only while a key exists. Deleting the key
179
+ // unbinds the provider, so `random` can never pick a keyless SerpApi.
180
+ // The transform replays on reload and reads `bound` as it stands then.
181
+ let bound = Boolean(yield* resolveKey())
182
+
183
+ yield* ctx.websearch.transform((editor) => {
184
+ if (!bound) return
185
+ editor.add({
186
+ id: ID,
187
+ name: "SerpApi",
188
+ execute: ({ query }) =>
189
+ Effect.gen(function* () {
190
+ const queryKey = cacheKey(query)
191
+ const cached = readCache(queryKey)
192
+ if (cached) return cached
193
+
194
+ // Rechecked per call: binding can lag a key deletion by the
195
+ // event round-trip, so this keeps that window explicit.
196
+ const key = yield* resolveKey()
197
+ if (!key) {
198
+ return yield* Effect.fail(
199
+ new Error("Connect SerpApi in /connect, or set SERPAPI_API_KEY"),
200
+ )
201
+ }
202
+
203
+ const url = new URL("https://serpapi.com/search.json")
204
+ url.searchParams.set("engine", "google")
205
+ url.searchParams.set("q", queryKey)
206
+ url.searchParams.set("api_key", key)
207
+ if (GL) url.searchParams.set("gl", GL)
208
+ if (HL) url.searchParams.set("hl", HL)
209
+ if (TBS) url.searchParams.set("tbs", TBS)
210
+
211
+ const response = yield* Effect.tryPromise({
212
+ try: () =>
213
+ fetch(url, {
214
+ headers: {
215
+ Accept: "application/json",
216
+ },
217
+ }),
218
+ catch: (error) => new Error("SerpApi request failed: " + String(error)),
219
+ })
220
+
221
+ if (!response.ok) {
222
+ const detail = yield* Effect.tryPromise({
223
+ try: () => response.text(),
224
+ catch: () => new Error("response body unavailable"),
225
+ })
226
+ return yield* Effect.fail(
227
+ new Error("SerpApi API error " + response.status + ": " + detail),
228
+ )
229
+ }
230
+
231
+ const json = yield* Effect.tryPromise({
232
+ try: () => response.json(),
233
+ catch: (error) => new Error("Invalid SerpApi response: " + String(error)),
234
+ })
235
+
236
+ const body = yield* Schema.decodeUnknownEffect(SerpApiResponse)(json)
237
+ const results = (body.organic_results ?? []).map(toResult)
238
+
239
+ writeCache(queryKey, results)
240
+ return results
241
+ }),
242
+ })
243
+ })
244
+
245
+ // Rebind on credential changes: connect, delete, or rotate a key and the
246
+ // provider joins or leaves the websearch pool without a restart. Env-var
247
+ // keys cannot change under a running process, so they bind at startup.
248
+ yield* Effect.forkScoped(
249
+ ctx.event.subscribe().pipe(
250
+ Stream.filter(
251
+ (event) =>
252
+ event.type === "credential.updated" ||
253
+ event.type === "credential.switched" ||
254
+ event.type === "integration.updated",
255
+ ),
256
+ Stream.mapEffect(() =>
257
+ Effect.gen(function* () {
258
+ const next = Boolean(yield* resolveKey())
259
+ if (next === bound) return
260
+ bound = next
261
+ yield* ctx.websearch.reload()
262
+ }),
263
+ ),
264
+ Stream.runDrain,
265
+ ),
266
+ )
267
+ })
268
+
269
+ export default Plugin.define({ id: "serpapi-search", effect })
@@ -0,0 +1,272 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // Serper mirrors the Google result page as JSON. Only `organic` carries the
5
+ // links an agent reads; the rich blocks (answerBox, knowledgeGraph,
6
+ // peopleAlsoAsk) have no source URL, so they stay out. Every result field is
7
+ // optional -- a missing one means "Google provided none", not an error.
8
+ const SerperResponse = Schema.Struct({
9
+ organic: Schema.optional(
10
+ Schema.Array(
11
+ Schema.Struct({
12
+ title: Schema.optional(Schema.String),
13
+ link: Schema.String,
14
+ snippet: Schema.optional(Schema.String),
15
+ date: Schema.optional(Schema.String),
16
+ }),
17
+ ),
18
+ ),
19
+ })
20
+
21
+ // The /connect integration ID, and the websearch provider ID it binds to.
22
+ const ID = "serper"
23
+
24
+ function envInt(name: string, fallback: number): number {
25
+ const raw = (process.env[name] ?? "").trim()
26
+ if (raw === "") return fallback
27
+ const value = Number(raw)
28
+ return Number.isFinite(value) && value >= 0 ? value : fallback
29
+ }
30
+
31
+ function envStr(name: string): string {
32
+ return (process.env[name] ?? "").trim()
33
+ }
34
+
35
+ // Search engines hand out dates in three flavours: relative ("28 minutes ago"),
36
+ // absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
37
+ // relative ("4天前"). Relative values are rounded upstream, so month/year use
38
+ // 30/365 days. ISO 8601 timestamps are accepted as-is.
39
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
40
+ const RELATIVE_MS: Record<string, number> = {
41
+ minute: 60_000,
42
+ hour: 3_600_000,
43
+ day: 86_400_000,
44
+ week: 604_800_000,
45
+ month: 2_592_000_000,
46
+ year: 31_536_000_000,
47
+ 分钟: 60_000,
48
+ 小时: 3_600_000,
49
+ 天: 86_400_000,
50
+ 周: 604_800_000,
51
+ 个月: 2_592_000_000,
52
+ 月: 2_592_000_000,
53
+ 年: 31_536_000_000,
54
+ }
55
+
56
+ const RELATIVE_DATE =
57
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
58
+
59
+ const ABSOLUTE_DATE = [
60
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
61
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
62
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
63
+ // ISO 8601 with a time component, which some providers return verbatim.
64
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
65
+ ]
66
+
67
+ function parseSearchDate(value: string | undefined): number {
68
+ const text = value?.trim()
69
+ if (!text) return NaN
70
+
71
+ const relative = RELATIVE_DATE.exec(text)
72
+ if (relative) {
73
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
74
+ const amount = Number(relative[1])
75
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
76
+ return Date.now() - amount * unit
77
+ }
78
+
79
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
80
+ // parser invents a year for strings like "Nov 1", which would date the result
81
+ // to 2001.
82
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
83
+ }
84
+
85
+ function toResult(item: {
86
+ title?: string | undefined
87
+ link: string
88
+ snippet?: string | undefined
89
+ date?: string | undefined
90
+ }) {
91
+ const published = parseSearchDate(item.date)
92
+ return {
93
+ url: item.link,
94
+ title: item.title || item.link,
95
+ content: item.snippet ?? "",
96
+ time: Number.isFinite(published) ? { published } : {},
97
+ }
98
+ }
99
+
100
+ type SearchResult = ReturnType<typeof toResult>
101
+
102
+ // Serper bills per query in credits and has no anonymous tier. A repeat query
103
+ // is a fresh billed dispatch, so responses are cached per normalized query.
104
+ // Knobs are read once at load time, restart after changing them.
105
+ //
106
+ // SERPER_CACHE_TTL ms to reuse a response for the same query (0 disables)
107
+ // SERPER_NUM results per search, clamped to 1-100
108
+ // SERPER_GL country code for results, e.g. "us", "cn"
109
+ // SERPER_HL language code for results, e.g. "en", "zh-cn"
110
+ // SERPER_TBS time filter: "qdr:h" (hour) "qdr:d" (day) "qdr:w" (week)
111
+ // "qdr:m" (month) "qdr:y" (year)
112
+ // SERPER_API_KEY required; from https://serper.dev
113
+ const CACHE_TTL = envInt("SERPER_CACHE_TTL", 600_000)
114
+ const SEARCH_SIZE = Math.min(Math.max(envInt("SERPER_NUM", 10), 1), 100)
115
+ const GL = envStr("SERPER_GL")
116
+ const HL = envStr("SERPER_HL")
117
+ const TBS = envStr("SERPER_TBS")
118
+
119
+ const CACHE_LIMIT = 200
120
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
121
+
122
+ function cacheKey(query: string): string {
123
+ return query.trim().replace(/\s+/g, " ")
124
+ }
125
+
126
+ function readCache(queryKey: string): SearchResult[] | undefined {
127
+ if (CACHE_TTL <= 0) return undefined
128
+ const hit = cache.get(queryKey)
129
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
130
+ }
131
+
132
+ function writeCache(queryKey: string, results: SearchResult[]): void {
133
+ if (CACHE_TTL <= 0) return
134
+ cache.set(queryKey, { at: Date.now(), results })
135
+ if (cache.size > CACHE_LIMIT) {
136
+ const oldest = cache.keys().next().value
137
+ if (oldest !== undefined) cache.delete(oldest)
138
+ }
139
+ }
140
+
141
+ export const effect = (ctx: Plugin.Context) =>
142
+ Effect.gen(function* () {
143
+ // Make "Serper" show up in /connect with an API-key method.
144
+ yield* ctx.integration.transform((editor) => {
145
+ editor.update(ID, (integration) => {
146
+ integration.name = "Serper"
147
+ })
148
+ editor.method.update({
149
+ integrationID: ID,
150
+ method: { type: "key", label: "Serper API key" },
151
+ })
152
+ editor.method.update({
153
+ integrationID: ID,
154
+ method: { type: "env", names: ["SERPER_API_KEY"] },
155
+ })
156
+ })
157
+
158
+ // The host instantiates this API's failure channel as `unknown`, which would
159
+ // make the whole plugin effect unrunnable. A credential read must never take
160
+ // the plugin down: a failed read degrades to "no stored key", and the
161
+ // environment fallback below applies exactly as it does when the credential
162
+ // simply does not exist.
163
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
164
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
165
+
166
+ // The key comes from the /connect credential, falling back to the
167
+ // environment. Resolved per call so a rotated key needs no restart.
168
+ const resolveKey = () =>
169
+ Effect.gen(function* () {
170
+ const connection = yield* soften(ctx.integration.connection.active(ID))
171
+ const credential = connection
172
+ ? yield* soften(ctx.integration.connection.resolve(connection))
173
+ : undefined
174
+ return credential?.type === "key" ? credential.key : process.env.SERPER_API_KEY
175
+ })
176
+
177
+ // Serper is bound to websearch only while a key exists. Deleting the key
178
+ // unbinds the provider, so `random` can never pick a keyless Serper.
179
+ // The transform replays on reload and reads `bound` as it stands then.
180
+ let bound = Boolean(yield* resolveKey())
181
+
182
+ yield* ctx.websearch.transform((editor) => {
183
+ if (!bound) return
184
+ editor.add({
185
+ id: ID,
186
+ name: "Serper",
187
+ execute: ({ query }) =>
188
+ Effect.gen(function* () {
189
+ const queryKey = cacheKey(query)
190
+ const cached = readCache(queryKey)
191
+ if (cached) return cached
192
+
193
+ // Rechecked per call: binding can lag a key deletion by the
194
+ // event round-trip, so this keeps that window explicit.
195
+ const key = yield* resolveKey()
196
+ if (!key) {
197
+ return yield* Effect.fail(
198
+ new Error("Connect Serper in /connect, or set SERPER_API_KEY"),
199
+ )
200
+ }
201
+
202
+ const response = yield* Effect.tryPromise({
203
+ try: () =>
204
+ fetch("https://google.serper.dev/search", {
205
+ method: "POST",
206
+ headers: {
207
+ Accept: "application/json",
208
+ "Content-Type": "application/json",
209
+ "X-API-KEY": key,
210
+ },
211
+ body: JSON.stringify({
212
+ q: queryKey,
213
+ num: SEARCH_SIZE,
214
+ ...(GL ? { gl: GL } : {}),
215
+ ...(HL ? { hl: HL } : {}),
216
+ ...(TBS ? { tbs: TBS } : {}),
217
+ }),
218
+ }),
219
+ catch: (error) => new Error("Serper request failed: " + String(error)),
220
+ })
221
+
222
+ if (!response.ok) {
223
+ const detail = yield* Effect.tryPromise({
224
+ try: () => response.text(),
225
+ catch: () => new Error("response body unavailable"),
226
+ })
227
+ return yield* Effect.fail(
228
+ new Error("Serper API error " + response.status + ": " + detail),
229
+ )
230
+ }
231
+
232
+ const json = yield* Effect.tryPromise({
233
+ try: () => response.json(),
234
+ catch: (error) => new Error("Invalid Serper response: " + String(error)),
235
+ })
236
+
237
+ const body = yield* Schema.decodeUnknownEffect(SerperResponse)(json)
238
+ const results = (body.organic ?? []).map(toResult)
239
+
240
+ // Serper charges per dispatch, so a response that never enters the
241
+ // cache is a query paid for again on every repeat.
242
+ writeCache(queryKey, results)
243
+ return results
244
+ }),
245
+ })
246
+ })
247
+
248
+ // Rebind on credential changes: connect, delete, or rotate a key and the
249
+ // provider joins or leaves the websearch pool without a restart. Env-var
250
+ // keys cannot change under a running process, so they bind at startup.
251
+ yield* Effect.forkScoped(
252
+ ctx.event.subscribe().pipe(
253
+ Stream.filter(
254
+ (event) =>
255
+ event.type === "credential.updated" ||
256
+ event.type === "credential.switched" ||
257
+ event.type === "integration.updated",
258
+ ),
259
+ Stream.mapEffect(() =>
260
+ Effect.gen(function* () {
261
+ const next = Boolean(yield* resolveKey())
262
+ if (next === bound) return
263
+ bound = next
264
+ yield* ctx.websearch.reload()
265
+ }),
266
+ ),
267
+ Stream.runDrain,
268
+ ),
269
+ )
270
+ })
271
+
272
+ export default Plugin.define({ id: "serper-search", effect })