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,268 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ const MetasoResponse = Schema.Struct({
5
+ webpages: Schema.optional(
6
+ Schema.Array(
7
+ Schema.Struct({
8
+ title: Schema.String,
9
+ link: Schema.String,
10
+ snippet: Schema.optional(Schema.String),
11
+ date: Schema.optional(Schema.String),
12
+ rawContent: Schema.optional(Schema.String),
13
+ }),
14
+ ),
15
+ ),
16
+ })
17
+
18
+ // The /connect integration ID, and the websearch provider ID it binds to.
19
+ const ID = "metaso"
20
+
21
+ function envInt(name: string, fallback: number): number {
22
+ const raw = (process.env[name] ?? "").trim()
23
+ if (raw === "") return fallback
24
+ const value = Number(raw)
25
+ return Number.isFinite(value) && value >= 0 ? value : fallback
26
+ }
27
+
28
+ function envStr(name: string): string {
29
+ return (process.env[name] ?? "").trim()
30
+ }
31
+
32
+ // Search engines hand out dates in three flavours: relative ("28 minutes ago"),
33
+ // absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
34
+ // relative ("4天前"). Relative values are rounded upstream, so month/year use
35
+ // 30/365 days. ISO 8601 timestamps are accepted as-is.
36
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
37
+ const RELATIVE_MS: Record<string, number> = {
38
+ minute: 60_000,
39
+ hour: 3_600_000,
40
+ day: 86_400_000,
41
+ week: 604_800_000,
42
+ month: 2_592_000_000,
43
+ year: 31_536_000_000,
44
+ 分钟: 60_000,
45
+ 小时: 3_600_000,
46
+ 天: 86_400_000,
47
+ 周: 604_800_000,
48
+ 个月: 2_592_000_000,
49
+ 月: 2_592_000_000,
50
+ 年: 31_536_000_000,
51
+ }
52
+
53
+ const RELATIVE_DATE =
54
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
55
+
56
+ const ABSOLUTE_DATE = [
57
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
58
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
59
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
60
+ // ISO 8601 with a time component, which some providers return verbatim.
61
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
62
+ ]
63
+
64
+ function parseSearchDate(value: string | undefined): number {
65
+ const text = value?.trim()
66
+ if (!text) return NaN
67
+
68
+ const relative = RELATIVE_DATE.exec(text)
69
+ if (relative) {
70
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
71
+ const amount = Number(relative[1])
72
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
73
+ return Date.now() - amount * unit
74
+ }
75
+
76
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
77
+ // parser invents a year for strings like "Nov 1", which would date the result
78
+ // to 2001.
79
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
80
+ }
81
+
82
+ // `rawContent` is the full source text when it was asked for, otherwise the
83
+ // snippet stands in.
84
+ function toResult(item: {
85
+ title: string
86
+ link: string
87
+ snippet?: string | undefined
88
+ date?: string | undefined
89
+ rawContent?: string | undefined
90
+ }) {
91
+ const published = parseSearchDate(item.date)
92
+ return {
93
+ url: item.link,
94
+ title: item.title,
95
+ content: item.rawContent ?? item.snippet ?? "",
96
+ time: Number.isFinite(published) ? { published } : {},
97
+ }
98
+ }
99
+
100
+ type SearchResult = ReturnType<typeof toResult>
101
+
102
+ // Metaso bills per search, not per result, which cuts two ways: a repeat query is
103
+ // a search you can avoid paying for, and a fuller page of results costs exactly the
104
+ // same as a thin one. Knobs are read once at load time, so restart after changing them.
105
+ //
106
+ // METASO_CACHE_TTL ms to reuse a response for the same query (0 disables)
107
+ // METASO_SEARCH_SIZE results per search, clamped to 1-100
108
+ // METASO_INCLUDE_RAW_CONTENT true returns the full source text of every hit: same
109
+ // search cost, far more context tokens. Only worth it
110
+ // when you would otherwise webfetch those hits anyway.
111
+ // METASO_API_KEY required; from https://metaso.cn
112
+ const CACHE_TTL = envInt("METASO_CACHE_TTL", 600_000)
113
+ const SEARCH_SIZE = Math.min(Math.max(envInt("METASO_SEARCH_SIZE", 20), 1), 100)
114
+ const INCLUDE_RAW_CONTENT = envStr("METASO_INCLUDE_RAW_CONTENT") === "true"
115
+
116
+ const CACHE_LIMIT = 200
117
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
118
+
119
+ function cacheKey(query: string): string {
120
+ return query.trim().replace(/\s+/g, " ")
121
+ }
122
+
123
+ function readCache(queryKey: string): SearchResult[] | undefined {
124
+ if (CACHE_TTL <= 0) return undefined
125
+ const hit = cache.get(queryKey)
126
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
127
+ }
128
+
129
+ function writeCache(queryKey: string, results: SearchResult[]): void {
130
+ if (CACHE_TTL <= 0) return
131
+ cache.set(queryKey, { at: Date.now(), results })
132
+ if (cache.size > CACHE_LIMIT) {
133
+ const oldest = cache.keys().next().value
134
+ if (oldest !== undefined) cache.delete(oldest)
135
+ }
136
+ }
137
+
138
+ export const effect = (ctx: Plugin.Context) =>
139
+ Effect.gen(function* () {
140
+ // Make "Metaso" show up in /connect with an API-key method.
141
+ yield* ctx.integration.transform((editor) => {
142
+ editor.update(ID, (integration) => {
143
+ integration.name = "Metaso Search"
144
+ })
145
+ editor.method.update({
146
+ integrationID: ID,
147
+ method: { type: "key", label: "Metaso API key" },
148
+ })
149
+ editor.method.update({
150
+ integrationID: ID,
151
+ method: { type: "env", names: ["METASO_API_KEY"] },
152
+ })
153
+ })
154
+
155
+ // The host instantiates this API's failure channel as `unknown`, which would
156
+ // make the whole plugin effect unrunnable. A credential read must never take
157
+ // the plugin down: a failed read degrades to "no stored key", and the
158
+ // environment fallback below applies exactly as it does when the credential
159
+ // simply does not exist.
160
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
161
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
162
+
163
+ // The key comes from the /connect credential, falling back to the
164
+ // environment. Resolved per call so a rotated key needs no restart.
165
+ const resolveKey = () =>
166
+ Effect.gen(function* () {
167
+ const connection = yield* soften(ctx.integration.connection.active(ID))
168
+ const credential = connection
169
+ ? yield* soften(ctx.integration.connection.resolve(connection))
170
+ : undefined
171
+ return credential?.type === "key" ? credential.key : process.env.METASO_API_KEY
172
+ })
173
+
174
+ // Metaso is bound to websearch only while a key exists. Deleting the key
175
+ // unbinds the provider, so `random` can never pick a keyless Metaso.
176
+ // The transform replays on reload and reads `bound` as it stands then.
177
+ let bound = Boolean(yield* resolveKey())
178
+
179
+ yield* ctx.websearch.transform((editor) => {
180
+ if (!bound) return
181
+ editor.add({
182
+ id: ID,
183
+ name: "Metaso Search",
184
+ execute: ({ query }) =>
185
+ Effect.gen(function* () {
186
+ const queryKey = cacheKey(query)
187
+ const cached = readCache(queryKey)
188
+ if (cached) return cached
189
+
190
+ // Rechecked per call: binding can lag a key deletion by the
191
+ // event round-trip, so this keeps that window explicit.
192
+ const key = yield* resolveKey()
193
+ if (!key) {
194
+ return yield* Effect.fail(
195
+ new Error("Connect Metaso in /connect, or set METASO_API_KEY"),
196
+ )
197
+ }
198
+
199
+ const response = yield* Effect.tryPromise({
200
+ try: () =>
201
+ fetch("https://metaso.cn/api/v1/search", {
202
+ method: "POST",
203
+ headers: {
204
+ Accept: "application/json",
205
+ "Content-Type": "application/json",
206
+ Authorization: "Bearer " + key,
207
+ },
208
+ body: JSON.stringify({
209
+ q: queryKey,
210
+ scope: "webpage",
211
+ size: SEARCH_SIZE,
212
+ includeSummary: false,
213
+ includeRawContent: INCLUDE_RAW_CONTENT,
214
+ conciseSnippet: true,
215
+ }),
216
+ }),
217
+ catch: (error) => new Error("Metaso request failed: " + String(error)),
218
+ })
219
+
220
+ if (!response.ok) {
221
+ const detail = yield* Effect.tryPromise({
222
+ try: () => response.text(),
223
+ catch: () => new Error("response body unavailable"),
224
+ })
225
+ return yield* Effect.fail(
226
+ new Error("Metaso API error " + response.status + ": " + detail),
227
+ )
228
+ }
229
+
230
+ const json = yield* Effect.tryPromise({
231
+ try: () => response.json(),
232
+ catch: (error) => new Error("Invalid Metaso response: " + String(error)),
233
+ })
234
+
235
+ const body = yield* Schema.decodeUnknownEffect(MetasoResponse)(json)
236
+ const results = (body.webpages ?? []).map(toResult)
237
+
238
+ writeCache(queryKey, results)
239
+ return results
240
+ }),
241
+ })
242
+ })
243
+
244
+ // Rebind on credential changes: connect, delete, or rotate a key and the
245
+ // provider joins or leaves the websearch pool without a restart. Env-var
246
+ // keys cannot change under a running process, so they bind at startup.
247
+ yield* Effect.forkScoped(
248
+ ctx.event.subscribe().pipe(
249
+ Stream.filter(
250
+ (event) =>
251
+ event.type === "credential.updated" ||
252
+ event.type === "credential.switched" ||
253
+ event.type === "integration.updated",
254
+ ),
255
+ Stream.mapEffect(() =>
256
+ Effect.gen(function* () {
257
+ const next = Boolean(yield* resolveKey())
258
+ if (next === bound) return
259
+ bound = next
260
+ yield* ctx.websearch.reload()
261
+ }),
262
+ ),
263
+ Stream.runDrain,
264
+ ),
265
+ )
266
+ })
267
+
268
+ export default Plugin.define({ id: "metaso-search", effect })
@@ -0,0 +1,290 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // Every field on a result is optional -- a missing one means "body text
5
+ // unavailable", not an error.
6
+ const QueritResponse = Schema.Struct({
7
+ error_code: Schema.optional(Schema.Number),
8
+ error_msg: Schema.optional(Schema.String),
9
+ results: Schema.optional(
10
+ Schema.Struct({
11
+ result: Schema.optional(
12
+ Schema.Array(
13
+ Schema.Struct({
14
+ url: Schema.optional(Schema.String),
15
+ title: Schema.optional(Schema.String),
16
+ snippet: Schema.optional(Schema.String),
17
+ page_age: Schema.optional(Schema.String),
18
+ sentence: Schema.optional(Schema.Array(Schema.String)),
19
+ }),
20
+ ),
21
+ ),
22
+ }),
23
+ ),
24
+ })
25
+
26
+ // The /connect integration ID, and the websearch provider ID it binds to.
27
+ const ID = "querit"
28
+
29
+ // search_id is documented as both a string and an integer, so read it loosely
30
+ // rather than letting a strict decode reject an otherwise good response.
31
+ function searchIdOf(json: unknown): string {
32
+ const value = (json as { search_id?: unknown } | null)?.search_id
33
+ return typeof value === "string" || typeof value === "number" ? String(value) : "unknown"
34
+ }
35
+
36
+ function envInt(name: string, fallback: number): number {
37
+ const raw = (process.env[name] ?? "").trim()
38
+ if (raw === "") return fallback
39
+ const value = Number(raw)
40
+ return Number.isFinite(value) && value >= 0 ? value : fallback
41
+ }
42
+
43
+ function envStr(name: string): string {
44
+ return (process.env[name] ?? "").trim()
45
+ }
46
+
47
+ // Search engines hand out dates in three flavours: relative ("28 minutes ago"),
48
+ // absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
49
+ // relative ("4天前"). Relative values are rounded upstream, so month/year use
50
+ // 30/365 days. ISO 8601 timestamps are accepted as-is.
51
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
52
+ const RELATIVE_MS: Record<string, number> = {
53
+ minute: 60_000,
54
+ hour: 3_600_000,
55
+ day: 86_400_000,
56
+ week: 604_800_000,
57
+ month: 2_592_000_000,
58
+ year: 31_536_000_000,
59
+ 分钟: 60_000,
60
+ 小时: 3_600_000,
61
+ 天: 86_400_000,
62
+ 周: 604_800_000,
63
+ 个月: 2_592_000_000,
64
+ 月: 2_592_000_000,
65
+ 年: 31_536_000_000,
66
+ }
67
+
68
+ const RELATIVE_DATE =
69
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
70
+
71
+ const ABSOLUTE_DATE = [
72
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
73
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
74
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
75
+ // ISO 8601 with a time component, which some providers return verbatim.
76
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
77
+ ]
78
+
79
+ function parseSearchDate(value: string | undefined): number {
80
+ const text = value?.trim()
81
+ if (!text) return NaN
82
+
83
+ const relative = RELATIVE_DATE.exec(text)
84
+ if (relative) {
85
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
86
+ const amount = Number(relative[1])
87
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
88
+ return Date.now() - amount * unit
89
+ }
90
+
91
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
92
+ // parser invents a year for strings like "Nov 1", which would date the result
93
+ // to 2001.
94
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
95
+ }
96
+
97
+ // Querit carries no snippet on every hit, in which case the sentence-level
98
+ // matches are the only body text available.
99
+ function toResult(item: {
100
+ url: string
101
+ title?: string | undefined
102
+ snippet?: string | undefined
103
+ page_age?: string | undefined
104
+ sentence?: readonly string[] | undefined
105
+ }) {
106
+ const published = parseSearchDate(item.page_age)
107
+ return {
108
+ url: item.url,
109
+ title: item.title ?? item.url,
110
+ content: item.snippet ?? (item.sentence ?? []).join(" "),
111
+ time: Number.isFinite(published) ? { published } : {},
112
+ }
113
+ }
114
+
115
+ type SearchResult = ReturnType<typeof toResult>
116
+
117
+ // Querit bills per search, so a repeat query is a charge that a cache can
118
+ // avoid. Knobs are read once at load time, restart after changing them.
119
+ //
120
+ // QUERIT_CACHE_TTL ms to reuse a response for the same query (0 disables)
121
+ // QUERIT_COUNT results per search; Querit's own limit applies
122
+ // QUERIT_API_KEY required; from https://querit.ai
123
+ const CACHE_TTL = envInt("QUERIT_CACHE_TTL", 600_000)
124
+ const SEARCH_SIZE = envInt("QUERIT_COUNT", 8)
125
+
126
+ const CACHE_LIMIT = 200
127
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
128
+
129
+ function cacheKey(query: string): string {
130
+ return query.trim().replace(/\s+/g, " ")
131
+ }
132
+
133
+ function readCache(queryKey: string): SearchResult[] | undefined {
134
+ if (CACHE_TTL <= 0) return undefined
135
+ const hit = cache.get(queryKey)
136
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
137
+ }
138
+
139
+ function writeCache(queryKey: string, results: SearchResult[]): void {
140
+ if (CACHE_TTL <= 0) return
141
+ cache.set(queryKey, { at: Date.now(), results })
142
+ if (cache.size > CACHE_LIMIT) {
143
+ const oldest = cache.keys().next().value
144
+ if (oldest !== undefined) cache.delete(oldest)
145
+ }
146
+ }
147
+
148
+ export const effect = (ctx: Plugin.Context) =>
149
+ Effect.gen(function* () {
150
+ // Make "Querit" show up in /connect with an API-key method.
151
+ yield* ctx.integration.transform((editor) => {
152
+ editor.update(ID, (integration) => {
153
+ integration.name = "Querit"
154
+ })
155
+ editor.method.update({
156
+ integrationID: ID,
157
+ method: { type: "key", label: "Querit API key" },
158
+ })
159
+ editor.method.update({
160
+ integrationID: ID,
161
+ method: { type: "env", names: ["QUERIT_API_KEY"] },
162
+ })
163
+ })
164
+
165
+ // The host instantiates this API's failure channel as `unknown`, which would
166
+ // make the whole plugin effect unrunnable. A credential read must never take
167
+ // the plugin down: a failed read degrades to "no stored key", and the
168
+ // environment fallback below applies exactly as it does when the credential
169
+ // simply does not exist.
170
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
171
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
172
+
173
+ // The key comes from the /connect credential, falling back to the
174
+ // environment. Resolved per call so a rotated key needs no restart.
175
+ const resolveKey = () =>
176
+ Effect.gen(function* () {
177
+ const connection = yield* soften(ctx.integration.connection.active(ID))
178
+ const credential = connection
179
+ ? yield* soften(ctx.integration.connection.resolve(connection))
180
+ : undefined
181
+ return credential?.type === "key" ? credential.key : process.env.QUERIT_API_KEY
182
+ })
183
+
184
+ // Querit is bound to websearch only while a key exists. Deleting the key
185
+ // unbinds the provider, so `random` can never pick a keyless Querit.
186
+ // The transform replays on reload and reads `bound` as it stands then.
187
+ let bound = Boolean(yield* resolveKey())
188
+
189
+ yield* ctx.websearch.transform((editor) => {
190
+ if (!bound) return
191
+ editor.add({
192
+ id: ID,
193
+ name: "Querit",
194
+ execute: ({ query }) =>
195
+ Effect.gen(function* () {
196
+ const queryKey = cacheKey(query)
197
+ const cached = readCache(queryKey)
198
+ if (cached) return cached
199
+
200
+ // Rechecked per call: binding can lag a key deletion by the
201
+ // event round-trip, so this keeps that window explicit.
202
+ const key = yield* resolveKey()
203
+ if (!key) {
204
+ return yield* Effect.fail(
205
+ new Error("Connect Querit in /connect, or set QUERIT_API_KEY"),
206
+ )
207
+ }
208
+
209
+ const response = yield* Effect.tryPromise({
210
+ try: () =>
211
+ fetch("https://api.querit.ai/v1/search", {
212
+ method: "POST",
213
+ headers: {
214
+ Accept: "application/json",
215
+ "Content-Type": "application/json",
216
+ Authorization: "Bearer " + key,
217
+ },
218
+ body: JSON.stringify({ query: queryKey, count: SEARCH_SIZE }),
219
+ }),
220
+ catch: (error) => new Error("Querit request failed: " + String(error)),
221
+ })
222
+
223
+ if (!response.ok) {
224
+ const detail = yield* Effect.tryPromise({
225
+ try: () => response.text(),
226
+ catch: () => new Error("response body unavailable"),
227
+ })
228
+ return yield* Effect.fail(
229
+ new Error("Querit API error " + response.status + ": " + detail),
230
+ )
231
+ }
232
+
233
+ const json = yield* Effect.tryPromise({
234
+ try: () => response.json(),
235
+ catch: (error) => new Error("Invalid Querit response: " + String(error)),
236
+ })
237
+
238
+ const body = yield* Schema.decodeUnknownEffect(QueritResponse)(json)
239
+
240
+ // Querit reports failures in the body too, even on an HTTP 200.
241
+ // search_id is what support needs to trace the request.
242
+ if (body.error_code !== undefined && body.error_code !== 200) {
243
+ return yield* Effect.fail(
244
+ new Error(
245
+ "Querit API error " +
246
+ body.error_code +
247
+ ": " +
248
+ (body.error_msg ?? "unknown error") +
249
+ " (search_id " +
250
+ searchIdOf(json) +
251
+ ")",
252
+ ),
253
+ )
254
+ }
255
+
256
+ const results = (body.results?.result ?? []).flatMap((item) =>
257
+ item.url ? [toResult({ ...item, url: item.url })] : [],
258
+ )
259
+
260
+ writeCache(queryKey, results)
261
+ return results
262
+ }),
263
+ })
264
+ })
265
+
266
+ // Rebind on credential changes: connect, delete, or rotate a key and the
267
+ // provider joins or leaves the websearch pool without a restart. Env-var
268
+ // keys cannot change under a running process, so they bind at startup.
269
+ yield* Effect.forkScoped(
270
+ ctx.event.subscribe().pipe(
271
+ Stream.filter(
272
+ (event) =>
273
+ event.type === "credential.updated" ||
274
+ event.type === "credential.switched" ||
275
+ event.type === "integration.updated",
276
+ ),
277
+ Stream.mapEffect(() =>
278
+ Effect.gen(function* () {
279
+ const next = Boolean(yield* resolveKey())
280
+ if (next === bound) return
281
+ bound = next
282
+ yield* ctx.websearch.reload()
283
+ }),
284
+ ),
285
+ Stream.runDrain,
286
+ ),
287
+ )
288
+ })
289
+
290
+ export default Plugin.define({ id: "querit-search", effect })