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,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 })