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,313 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema, Stream } from "effect"
3
+
4
+ // You.com returns { results: { web, news }, metadata } with no body-level error
5
+ // envelope: failures are HTTP status codes only. Every result field is
6
+ // optional -- a missing one means "the source provided none", not an error.
7
+ // search_uuid is the only handle support has on a request, so it rides along
8
+ // on decode failures.
9
+ const YouResult = Schema.Struct({
10
+ url: Schema.optional(Schema.String),
11
+ title: Schema.optional(Schema.String),
12
+ description: Schema.optional(Schema.String),
13
+ snippets: Schema.optional(Schema.Array(Schema.String)),
14
+ page_age: Schema.optional(Schema.String),
15
+ contents: Schema.optional(
16
+ Schema.Struct({
17
+ html: Schema.optional(Schema.String),
18
+ markdown: Schema.optional(Schema.String),
19
+ highlights: Schema.optional(Schema.Array(Schema.String)),
20
+ }),
21
+ ),
22
+ })
23
+
24
+ const YouResponse = Schema.Struct({
25
+ results: Schema.optional(
26
+ Schema.Struct({
27
+ web: Schema.optional(Schema.Array(YouResult)),
28
+ news: Schema.optional(Schema.Array(YouResult)),
29
+ }),
30
+ ),
31
+ metadata: Schema.optional(
32
+ Schema.Struct({
33
+ search_uuid: Schema.optional(Schema.String),
34
+ query: Schema.optional(Schema.String),
35
+ latency: Schema.optional(Schema.Number),
36
+ }),
37
+ ),
38
+ })
39
+
40
+ type YouHit = typeof YouResult.Type
41
+
42
+ // The /connect integration ID, and the websearch provider ID it binds to.
43
+ const ID = "youcom"
44
+
45
+ function envInt(name: string, fallback: number): number {
46
+ const raw = (process.env[name] ?? "").trim()
47
+ if (raw === "") return fallback
48
+ const value = Number(raw)
49
+ return Number.isFinite(value) && value >= 0 ? value : fallback
50
+ }
51
+
52
+ function envStr(name: string): string {
53
+ return (process.env[name] ?? "").trim()
54
+ }
55
+
56
+ // Search engines hand out dates in three flavours: relative ("28 minutes ago"),
57
+ // absolute ("Sep 30, 2026" in the US, "31 Mar 2026" elsewhere), and non-English
58
+ // relative ("4天前"). Relative values are rounded upstream, so month/year use
59
+ // 30/365 days. ISO 8601 timestamps are accepted as-is.
60
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
61
+ const RELATIVE_MS: Record<string, number> = {
62
+ minute: 60_000,
63
+ hour: 3_600_000,
64
+ day: 86_400_000,
65
+ week: 604_800_000,
66
+ month: 2_592_000_000,
67
+ year: 31_536_000_000,
68
+ 分钟: 60_000,
69
+ 小时: 3_600_000,
70
+ 天: 86_400_000,
71
+ 周: 604_800_000,
72
+ 个月: 2_592_000_000,
73
+ 月: 2_592_000_000,
74
+ 年: 31_536_000_000,
75
+ }
76
+
77
+ const RELATIVE_DATE =
78
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
79
+
80
+ const ABSOLUTE_DATE = [
81
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
82
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
83
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
84
+ // ISO 8601 with a time component, which some providers return verbatim.
85
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
86
+ ]
87
+
88
+ function parseSearchDate(value: string | undefined): number {
89
+ const text = value?.trim()
90
+ if (!text) return NaN
91
+
92
+ const relative = RELATIVE_DATE.exec(text)
93
+ if (relative) {
94
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
95
+ const amount = Number(relative[1])
96
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
97
+ return Date.now() - amount * unit
98
+ }
99
+
100
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
101
+ // parser invents a year for strings like "Nov 1", which would date the result
102
+ // to 2001.
103
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
104
+ }
105
+
106
+ // Web and news live in separate sections; an agent asking "what's new" wants
107
+ // both, so they merge into one list with web first.
108
+ function toResult(item: YouHit) {
109
+ const published = parseSearchDate(item.page_age)
110
+ const extracted =
111
+ item.contents?.highlights?.join("\n") ??
112
+ item.contents?.markdown ??
113
+ item.snippets?.join(" ") ??
114
+ item.description ??
115
+ ""
116
+ return {
117
+ url: item.url ?? "",
118
+ title: item.title || item.url || "",
119
+ content: extracted,
120
+ time: Number.isFinite(published) ? { published } : {},
121
+ }
122
+ }
123
+
124
+ type SearchResult = ReturnType<typeof toResult>
125
+
126
+ // You.com is pay-as-you-go and has no anonymous tier: $5 per 1k searches, up
127
+ // to 100 results per call. A repeat query is a fresh billed dispatch, so
128
+ // responses are cached per normalized query. Knobs are read once at load time,
129
+ // restart after changing them.
130
+ //
131
+ // YOUCOM_CACHE_TTL ms to reuse a response for the same query (0 disables)
132
+ // YOUCOM_COUNT results per section (web and news each), clamped 1-100
133
+ // YOUCOM_EXTRACTION "highlights" (default) returns the passages that answer
134
+ // the query, "none" returns bare snippets, "full_page"
135
+ // crawls every hit and is billed per live-fetched page
136
+ // ($1 per 1k) on top of the search -- cached pages are free
137
+ // YOUCOM_LANGUAGE result language, e.g. "EN", "ZH-HANS"
138
+ // YOUCOM_COUNTRY geographic focus, e.g. "US", "CN"
139
+ // YOUCOM_FRESHNESS "day" | "week" | "month" | "year" or "YYYY-MM-DDtoYYYY-MM-DD"
140
+ // YDC_API_KEY required; You.com's canonical env var, shared with its SDKs
141
+ const CACHE_TTL = envInt("YOUCOM_CACHE_TTL", 600_000)
142
+ const SEARCH_SIZE = Math.min(Math.max(envInt("YOUCOM_COUNT", 10), 1), 100)
143
+ const EXTRACTION = ["none", "highlights", "full_page"].includes(envStr("YOUCOM_EXTRACTION"))
144
+ ? envStr("YOUCOM_EXTRACTION")
145
+ : "highlights"
146
+ const LANGUAGE = envStr("YOUCOM_LANGUAGE")
147
+ const COUNTRY = envStr("YOUCOM_COUNTRY")
148
+ const FRESHNESS = envStr("YOUCOM_FRESHNESS")
149
+
150
+ const CACHE_LIMIT = 200
151
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
152
+
153
+ function cacheKey(query: string): string {
154
+ return query.trim().replace(/\s+/g, " ")
155
+ }
156
+
157
+ function readCache(queryKey: string): SearchResult[] | undefined {
158
+ if (CACHE_TTL <= 0) return undefined
159
+ const hit = cache.get(queryKey)
160
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
161
+ }
162
+
163
+ function writeCache(queryKey: string, results: SearchResult[]): void {
164
+ if (CACHE_TTL <= 0) return
165
+ cache.set(queryKey, { at: Date.now(), results })
166
+ if (cache.size > CACHE_LIMIT) {
167
+ const oldest = cache.keys().next().value
168
+ if (oldest !== undefined) cache.delete(oldest)
169
+ }
170
+ }
171
+
172
+ export const effect = (ctx: Plugin.Context) =>
173
+ Effect.gen(function* () {
174
+ // Make "You.com" show up in /connect with an API-key method.
175
+ yield* ctx.integration.transform((editor) => {
176
+ editor.update(ID, (integration) => {
177
+ integration.name = "You.com"
178
+ })
179
+ editor.method.update({
180
+ integrationID: ID,
181
+ method: { type: "key", label: "You.com API key" },
182
+ })
183
+ editor.method.update({
184
+ integrationID: ID,
185
+ method: { type: "env", names: ["YDC_API_KEY"] },
186
+ })
187
+ })
188
+
189
+ // The host instantiates this API's failure channel as `unknown`, which would
190
+ // make the whole plugin effect unrunnable. A credential read must never take
191
+ // the plugin down: a failed read degrades to "no stored key", and the
192
+ // environment fallback below applies exactly as it does when the credential
193
+ // simply does not exist.
194
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
195
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
196
+
197
+ // The key comes from the /connect credential, falling back to the
198
+ // environment. Resolved per call so a rotated key needs no restart.
199
+ const resolveKey = () =>
200
+ Effect.gen(function* () {
201
+ const connection = yield* soften(ctx.integration.connection.active(ID))
202
+ const credential = connection
203
+ ? yield* soften(ctx.integration.connection.resolve(connection))
204
+ : undefined
205
+ return credential?.type === "key" ? credential.key : process.env.YDC_API_KEY
206
+ })
207
+
208
+ // You.com is bound to websearch only while a key exists. Deleting the key
209
+ // unbinds the provider, so `random` can never pick a keyless You.com.
210
+ // The transform replays on reload and reads `bound` as it stands then.
211
+ let bound = Boolean(yield* resolveKey())
212
+
213
+ yield* ctx.websearch.transform((editor) => {
214
+ if (!bound) return
215
+ editor.add({
216
+ id: ID,
217
+ name: "You.com",
218
+ execute: ({ query }) =>
219
+ Effect.gen(function* () {
220
+ const queryKey = cacheKey(query)
221
+ const cached = readCache(queryKey)
222
+ if (cached) return cached
223
+
224
+ // Rechecked per call: binding can lag a key deletion by the
225
+ // event round-trip, so this keeps that window explicit.
226
+ const key = yield* resolveKey()
227
+ if (!key) {
228
+ return yield* Effect.fail(
229
+ new Error("Connect You.com in /connect, or set YDC_API_KEY"),
230
+ )
231
+ }
232
+
233
+ const extraction =
234
+ EXTRACTION === "highlights"
235
+ ? { extraction_mode: "highlights" }
236
+ : EXTRACTION === "full_page"
237
+ ? {
238
+ extraction_mode: "full_page",
239
+ full_page: { extraction_formats: ["markdown"] },
240
+ }
241
+ : undefined
242
+
243
+ const response = yield* Effect.tryPromise({
244
+ try: () =>
245
+ fetch("https://ydc-index.io/v1/search", {
246
+ method: "POST",
247
+ headers: {
248
+ Accept: "application/json",
249
+ "Content-Type": "application/json",
250
+ "X-API-Key": key,
251
+ },
252
+ body: JSON.stringify({
253
+ query: queryKey,
254
+ count: SEARCH_SIZE,
255
+ ...(LANGUAGE ? { language: LANGUAGE } : {}),
256
+ ...(COUNTRY ? { country: COUNTRY } : {}),
257
+ ...(FRESHNESS ? { freshness: FRESHNESS } : {}),
258
+ ...(extraction ? { extraction } : {}),
259
+ }),
260
+ }),
261
+ catch: (error) => new Error("You.com request failed: " + String(error)),
262
+ })
263
+
264
+ if (!response.ok) {
265
+ const detail = yield* Effect.tryPromise({
266
+ try: () => response.text(),
267
+ catch: () => new Error("response body unavailable"),
268
+ })
269
+ return yield* Effect.fail(
270
+ new Error("You.com API error " + response.status + ": " + detail),
271
+ )
272
+ }
273
+
274
+ const json = yield* Effect.tryPromise({
275
+ try: () => response.json(),
276
+ catch: (error) => new Error("Invalid You.com response: " + String(error)),
277
+ })
278
+
279
+ const body = yield* Schema.decodeUnknownEffect(YouResponse)(json)
280
+ const hits = [...(body.results?.web ?? []), ...(body.results?.news ?? [])]
281
+ const results = hits.flatMap((item) => (item.url ? [toResult(item)] : []))
282
+
283
+ writeCache(queryKey, results)
284
+ return results
285
+ }),
286
+ })
287
+ })
288
+
289
+ // Rebind on credential changes: connect, delete, or rotate a key and the
290
+ // provider joins or leaves the websearch pool without a restart. Env-var
291
+ // keys cannot change under a running process, so they bind at startup.
292
+ yield* Effect.forkScoped(
293
+ ctx.event.subscribe().pipe(
294
+ Stream.filter(
295
+ (event) =>
296
+ event.type === "credential.updated" ||
297
+ event.type === "credential.switched" ||
298
+ event.type === "integration.updated",
299
+ ),
300
+ Stream.mapEffect(() =>
301
+ Effect.gen(function* () {
302
+ const next = Boolean(yield* resolveKey())
303
+ if (next === bound) return
304
+ bound = next
305
+ yield* ctx.websearch.reload()
306
+ }),
307
+ ),
308
+ Stream.runDrain,
309
+ ),
310
+ )
311
+ })
312
+
313
+ export default Plugin.define({ id: "youcom-search", effect })