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,242 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Jina wraps the SERP in an envelope ({ code, status, data }) but failures
|
|
5
|
+
// still arrive as HTTP status codes with a `detail` body, so the envelope is
|
|
6
|
+
// read for results only. Every result field is optional -- a missing one means
|
|
7
|
+
// "Jina provided none", not an error. With `X-Respond-With: no-content` each
|
|
8
|
+
// hit carries only title/url/description; `content` (full page markdown)
|
|
9
|
+
// appears only when it was explicitly requested.
|
|
10
|
+
const JinaResponse = Schema.Struct({
|
|
11
|
+
data: Schema.optional(
|
|
12
|
+
Schema.Array(
|
|
13
|
+
Schema.Struct({
|
|
14
|
+
title: Schema.optional(Schema.String),
|
|
15
|
+
url: Schema.optional(Schema.String),
|
|
16
|
+
description: Schema.optional(Schema.String),
|
|
17
|
+
content: Schema.optional(Schema.String),
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
24
|
+
const ID = "jina"
|
|
25
|
+
|
|
26
|
+
// Jina returns no publication date on search hits, so results carry an empty
|
|
27
|
+
// time record rather than a parsed one.
|
|
28
|
+
function toResult(
|
|
29
|
+
item: {
|
|
30
|
+
title?: string | undefined
|
|
31
|
+
url?: string | undefined
|
|
32
|
+
description?: string | undefined
|
|
33
|
+
content?: string | undefined
|
|
34
|
+
},
|
|
35
|
+
includeContent: boolean,
|
|
36
|
+
) {
|
|
37
|
+
const text = includeContent
|
|
38
|
+
? item.content ?? item.description ?? ""
|
|
39
|
+
: item.description ?? ""
|
|
40
|
+
return {
|
|
41
|
+
url: item.url ?? "",
|
|
42
|
+
title: item.title || item.url || "",
|
|
43
|
+
content: text,
|
|
44
|
+
time: {},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
49
|
+
|
|
50
|
+
function envInt(name: string, fallback: number): number {
|
|
51
|
+
const raw = (process.env[name] ?? "").trim()
|
|
52
|
+
if (raw === "") return fallback
|
|
53
|
+
const value = Number(raw)
|
|
54
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function envStr(name: string): string {
|
|
58
|
+
return (process.env[name] ?? "").trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Jina meters s.jina.ai at 100 RPM on a free key and bills the page content
|
|
62
|
+
// it returns per token, so a repeat query is worth avoiding and content is
|
|
63
|
+
// off unless asked for. Knobs are read once at load time, restart after
|
|
64
|
+
// changing them.
|
|
65
|
+
//
|
|
66
|
+
// JINA_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
67
|
+
// JINA_INCLUDE_CONTENT true returns each hit's full page markdown: same search,
|
|
68
|
+
// far more context tokens and billed tokens. Only worth it
|
|
69
|
+
// when you would otherwise webfetch those hits anyway.
|
|
70
|
+
// Default (off) asks Jina for titles and snippets only.
|
|
71
|
+
// JINA_NUM results per search, clamped 1-100; 0 (default) omits the
|
|
72
|
+
// parameter -- Jina warns num adds latency and drops
|
|
73
|
+
// specialized result types, so use its page size
|
|
74
|
+
// JINA_GL country code for results, e.g. "us", "cn"
|
|
75
|
+
// JINA_HL language code for results, e.g. "en", "zh-cn"
|
|
76
|
+
// JINA_LOCATION city-level origin to simulate, e.g. "Berlin"
|
|
77
|
+
// JINA_API_KEY required; free key at https://jina.ai/?sui=apikey
|
|
78
|
+
const CACHE_TTL = envInt("JINA_CACHE_TTL", 600_000)
|
|
79
|
+
const INCLUDE_CONTENT = envStr("JINA_INCLUDE_CONTENT") === "true"
|
|
80
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("JINA_NUM", 0), 0), 100)
|
|
81
|
+
const GL = envStr("JINA_GL")
|
|
82
|
+
const HL = envStr("JINA_HL")
|
|
83
|
+
const LOCATION = envStr("JINA_LOCATION")
|
|
84
|
+
|
|
85
|
+
const CACHE_LIMIT = 200
|
|
86
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
87
|
+
|
|
88
|
+
function cacheKey(query: string): string {
|
|
89
|
+
return query.trim().replace(/\s+/g, " ")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
93
|
+
if (CACHE_TTL <= 0) return undefined
|
|
94
|
+
const hit = cache.get(queryKey)
|
|
95
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
99
|
+
if (CACHE_TTL <= 0) return
|
|
100
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
101
|
+
if (cache.size > CACHE_LIMIT) {
|
|
102
|
+
const oldest = cache.keys().next().value
|
|
103
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
// Make "Jina" show up in /connect with an API-key method.
|
|
110
|
+
yield* ctx.integration.transform((editor) => {
|
|
111
|
+
editor.update(ID, (integration) => {
|
|
112
|
+
integration.name = "Jina"
|
|
113
|
+
})
|
|
114
|
+
editor.method.update({
|
|
115
|
+
integrationID: ID,
|
|
116
|
+
method: { type: "key", label: "Jina API key" },
|
|
117
|
+
})
|
|
118
|
+
editor.method.update({
|
|
119
|
+
integrationID: ID,
|
|
120
|
+
method: { type: "env", names: ["JINA_API_KEY"] },
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
125
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
126
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
127
|
+
// environment fallback below applies exactly as it does when the credential
|
|
128
|
+
// simply does not exist.
|
|
129
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
130
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
131
|
+
|
|
132
|
+
// The key comes from the /connect credential, falling back to the
|
|
133
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
134
|
+
const resolveKey = () =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
137
|
+
const credential = connection
|
|
138
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
139
|
+
: undefined
|
|
140
|
+
return credential?.type === "key" ? credential.key : process.env.JINA_API_KEY
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Jina is bound to websearch only while a key exists. Deleting the key
|
|
144
|
+
// unbinds the provider, so `random` can never pick a keyless Jina.
|
|
145
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
146
|
+
let bound = Boolean(yield* resolveKey())
|
|
147
|
+
|
|
148
|
+
yield* ctx.websearch.transform((editor) => {
|
|
149
|
+
if (!bound) return
|
|
150
|
+
editor.add({
|
|
151
|
+
id: ID,
|
|
152
|
+
name: "Jina",
|
|
153
|
+
execute: ({ query }) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const queryKey = cacheKey(query)
|
|
156
|
+
const cached = readCache(queryKey)
|
|
157
|
+
if (cached) return cached
|
|
158
|
+
|
|
159
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
160
|
+
// event round-trip, so this keeps that window explicit.
|
|
161
|
+
const key = yield* resolveKey()
|
|
162
|
+
if (!key) {
|
|
163
|
+
return yield* Effect.fail(
|
|
164
|
+
new Error("Connect Jina in /connect, or set JINA_API_KEY"),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const response = yield* Effect.tryPromise({
|
|
169
|
+
try: () =>
|
|
170
|
+
fetch("https://s.jina.ai/", {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
Accept: "application/json",
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
Authorization: "Bearer " + key,
|
|
176
|
+
// Snippets only unless full page text was asked for: the
|
|
177
|
+
// content Jina returns is token-billed and lands in the
|
|
178
|
+
// agent's context verbatim.
|
|
179
|
+
...(INCLUDE_CONTENT ? {} : { "X-Respond-With": "no-content" }),
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
q: queryKey,
|
|
183
|
+
...(SEARCH_SIZE >= 1 ? { num: SEARCH_SIZE } : {}),
|
|
184
|
+
...(GL ? { gl: GL } : {}),
|
|
185
|
+
...(HL ? { hl: HL } : {}),
|
|
186
|
+
...(LOCATION ? { location: LOCATION } : {}),
|
|
187
|
+
}),
|
|
188
|
+
}),
|
|
189
|
+
catch: (error) => new Error("Jina request failed: " + String(error)),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const detail = yield* Effect.tryPromise({
|
|
194
|
+
try: () => response.text(),
|
|
195
|
+
catch: () => new Error("response body unavailable"),
|
|
196
|
+
})
|
|
197
|
+
return yield* Effect.fail(
|
|
198
|
+
new Error("Jina API error " + response.status + ": " + detail),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const json = yield* Effect.tryPromise({
|
|
203
|
+
try: () => response.json(),
|
|
204
|
+
catch: (error) => new Error("Invalid Jina response: " + String(error)),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const body = yield* Schema.decodeUnknownEffect(JinaResponse)(json)
|
|
208
|
+
const results = (body.data ?? []).flatMap((item) =>
|
|
209
|
+
item.url ? [toResult(item, INCLUDE_CONTENT)] : [],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
writeCache(queryKey, results)
|
|
213
|
+
return results
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
219
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
220
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
221
|
+
yield* Effect.forkScoped(
|
|
222
|
+
ctx.event.subscribe().pipe(
|
|
223
|
+
Stream.filter(
|
|
224
|
+
(event) =>
|
|
225
|
+
event.type === "credential.updated" ||
|
|
226
|
+
event.type === "credential.switched" ||
|
|
227
|
+
event.type === "integration.updated",
|
|
228
|
+
),
|
|
229
|
+
Stream.mapEffect(() =>
|
|
230
|
+
Effect.gen(function* () {
|
|
231
|
+
const next = Boolean(yield* resolveKey())
|
|
232
|
+
if (next === bound) return
|
|
233
|
+
bound = next
|
|
234
|
+
yield* ctx.websearch.reload()
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
Stream.runDrain,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
export default Plugin.define({ id: "jina-search", effect })
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Linkup returns { results: [...] } with no body-level error envelope: failures
|
|
5
|
+
// are HTTP status codes only. Text and image results share one list; image
|
|
6
|
+
// entries carry no content, so they are dropped on the way out. Every field is
|
|
7
|
+
// optional -- a missing one means "the source provided none", not an error.
|
|
8
|
+
const LinkupResult = Schema.Struct({
|
|
9
|
+
name: Schema.optional(Schema.String),
|
|
10
|
+
url: Schema.String,
|
|
11
|
+
content: Schema.optional(Schema.String),
|
|
12
|
+
favicon: Schema.optional(Schema.String),
|
|
13
|
+
type: Schema.optional(Schema.String),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const LinkupResponse = Schema.Struct({
|
|
17
|
+
results: Schema.optional(Schema.Array(LinkupResult)),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
21
|
+
const ID = "linkup"
|
|
22
|
+
|
|
23
|
+
function toResult(item: typeof LinkupResult.Type) {
|
|
24
|
+
return {
|
|
25
|
+
url: item.url,
|
|
26
|
+
title: item.name || item.url,
|
|
27
|
+
content: item.content ?? "",
|
|
28
|
+
time: {},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
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
|
+
// Linkup bills per request on credits and has no anonymous tier. A repeat
|
|
46
|
+
// query is a fresh billed dispatch, so responses are cached per normalized
|
|
47
|
+
// query. Knobs are read once at load time, restart after changing them.
|
|
48
|
+
//
|
|
49
|
+
// LINKUP_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
50
|
+
// LINKUP_DEPTH "flash" (few hundred ms), "fast" (~1s one-shot, the
|
|
51
|
+
// vendor's recommended default for agents), "standard"
|
|
52
|
+
// (single agentic pass) or "deep" (several iterations)
|
|
53
|
+
// LINKUP_MAX_RESULTS results per search, clamped to 1-100
|
|
54
|
+
// LINKUP_API_KEY required; from https://app.linkup.so
|
|
55
|
+
const CACHE_TTL = envInt("LINKUP_CACHE_TTL", 600_000)
|
|
56
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("LINKUP_MAX_RESULTS", 10), 1), 100)
|
|
57
|
+
const DEPTH = ["flash", "fast", "standard", "deep"].includes(envStr("LINKUP_DEPTH"))
|
|
58
|
+
? envStr("LINKUP_DEPTH")
|
|
59
|
+
: "fast"
|
|
60
|
+
|
|
61
|
+
const CACHE_LIMIT = 200
|
|
62
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
63
|
+
|
|
64
|
+
function cacheKey(query: string): string {
|
|
65
|
+
return query.trim().replace(/\s+/g, " ")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
69
|
+
if (CACHE_TTL <= 0) return undefined
|
|
70
|
+
const hit = cache.get(queryKey)
|
|
71
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
75
|
+
if (CACHE_TTL <= 0) return
|
|
76
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
77
|
+
if (cache.size > CACHE_LIMIT) {
|
|
78
|
+
const oldest = cache.keys().next().value
|
|
79
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
// Make "Linkup" show up in /connect with an API-key method.
|
|
86
|
+
yield* ctx.integration.transform((editor) => {
|
|
87
|
+
editor.update(ID, (integration) => {
|
|
88
|
+
integration.name = "Linkup"
|
|
89
|
+
})
|
|
90
|
+
editor.method.update({
|
|
91
|
+
integrationID: ID,
|
|
92
|
+
method: { type: "key", label: "Linkup API key" },
|
|
93
|
+
})
|
|
94
|
+
editor.method.update({
|
|
95
|
+
integrationID: ID,
|
|
96
|
+
method: { type: "env", names: ["LINKUP_API_KEY"] },
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
101
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
102
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
103
|
+
// environment fallback below applies exactly as it does when the credential
|
|
104
|
+
// simply does not exist.
|
|
105
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
106
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
107
|
+
|
|
108
|
+
// The key comes from the /connect credential, falling back to the
|
|
109
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
110
|
+
const resolveKey = () =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
113
|
+
const credential = connection
|
|
114
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
115
|
+
: undefined
|
|
116
|
+
return credential?.type === "key" ? credential.key : process.env.LINKUP_API_KEY
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Linkup is bound to websearch only while a key exists. Deleting the key
|
|
120
|
+
// unbinds the provider, so `random` can never pick a keyless Linkup.
|
|
121
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
122
|
+
let bound = Boolean(yield* resolveKey())
|
|
123
|
+
|
|
124
|
+
yield* ctx.websearch.transform((editor) => {
|
|
125
|
+
if (!bound) return
|
|
126
|
+
editor.add({
|
|
127
|
+
id: ID,
|
|
128
|
+
name: "Linkup",
|
|
129
|
+
execute: ({ query }) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const queryKey = cacheKey(query)
|
|
132
|
+
const cached = readCache(queryKey)
|
|
133
|
+
if (cached) return cached
|
|
134
|
+
|
|
135
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
136
|
+
// event round-trip, so this keeps that window explicit.
|
|
137
|
+
const key = yield* resolveKey()
|
|
138
|
+
if (!key) {
|
|
139
|
+
return yield* Effect.fail(
|
|
140
|
+
new Error("Connect Linkup in /connect, or set LINKUP_API_KEY"),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const response = yield* Effect.tryPromise({
|
|
145
|
+
try: () =>
|
|
146
|
+
fetch("https://api.linkup.so/v1/search", {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
Accept: "application/json",
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
Authorization: "Bearer " + key,
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
q: queryKey,
|
|
155
|
+
depth: DEPTH,
|
|
156
|
+
outputType: "searchResults",
|
|
157
|
+
maxResults: SEARCH_SIZE,
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
catch: (error) => new Error("Linkup request failed: " + String(error)),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (!response.ok) {
|
|
164
|
+
const detail = yield* Effect.tryPromise({
|
|
165
|
+
try: () => response.text(),
|
|
166
|
+
catch: () => new Error("response body unavailable"),
|
|
167
|
+
})
|
|
168
|
+
return yield* Effect.fail(
|
|
169
|
+
new Error("Linkup API error " + response.status + ": " + detail),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const json = yield* Effect.tryPromise({
|
|
174
|
+
try: () => response.json(),
|
|
175
|
+
catch: (error) => new Error("Invalid Linkup response: " + String(error)),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const body = yield* Schema.decodeUnknownEffect(LinkupResponse)(json)
|
|
179
|
+
const results = (body.results ?? []).flatMap((item) =>
|
|
180
|
+
item.url && item.type !== "image" ? [toResult(item)] : [],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
writeCache(queryKey, results)
|
|
184
|
+
return results
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
190
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
191
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
192
|
+
yield* Effect.forkScoped(
|
|
193
|
+
ctx.event.subscribe().pipe(
|
|
194
|
+
Stream.filter(
|
|
195
|
+
(event) =>
|
|
196
|
+
event.type === "credential.updated" ||
|
|
197
|
+
event.type === "credential.switched" ||
|
|
198
|
+
event.type === "integration.updated",
|
|
199
|
+
),
|
|
200
|
+
Stream.mapEffect(() =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const next = Boolean(yield* resolveKey())
|
|
203
|
+
if (next === bound) return
|
|
204
|
+
bound = next
|
|
205
|
+
yield* ctx.websearch.reload()
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
Stream.runDrain,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
export default Plugin.define({ id: "linkup-search", effect })
|