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.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/README.zh-CN.md +230 -0
- package/index.ts +59 -0
- package/package.json +57 -4
- package/plugins/anysearch-search.ts +227 -0
- package/plugins/brave-search.ts +270 -0
- package/plugins/jina-search.ts +242 -0
- package/plugins/linkup-search.ts +213 -0
- package/plugins/metaso-search.ts +268 -0
- package/plugins/querit-search.ts +290 -0
- package/plugins/serpapi-search.ts +269 -0
- package/plugins/serper-search.ts +272 -0
- package/plugins/youcom-search.ts +313 -0
|
@@ -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 })
|