opencode-search-plugin 1.0.1 → 2.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/README.md CHANGED
@@ -2,30 +2,45 @@
2
2
 
3
3
  **English** · [简体中文](README.zh-CN.md)
4
4
 
5
- Nine self-contained [OpenCode](https://opencode.ai) v2 plugins that register web search providers. Each file stands on its own: copy one into your plugins directory, or install the package to get all nine.
5
+ Ten self-contained [OpenCode](https://opencode.ai) v2 plugins that register web search providers. Each file stands on its own: copy one into your plugins directory, or install the package to get all ten.
6
6
 
7
- [AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
7
+ [AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
8
8
 
9
9
  ## Why this exists
10
10
 
11
11
  OpenCode v2 ships with five web search providers built in: `exa`, `firecrawl`, `parallel`, `tavily` and `tinyfish`. Outside that set, a much wider field of search APIs offers free quotas or free tiers — enough to run an agent's searches without paying for the privilege.
12
12
 
13
- This repository adds plugins for nine more of them, taking the pool from five providers to fourteen. AnySearch runs without a key at all; the other eight bind only when you supply one, so installing this package never routes a query to a provider that would immediately fail. Which of them are worth your time depends on the free tier each one currently offers, so check a provider's pricing before you rely on it.
13
+ This repository adds plugins for ten more of them, taking the pool from five providers to fifteen. AnySearch and Keenable run without a key at all; the other eight bind only when you supply one, so installing this package never routes a query to a provider that would immediately fail. Which of them are worth your time depends on the free tier each one currently offers, so check a provider's pricing before you rely on it.
14
14
 
15
15
  ## Install
16
16
 
17
- ### All nine, as a package
17
+ Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@2.0.0` always installs the same bytes, whereas a GitHub tag can in principle be moved by a maintainer to point at a different commit. The first two install the same complete package (all ten); use the third for one or two files only.
18
18
 
19
- ```sh
20
- # from GitHub (add #v1.0.1 to pin)
21
- opencode plugin add github:guotree/opencode-search-plugin
19
+ ### 1. From npm (recommended)
22
20
 
23
- # or from npm (add @1.0.1 to pin)
21
+ ```sh
22
+ # add @2.0.0 to pin
24
23
  opencode plugin add opencode-search-plugin
25
24
  ```
26
25
 
27
26
  or declare it in `opencode.json(c)`:
28
27
 
28
+ ```jsonc
29
+ {
30
+ "$schema": "https://opencode.ai/config.json",
31
+ "plugins": ["opencode-search-plugin"]
32
+ }
33
+ ```
34
+
35
+ ### 2. From GitHub
36
+
37
+ ```sh
38
+ # add #v2.0.0 to pin
39
+ opencode plugin add github:guotree/opencode-search-plugin
40
+ ```
41
+
42
+ or declare it in `opencode.json(c)`:
43
+
29
44
  ```jsonc
30
45
  {
31
46
  "$schema": "https://opencode.ai/config.json",
@@ -33,7 +48,7 @@ or declare it in `opencode.json(c)`:
33
48
  }
34
49
  ```
35
50
 
36
- ### One or two, by copying a file
51
+ ### 3. By copying a file
37
52
 
38
53
  Every plugin is a single `.ts` file with no relative imports and no build step. Drop it where OpenCode auto-discovers plugins:
39
54
 
@@ -52,31 +67,55 @@ For a location that is not auto-discovered, list it explicitly:
52
67
 
53
68
  ### Skip the services you have no key for
54
69
 
55
- Loading the package registers all nine, but a provider without a key never joins the search pool — see [Keys](#keys). To keep them out of the registry entirely:
70
+ Loading the package registers all ten, but a provider without a key never joins the search pool — see [Keys](#keys). To keep them out of the registry entirely:
56
71
 
57
72
  ```jsonc
58
73
  {
59
74
  "plugins": [
60
75
  {
61
- "package": "github:guotree/opencode-search-plugin",
62
- "options": { "disable": ["serper", "serpapi", "youcom"] }
76
+ "package": "opencode-search-plugin",
77
+ "options": { "disable": ["osp-serper", "osp-serpapi", "osp-youcom"] }
63
78
  }
64
79
  ]
65
80
  }
66
81
  ```
67
82
 
68
- `disable` takes the provider IDs listed in the table below.
83
+ `disable` takes the provider IDs listed in the table below. AnySearch and Keenable need no key, so `disable` is the only way to keep those two out of the pool.
84
+
85
+ ## Upgrade
86
+
87
+ ```sh
88
+ # installed from npm
89
+ opencode plugin update opencode-search-plugin
90
+ # installed from GitHub
91
+ opencode plugin update github:guotree/opencode-search-plugin
92
+ # everything outdated
93
+ opencode plugin update
94
+ ```
95
+
96
+ The target is the specifier that was configured: an npm install is named by its package name, a GitHub install answers only to its `github:` form — naming it by its bare package name fails with `Plugin is not configured`. Run `opencode plugin check` first to see what is outdated. An install without a pinned version follows new releases; a pinned one — `@2.0.0` on npm, `#v2.0.0` on GitHub — stays on its pin until you re-run `plugin add` with a newer pin or edit the specifier in `opencode.json(c)`. A locally copied file is upgraded by copying the new version over the old one.
97
+
98
+ ## Uninstall
99
+
100
+ ```sh
101
+ # installed from npm
102
+ opencode plugin remove opencode-search-plugin
103
+ # installed from GitHub
104
+ opencode plugin remove github:guotree/opencode-search-plugin
105
+ ```
106
+
107
+ The same rule: pass the specifier you installed with. The command edits the global configuration; if the plugin is instead a `plugins` entry in `opencode.json(c)` or a locally copied file, delete the entry or the copied `.ts` file. Saved `/connect` credentials are untouched — a reinstall finds them again — and to turn off a few providers instead of the whole plugin, use `disable` above.
69
108
 
70
109
  ## Keys
71
110
 
72
- Every provider except AnySearch needs an API key. Give it one of two ways:
111
+ Every provider except AnySearch and Keenable needs an API key. Give it one of two ways:
73
112
 
74
113
  - **`/connect`** inside OpenCode — stored per provider, rotated without a restart, and survives environment changes.
75
114
  - **Environment variable** — read as a fallback, so it works in headless and CI contexts.
76
115
 
77
116
  A provider with no key stays unbound: it is absent from the websearch pool, so `"provider": "random"` can never route a query to a service that would immediately fail.
78
117
 
79
- AnySearch is the exception. It has an anonymous tier, so it is always registered and only attaches an `Authorization` header when a key exists.
118
+ AnySearch and Keenable are the exceptions. Both have an anonymous tier, so they are always registered and only attach their credential header when a key exists. For Keenable the two tiers draw on separate budgets: a key spends the free allowance of 100,000 requests a month, while a keyless call goes to the `/public` endpoint instead and is metered by rate — 1,000 requests an hour and 10 a second per IP, on a pool shared with everyone else on your egress address. A key is what makes that shared headroom irrelevant.
80
119
 
81
120
  Once keys are in place, pick how OpenCode chooses among them:
82
121
 
@@ -84,27 +123,30 @@ Once keys are in place, pick how OpenCode chooses among them:
84
123
  { "websearch": { "provider": "random" } }
85
124
  ```
86
125
 
87
- `random` spreads queries across every bound provider. Set it to a specific ID such as `"brave"` to pin one, or `false` to turn web search off.
126
+ `random` spreads queries across every bound provider. Set it to a specific ID such as `"osp-brave"` to pin one, or `false` to turn web search off.
88
127
 
89
128
  ## Providers
90
129
 
130
+ Every ID below carries an `osp-` prefix — this package's namespace. OpenCode's own providers register bare vendor names (`exa`, `tavily`), and a vendor's official plugin would do the same, so the prefix is what keeps those entries from colliding with ours — both in the websearch pool and in the `/connect` table, where credentials are keyed by integration ID. Use the prefixed form verbatim in `websearch.provider` and in `disable`.
131
+
91
132
  | Provider | ID | Key (env / `/connect`) | Anonymous | Notes |
92
133
  | --- | --- | --- | --- | --- |
93
- | AnySearch | `anysearch` | `ANYSEARCH_API_KEY` | yes | Free tier per IP; intent router, `cn`/`intl` zones |
94
- | Brave Search | `brave` | `BRAVE_SEARCH_API_KEY` | no | Independent index, 20 results max |
95
- | Jina | `jina` | `JINA_API_KEY` | no | Page content is opt-in because it is token-billed |
96
- | Linkup | `linkup` | `LINKUP_API_KEY` | no | `flash`/`fast`/`standard`/`deep` reasoning depth |
97
- | Metaso | `metaso` | `METASO_API_KEY` | no | Billed per search, not per result |
98
- | Querit | `querit` | `QUERIT_API_KEY` | no | Falls back to sentence matches when a snippet is missing |
99
- | SerpApi | `serpapi` | `SERPAPI_API_KEY` | no | Google via SerpApi; also has its own free 1-hour server cache |
100
- | Serper | `serper` | `SERPER_API_KEY` | no | Google via Serper; billed per query in credits |
101
- | You.com | `youcom` | `YDC_API_KEY` | no | Web and news merged; extraction modes |
134
+ | AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | yes | Free tier per IP; intent router, `cn`/`intl` zones |
135
+ | Brave Search | `osp-brave` | `BRAVE_SEARCH_API_KEY` | no | Independent index, 20 results max |
136
+ | Jina | `osp-jina` | `JINA_API_KEY` | no | Page content is opt-in because it is token-billed |
137
+ | Keenable | `osp-keenable` | `KEENABLE_API_KEY` | yes | 100,000 requests a month with a key; keyless tier is per IP |
138
+ | Linkup | `osp-linkup` | `LINKUP_API_KEY` | no | `flash`/`fast`/`standard`/`deep` reasoning depth |
139
+ | Metaso | `osp-metaso` | `METASO_API_KEY` | no | Billed per search, not per result |
140
+ | Querit | `osp-querit` | `QUERIT_API_KEY` | no | Falls back to sentence matches when a snippet is missing |
141
+ | SerpApi | `osp-serpapi` | `SERPAPI_API_KEY` | no | Google via SerpApi; also has its own free 1-hour server cache |
142
+ | Serper | `osp-serper` | `SERPER_API_KEY` | no | Google via Serper; billed per query in credits |
143
+ | You.com | `osp-youcom` | `YDC_API_KEY` | no | Web and news merged; extraction modes |
102
144
 
103
145
  ## Configuration
104
146
 
105
147
  Knobs are environment variables, read once when OpenCode starts — change one and restart. Defaults match what each API documents, so leaving them unset is the right starting point.
106
148
 
107
- ### Shared by all nine
149
+ ### Shared by all ten
108
150
 
109
151
  | Variable | Default | Meaning |
110
152
  | --- | --- | --- |
@@ -139,6 +181,15 @@ Knobs are environment variables, read once when OpenCode starts — change one a
139
181
  | `JINA_GL` / `JINA_HL` | — | Country / language code |
140
182
  | `JINA_LOCATION` | — | City-level origin to simulate, e.g. `Berlin` |
141
183
 
184
+ ### Keenable
185
+
186
+ | Variable | Default | Meaning |
187
+ | --- | --- | --- |
188
+ | `KEENABLE_MAX_RESULTS` | `10` | Results per search, clamped to 1–50 |
189
+ | `KEENABLE_MODE` | — | `pro` (deeper retrieval) or `realtime` (fastest); unset leaves Keenable's own default |
190
+ | `KEENABLE_TITLE` | `opencode-search-plugin` | App name sent as `X-Keenable-Title`, which the keyless endpoint requires to attribute the call |
191
+ | `KEENABLE_API_KEY` | — | Optional; keyless access works without it |
192
+
142
193
  ### Linkup
143
194
 
144
195
  | Variable | Default | Meaning |
@@ -203,12 +254,14 @@ opencode plugin list
203
254
 
204
255
  # Which providers are actually in the pool?
205
256
  # A provider missing here has no key, or is listed in `disable`.
257
+ # AnySearch and Keenable are always present, since neither needs one.
206
258
  opencode api get "/api/websearch/provider"
207
259
  ```
208
260
 
209
261
  Also worth checking:
210
262
 
211
263
  - **A provider is missing from the pool** — it has no key. Run `/connect` or export its variable, then restart; credential changes made in `/connect` rebind without one.
264
+ - **A search fails with `Keenable rate limited the request (HTTP 429)`** — the keyless pool is capped at 1,000 requests an hour and 10 a second per IP, shared with the rest of your egress address. Set `KEENABLE_API_KEY` to draw on your own allowance instead.
212
265
  - **A search fails with `Connect X in /connect, or set X_API_KEY`** — the key disappeared between binding and searching. Reconnect or re-export.
213
266
  - **Everything is slow or burning credits** — leave `*_CACHE_TTL` at its default. Repeated queries within the window are served from memory.
214
267
  - **Nothing happens after editing a plugin file** — OpenCode watches plugins and reloads them; if it does not, `opencode service restart`.
package/README.zh-CN.md CHANGED
@@ -2,30 +2,45 @@
2
2
 
3
3
  # opencode-search-plugin
4
4
 
5
- 九个相互独立的 [OpenCode](https://opencode.ai) v2 插件,各自注册一个网页搜索 provider。每个文件都能单独使用:复制一个进你的插件目录,或者安装这个包一次拿全九个。
5
+ 十个相互独立的 [OpenCode](https://opencode.ai) v2 插件,各自注册一个网页搜索 provider。每个文件都能单独使用:复制一个进你的插件目录,或者安装这个包一次拿全十个。
6
6
 
7
- [AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
7
+ [AnySearch](https://www.anysearch.com/) · [Brave Search](https://brave.com/search/api/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
8
8
 
9
9
  ## 为什么做这个插件
10
10
 
11
11
  OpenCode v2 内置了五个网页搜索 provider:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。在这个范围之外,还有大量搜索 API 提供免费额度或免费层级,足以支撑 agent 的日常检索而不必为此付费。
12
12
 
13
- 本仓库为其中九家补上了插件,把可用池从五个 provider 扩到十四个。AnySearch 无需任何 key 即可使用;其余八家只在你提供 key 之后才会绑定,因此装上这个包也不会把查询路由到一个注定失败的服务上。至于哪几家值得用,取决于它们各自当前的免费额度,长期依赖某家之前建议先确认它的定价。
13
+ 本仓库为其中十家补上了插件,把可用池从五个 provider 扩到十五个。AnySearch 和 Keenable 无需任何 key 即可使用;其余八家只在你提供 key 之后才会绑定,因此装上这个包也不会把查询路由到一个注定失败的服务上。至于哪几家值得用,取决于它们各自当前的免费额度,长期依赖某家之前建议先确认它的定价。
14
14
 
15
15
  ## 安装
16
16
 
17
- ### 一次性装全九个:包安装
17
+ 三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@2.0.0` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十个全有),只想用一两个就用第三种。
18
18
 
19
- ```sh
20
- # 从 GitHub 安装(追加 #v1.0.1 可固定到该 tag)
21
- opencode plugin add github:guotree/opencode-search-plugin
19
+ ### 1. 从 npm 安装(推荐)
22
20
 
23
- # 或从 npm 安装(追加 @1.0.1 可固定到该版本)
21
+ ```sh
22
+ # 追加 @2.0.0 可固定到该版本
24
23
  opencode plugin add opencode-search-plugin
25
24
  ```
26
25
 
27
26
  或者写进 `opencode.json(c)`:
28
27
 
28
+ ```jsonc
29
+ {
30
+ "$schema": "https://opencode.ai/config.json",
31
+ "plugins": ["opencode-search-plugin"]
32
+ }
33
+ ```
34
+
35
+ ### 2. 从 GitHub 安装
36
+
37
+ ```sh
38
+ # 追加 #v2.0.0 可固定到该 tag
39
+ opencode plugin add github:guotree/opencode-search-plugin
40
+ ```
41
+
42
+ 或者写进 `opencode.json(c)`:
43
+
29
44
  ```jsonc
30
45
  {
31
46
  "$schema": "https://opencode.ai/config.json",
@@ -33,7 +48,7 @@ opencode plugin add opencode-search-plugin
33
48
  }
34
49
  ```
35
50
 
36
- ### 只要一两个:直接复制文件
51
+ ### 3. 本地复制文件
37
52
 
38
53
  每个插件都是单个 `.ts` 文件,没有相对导入,也没有构建步骤。放进 OpenCode 会自动发现插件的位置即可:
39
54
 
@@ -52,31 +67,55 @@ opencode plugin add opencode-search-plugin
52
67
 
53
68
  ### 跳过你没有 key 的服务
54
69
 
55
- 装包会注册全部九家,但没有 key 的 provider 永远不会进入搜索池——见下文[密钥](#密钥)。想让它们连注册表都不出现:
70
+ 装包会注册全部十家,但没有 key 的 provider 永远不会进入搜索池——见下文[密钥](#密钥)。想让它们连注册表都不出现:
56
71
 
57
72
  ```jsonc
58
73
  {
59
74
  "plugins": [
60
75
  {
61
- "package": "github:guotree/opencode-search-plugin",
62
- "options": { "disable": ["serper", "serpapi", "youcom"] }
76
+ "package": "opencode-search-plugin",
77
+ "options": { "disable": ["osp-serper", "osp-serpapi", "osp-youcom"] }
63
78
  }
64
79
  ]
65
80
  }
66
81
  ```
67
82
 
68
- `disable` 填的是下表中的 provider ID。
83
+ `disable` 填的是下表中的 provider ID。AnySearch 和 Keenable 不需要 key,想把这两家也排除在搜索池之外,只能用 `disable`。
84
+
85
+ ## 升级
86
+
87
+ ```sh
88
+ # 从 npm 安装的
89
+ opencode plugin update opencode-search-plugin
90
+ # 从 GitHub 安装的
91
+ opencode plugin update github:guotree/opencode-search-plugin
92
+ # 更新所有过期插件
93
+ opencode plugin update
94
+ ```
95
+
96
+ 升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式——用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的——npm 形式的 `@2.0.0`、GitHub 形式的 `#v2.0.0`——会停在原地,直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件则用新版本覆盖旧文件来升级。
97
+
98
+ ## 卸载
99
+
100
+ ```sh
101
+ # 从 npm 安装的
102
+ opencode plugin remove opencode-search-plugin
103
+ # 从 GitHub 安装的
104
+ opencode plugin remove github:guotree/opencode-search-plugin
105
+ ```
106
+
107
+ 同上:传入安装时使用的说明符。该命令修改的是全局配置;若插件是写在 `opencode.json(c)` 的 `plugins` 条目里或本地复制的文件,删掉对应条目、或删掉复制的 `.ts` 文件即可。`/connect` 里保存的密钥不受影响——重新装回就能继续用——只想关掉其中几家而不是整个插件,用上文的 `disable`。
69
108
 
70
109
  ## 密钥
71
110
 
72
- 除 AnySearch 外,每家服务都需要 API key。两种给法二选一:
111
+ 除 AnySearch 和 Keenable 外,每家服务都需要 API key。两种给法二选一:
73
112
 
74
113
  - **OpenCode 内的 `/connect`** —— 按 provider 存储,轮换后无需重启,且不受环境变量变动影响。
75
114
  - **环境变量** —— 作为兜底读取,因此在无界面和 CI 场景下也能工作。
76
115
 
77
116
  没有 key 的 provider 会保持未绑定状态:它不在 websearch 池中,所以 `"provider": "random"` 绝不会把查询路由到一个注定失败的服务上。
78
117
 
79
- AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key 时才附带 `Authorization` 头。
118
+ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册,只有存在 key 时才附带各自的凭据头。Keenable 的两层额度互不通用:有 key 时消耗每月 100,000 次的免费额度,无 key 的请求则改走 `/public` 端点,按速率限制——每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的所有人共用。想摆脱这个共享池的不确定性,就得配一把 key。
80
119
 
81
120
  密钥就位后,再决定 OpenCode 如何在它们之间做选择:
82
121
 
@@ -84,27 +123,30 @@ AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key
84
123
  { "websearch": { "provider": "random" } }
85
124
  ```
86
125
 
87
- `random` 会把查询分散到所有已绑定的 provider。填具体 ID(例如 `"brave"`)则固定用某一家,填 `false` 则完全关闭网页搜索。
126
+ `random` 会把查询分散到所有已绑定的 provider。填具体 ID(例如 `"osp-brave"`)则固定用某一家,填 `false` 则完全关闭网页搜索。
88
127
 
89
128
  ## 支持的服务
90
129
 
130
+ 下表每个 ID 都带 `osp-` 前缀——这是本包的命名空间。OpenCode 内置 provider 用的是裸厂商名(`exa`、`tavily`),厂商自己的插件也会注册裸名(Keenable 就已经有一个),所以前缀是避免撞名的唯一保障:既避开搜索池里的重复,也避开 `/connect` 里按 integration ID 存凭据的那张表。在 `websearch.provider` 和 `disable` 里请原样使用带前缀的 ID。
131
+
91
132
  | 服务 | ID | 密钥(环境变量 / `/connect`) | 匿名可用 | 说明 |
92
133
  | --- | --- | --- | --- | --- |
93
- | AnySearch | `anysearch` | `ANYSEARCH_API_KEY` | 是 | 按 IP 的免费额度;意图路由,`cn` / `intl` 分区 |
94
- | Brave Search | `brave` | `BRAVE_SEARCH_API_KEY` | 否 | 独立索引,最多 20 条结果 |
95
- | Jina | `jina` | `JINA_API_KEY` | 否 | 页面正文按 token 计费,因此默认不取 |
96
- | Linkup | `linkup` | `LINKUP_API_KEY` | 否 | `flash` / `fast` / `standard` / `deep` 四档推理深度 |
97
- | Metaso | `metaso` | `METASO_API_KEY` | 否 | 按次搜索计费,不按结果条数 |
98
- | Querit | `querit` | `QUERIT_API_KEY` | 否 | 摘要缺失时回退到句子级匹配 |
99
- | SerpApi | `serpapi` | `SERPAPI_API_KEY` | 否 | 经 SerpApi 调用 Google;另有其自带的免费 1 小时服务端缓存 |
100
- | Serper | `serper` | `SERPER_API_KEY` | 否 | 经 Serper 调用 Google;按查询消耗积分计费 |
101
- | You.com | `youcom` | `YDC_API_KEY` | 否 | 网页与新闻结果合并;多种正文抽取模式 |
134
+ | AnySearch | `osp-anysearch` | `ANYSEARCH_API_KEY` | 是 | 按 IP 的免费额度;意图路由,`cn` / `intl` 分区 |
135
+ | Brave Search | `osp-brave` | `BRAVE_SEARCH_API_KEY` | 否 | 独立索引,最多 20 条结果 |
136
+ | Jina | `osp-jina` | `JINA_API_KEY` | 否 | 页面正文按 token 计费,因此默认不取 |
137
+ | Keenable | `osp-keenable` | `KEENABLE_API_KEY` | 是 | 有 key 每月 100,000 次免费请求;无 key 层级按 IP 限速 |
138
+ | Linkup | `osp-linkup` | `LINKUP_API_KEY` | 否 | `flash` / `fast` / `standard` / `deep` 四档推理深度 |
139
+ | Metaso | `osp-metaso` | `METASO_API_KEY` | 否 | 按次搜索计费,不按结果条数 |
140
+ | Querit | `osp-querit` | `QUERIT_API_KEY` | 否 | 摘要缺失时回退到句子级匹配 |
141
+ | SerpApi | `osp-serpapi` | `SERPAPI_API_KEY` | 否 | 经 SerpApi 调用 Google;另有其自带的免费 1 小时服务端缓存 |
142
+ | Serper | `osp-serper` | `SERPER_API_KEY` | 否 | 经 Serper 调用 Google;按查询消耗积分计费 |
143
+ | You.com | `osp-youcom` | `YDC_API_KEY` | 否 | 网页与新闻结果合并;多种正文抽取模式 |
102
144
 
103
145
  ## 配置
104
146
 
105
147
  所有配置项都是环境变量,OpenCode 启动时读取一次——改了要重启。默认值取自各家 API 文档,所以一开始什么都不设就是合理的起点。
106
148
 
107
- ### 九家通用
149
+ ### 十家通用
108
150
 
109
151
  | 变量 | 默认值 | 含义 |
110
152
  | --- | --- | --- |
@@ -139,6 +181,15 @@ AnySearch 是例外。它有匿名额度,因此始终注册,只有存在 key
139
181
  | `JINA_GL` / `JINA_HL` | — | 国家 / 语言代码 |
140
182
  | `JINA_LOCATION` | — | 模拟的城市级来源,例如 `Berlin` |
141
183
 
184
+ ### Keenable
185
+
186
+ | 变量 | 默认值 | 含义 |
187
+ | --- | --- | --- |
188
+ | `KEENABLE_MAX_RESULTS` | `10` | 每次搜索的结果数,限制在 1–50 |
189
+ | `KEENABLE_MODE` | — | `pro`(更深的检索)或 `realtime`(最快);不设则沿用 Keenable 自己的默认值 |
190
+ | `KEENABLE_TITLE` | `opencode-search-plugin` | 作为 `X-Keenable-Title` 发出的应用名,无 key 端点靠它归属调用方 |
191
+ | `KEENABLE_API_KEY` | — | 可选;不设也能匿名使用 |
192
+
142
193
  ### Linkup
143
194
 
144
195
  | 变量 | 默认值 | 含义 |
@@ -203,12 +254,14 @@ opencode plugin list
203
254
 
204
255
  # 搜索池里到底有哪些 provider?
205
256
  # 这里少了一家,说明它没有 key,或者被写进了 `disable`。
257
+ # AnySearch 和 Keenable 永远在列,因为它们都不需要 key。
206
258
  opencode api get "/api/websearch/provider"
207
259
  ```
208
260
 
209
261
  另外几个值得检查的地方:
210
262
 
211
263
  - **某家 provider 不在池里** —— 它没有 key。执行 `/connect` 或导出对应环境变量,然后重启;在 `/connect` 里做的凭据变更会自动重绑,不需要重启。
264
+ - **搜索报错 `Keenable rate limited the request (HTTP 429)`** —— 无 key 层级每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的其他人共用。改用 `KEENABLE_API_KEY` 就会记到你自己的额度上。
212
265
  - **搜索报错 `Connect X in /connect, or set X_API_KEY`** —— key 在绑定和搜索之间消失了。重新连接或重新导出。
213
266
  - **一切都很慢、或者积分烧得飞快** —— 把 `*_CACHE_TTL` 保持在默认值。时间窗内的重复查询由内存直接返回。
214
267
  - **改了插件文件却没反应** —— OpenCode 会监视插件并自动重载;如果没有,执行 `opencode service restart`。
package/index.ts CHANGED
@@ -5,6 +5,7 @@ import type { Scope } from "effect"
5
5
  import { effect as anysearch } from "./plugins/anysearch-search"
6
6
  import { effect as brave } from "./plugins/brave-search"
7
7
  import { effect as jina } from "./plugins/jina-search"
8
+ import { effect as keenable } from "./plugins/keenable-search"
8
9
  import { effect as linkup } from "./plugins/linkup-search"
9
10
  import { effect as metaso } from "./plugins/metaso-search"
10
11
  import { effect as querit } from "./plugins/querit-search"
@@ -15,24 +16,26 @@ import { effect as youcom } from "./plugins/youcom-search"
15
16
  type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
16
17
 
17
18
  /**
18
- * The nine providers this package registers, in alphabetical order. Each entry
19
+ * The ten providers this package registers, in alphabetical order. Each entry
19
20
  * is the same effect its standalone file uses as a default export, so a file
20
21
  * copied out of this repository behaves exactly as it does here.
21
22
  *
22
23
  * The key of each entry is the websearch provider ID -- the one that appears in
23
24
  * `/api/websearch/provider`, in `websearch.provider`, and in the `disable`
24
- * option below. Renaming one would strand existing configuration.
25
+ * option below. Renaming one would strand existing configuration. Each is
26
+ * prefixed `osp-` to namespace it to this package; see CONTRIBUTING.md.
25
27
  */
26
28
  const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
27
- ["anysearch", anysearch],
28
- ["brave", brave],
29
- ["jina", jina],
30
- ["linkup", linkup],
31
- ["metaso", metaso],
32
- ["querit", querit],
33
- ["serpapi", serpapi],
34
- ["serper", serper],
35
- ["youcom", youcom],
29
+ ["osp-anysearch", anysearch],
30
+ ["osp-brave", brave],
31
+ ["osp-jina", jina],
32
+ ["osp-keenable", keenable],
33
+ ["osp-linkup", linkup],
34
+ ["osp-metaso", metaso],
35
+ ["osp-querit", querit],
36
+ ["osp-serpapi", serpapi],
37
+ ["osp-serper", serper],
38
+ ["osp-youcom", youcom],
36
39
  ]
37
40
 
38
41
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-search-plugin",
3
- "version": "1.0.1",
4
- "description": "Nine self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, Jina, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
3
+ "version": "2.0.0",
4
+ "description": "Ten self-contained websearch provider plugins for OpenCode v2: AnySearch, Brave, Jina, Keenable, Linkup, Metaso, Querit, SerpApi, Serper and You.com.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -25,6 +25,7 @@
25
25
  "./anysearch-search": "./plugins/anysearch-search.ts",
26
26
  "./brave-search": "./plugins/brave-search.ts",
27
27
  "./jina-search": "./plugins/jina-search.ts",
28
+ "./keenable-search": "./plugins/keenable-search.ts",
28
29
  "./linkup-search": "./plugins/linkup-search.ts",
29
30
  "./metaso-search": "./plugins/metaso-search.ts",
30
31
  "./querit-search": "./plugins/querit-search.ts",
@@ -27,7 +27,10 @@ const AnySearchResponse = Schema.Struct({
27
27
  })
28
28
 
29
29
  // The /connect integration ID, and the websearch provider ID it binds to.
30
- const ID = "anysearch"
30
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
31
+ // vendor plugin's own entry are bare vendor names, so `anysearch` would collide
32
+ // with them, and with the opencode.db row keyed by integration ID.
33
+ const ID = "osp-anysearch"
31
34
 
32
35
  // AnySearch returns no publication date, so results carry an empty time record.
33
36
  function toResult(item: {
@@ -21,7 +21,10 @@ const BraveResponse = Schema.Struct({
21
21
  })
22
22
 
23
23
  // The /connect integration ID, and the websearch provider ID it binds to.
24
- const ID = "brave"
24
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
25
+ // vendor plugin's own entry are bare vendor names, so `brave` would collide
26
+ // with them, and with the opencode.db row keyed by integration ID.
27
+ const ID = "osp-brave"
25
28
 
26
29
  function envInt(name: string, fallback: number): number {
27
30
  const raw = (process.env[name] ?? "").trim()
@@ -21,7 +21,10 @@ const JinaResponse = Schema.Struct({
21
21
  })
22
22
 
23
23
  // The /connect integration ID, and the websearch provider ID it binds to.
24
- const ID = "jina"
24
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
25
+ // vendor plugin's own entry are bare vendor names, so `jina` would collide
26
+ // with them, and with the opencode.db row keyed by integration ID.
27
+ const ID = "osp-jina"
25
28
 
26
29
  // Jina returns no publication date on search hits, so results carry an empty
27
30
  // time record rather than a parsed one.
@@ -0,0 +1,280 @@
1
+ import { Plugin } from "@opencode/plugin/effect"
2
+ import { Effect, Schema } from "effect"
3
+
4
+ // Keenable answers with a flat object: `results` carries ranked hits, `mode`
5
+ // echoes the tier that served the call, and there is no failure envelope --
6
+ // refusals arrive as plain HTTP statuses. Every field but the URL is optional,
7
+ // and a missing one means "Keenable provided none", not an error.
8
+ const KeenableResponse = Schema.Struct({
9
+ query: Schema.optional(Schema.String),
10
+ mode: Schema.optional(Schema.String),
11
+ results: Schema.optional(
12
+ Schema.Array(
13
+ Schema.Struct({
14
+ title: Schema.optional(Schema.String),
15
+ url: Schema.String,
16
+ description: Schema.optional(Schema.String),
17
+ snippet: Schema.optional(Schema.String),
18
+ published_at: Schema.optional(Schema.String),
19
+ acquired_at: Schema.optional(Schema.String),
20
+ }),
21
+ ),
22
+ ),
23
+ })
24
+
25
+ // The /connect integration ID, and the websearch provider ID it binds to.
26
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
27
+ // vendor plugin's own entry are bare vendor names, so `keenable` would collide
28
+ // with them, and with the opencode.db row keyed by integration ID.
29
+ const ID = "osp-keenable"
30
+
31
+ // Keenable returns ISO 8601 timestamps, which the parser below accepts as-is.
32
+ // See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
33
+ const RELATIVE_MS: Record<string, number> = {
34
+ minute: 60_000,
35
+ hour: 3_600_000,
36
+ day: 86_400_000,
37
+ week: 604_800_000,
38
+ month: 2_592_000_000,
39
+ year: 31_536_000_000,
40
+ 分钟: 60_000,
41
+ 小时: 3_600_000,
42
+ 天: 86_400_000,
43
+ 周: 604_800_000,
44
+ 个月: 2_592_000_000,
45
+ 月: 2_592_000_000,
46
+ 年: 31_536_000_000,
47
+ }
48
+
49
+ const RELATIVE_DATE =
50
+ /^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
51
+
52
+ const ABSOLUTE_DATE = [
53
+ /^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
54
+ /^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
55
+ /^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
56
+ // ISO 8601 with a time component, which Keenable returns on `published_at`.
57
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
58
+ ]
59
+
60
+ function parseSearchDate(value: string | undefined): number {
61
+ const text = value?.trim()
62
+ if (!text) return NaN
63
+
64
+ const relative = RELATIVE_DATE.exec(text)
65
+ if (relative) {
66
+ const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
67
+ const amount = Number(relative[1])
68
+ if (unit === undefined || !Number.isFinite(amount)) return NaN
69
+ return Date.now() - amount * unit
70
+ }
71
+
72
+ // Only trust Date.parse on shapes these providers actually emit -- its lenient
73
+ // parser invents a year for strings like "Nov 1", which would date the result
74
+ // to 2001.
75
+ return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
76
+ }
77
+
78
+ function toResult(item: {
79
+ title?: string | undefined
80
+ url: string
81
+ description?: string | undefined
82
+ snippet?: string | undefined
83
+ published_at?: string | undefined
84
+ }) {
85
+ const published = parseSearchDate(item.published_at)
86
+ return {
87
+ url: item.url,
88
+ title: item.title || item.url,
89
+ // The snippet is the longer excerpt, so it is the better answer to give an
90
+ // agent; the description is the short summary behind it, and Keenable often
91
+ // returns it empty -- `||`, not `??`, so an empty snippet still falls back.
92
+ content: item.snippet || item.description || "",
93
+ time: Number.isFinite(published) ? { published } : {},
94
+ }
95
+ }
96
+
97
+ type SearchResult = ReturnType<typeof toResult>
98
+
99
+ function envInt(name: string, fallback: number): number {
100
+ const raw = (process.env[name] ?? "").trim()
101
+ if (raw === "") return fallback
102
+ const value = Number(raw)
103
+ return Number.isFinite(value) && value >= 0 ? value : fallback
104
+ }
105
+
106
+ function envStr(name: string): string {
107
+ return (process.env[name] ?? "").trim()
108
+ }
109
+
110
+ // Keenable has two tiers that draw on separate budgets. With a key, a call is
111
+ // metered against the free monthly allowance of 100,000 requests per
112
+ // organization. Without one, a call goes to the `/public` twin of each endpoint
113
+ // and is limited per IP instead -- 1,000 an hour and 10 a second on a pool
114
+ // shared with everyone else on the egress address -- and spends no credits.
115
+ // A missing key therefore routes elsewhere rather than failing, which is why
116
+ // this provider never gates on `bound`. Knobs are read once at load time,
117
+ // restart after changing them.
118
+ //
119
+ // KEENABLE_CACHE_TTL ms to reuse a response for the same query (0 disables)
120
+ // KEENABLE_MAX_RESULTS results per search, clamped to 1-50 (the API's range)
121
+ // KEENABLE_MODE "pro" for deeper retrieval, "realtime" for speed;
122
+ // leave unset and the organization's default applies
123
+ // KEENABLE_TITLE app name sent as X-Keenable-Title, which the keyless
124
+ // endpoint requires to attribute traffic (max 256 chars)
125
+ // KEENABLE_API_KEY optional; keyless access works without it
126
+ const CACHE_TTL = envInt("KEENABLE_CACHE_TTL", 600_000)
127
+ const SEARCH_SIZE = Math.min(Math.max(envInt("KEENABLE_MAX_RESULTS", 10), 1), 50)
128
+ const MODE = envStr("KEENABLE_MODE")
129
+ const TITLE = (envStr("KEENABLE_TITLE") || "opencode-search-plugin").slice(0, 256)
130
+
131
+ const SEARCH_URL = "https://api.keenable.ai/v1/search"
132
+ const PUBLIC_URL = "https://api.keenable.ai/v1/search/public"
133
+
134
+ const CACHE_LIMIT = 200
135
+ const cache = new Map<string, { at: number; results: SearchResult[] }>()
136
+
137
+ function cacheKey(query: string): string {
138
+ return query.trim().replace(/\s+/g, " ")
139
+ }
140
+
141
+ function readCache(queryKey: string): SearchResult[] | undefined {
142
+ if (CACHE_TTL <= 0) return undefined
143
+ const hit = cache.get(queryKey)
144
+ return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
145
+ }
146
+
147
+ function writeCache(queryKey: string, results: SearchResult[]): void {
148
+ if (CACHE_TTL <= 0) return
149
+ cache.set(queryKey, { at: Date.now(), results })
150
+ if (cache.size > CACHE_LIMIT) {
151
+ const oldest = cache.keys().next().value
152
+ if (oldest !== undefined) cache.delete(oldest)
153
+ }
154
+ }
155
+
156
+ // Unlike the key-requiring providers in this repo, Keenable never gates on
157
+ // `bound`: keyless access is a first-class mode, so the provider is always in
158
+ // the pool. The key is still read per call, which is why no credential-event
159
+ // subscription is needed -- a rotated key takes effect on the next search
160
+ // without a reload.
161
+ export const effect = (ctx: Plugin.Context) =>
162
+ Effect.gen(function* () {
163
+ // Make "Keenable" show up in /connect with an API-key method.
164
+ yield* ctx.integration.transform((editor) => {
165
+ editor.update(ID, (integration) => {
166
+ integration.name = "Keenable"
167
+ })
168
+ editor.method.update({
169
+ integrationID: ID,
170
+ method: { type: "key", label: "Keenable API key" },
171
+ })
172
+ editor.method.update({
173
+ integrationID: ID,
174
+ method: { type: "env", names: ["KEENABLE_API_KEY"] },
175
+ })
176
+ })
177
+
178
+ // The host instantiates this API's failure channel as `unknown`, which would
179
+ // make the whole plugin effect unrunnable. A credential read must never take
180
+ // the plugin down: a failed read degrades to "no stored key", and the
181
+ // environment fallback below applies exactly as it does when the credential
182
+ // simply does not exist.
183
+ const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
184
+ read.pipe(Effect.catch(() => Effect.succeed(undefined)))
185
+
186
+ yield* ctx.websearch.transform((editor) => {
187
+ editor.add({
188
+ id: ID,
189
+ name: "Keenable",
190
+ execute: ({ query }) =>
191
+ Effect.gen(function* () {
192
+ const queryKey = cacheKey(query)
193
+ const cached = readCache(queryKey)
194
+ if (cached) return cached
195
+
196
+ // Keyless access is a first-class mode, so a missing key is not an
197
+ // error: it only moves the request to the `/public` endpoint.
198
+ const connection = yield* soften(ctx.integration.connection.active(ID))
199
+ const credential = connection
200
+ ? yield* soften(ctx.integration.connection.resolve(connection))
201
+ : undefined
202
+ const key =
203
+ credential?.type === "key" ? credential.key : process.env.KEENABLE_API_KEY
204
+
205
+ const headers: Record<string, string> = {
206
+ Accept: "application/json",
207
+ "Content-Type": "application/json",
208
+ // Required by the keyless endpoint, where an absent value is a
209
+ // 400. It names the calling application rather than the user, so
210
+ // it rides along with a key too.
211
+ "X-Keenable-Title": TITLE,
212
+ }
213
+ if (key) headers["X-API-Key"] = key
214
+
215
+ const response = yield* Effect.tryPromise({
216
+ try: () =>
217
+ fetch(key ? SEARCH_URL : PUBLIC_URL, {
218
+ method: "POST",
219
+ headers,
220
+ body: JSON.stringify({
221
+ query: queryKey,
222
+ max_results: SEARCH_SIZE,
223
+ ...(MODE ? { mode: MODE } : {}),
224
+ }),
225
+ }),
226
+ catch: (error) => new Error("Keenable request failed: " + String(error)),
227
+ })
228
+
229
+ if (!response.ok) {
230
+ // Only a keyed call is metered, so only a keyed call can exhaust
231
+ // the monthly allowance. The keyless pool is limited by rate, so
232
+ // it answers 429 instead.
233
+ if (response.status === 402 && key) {
234
+ return yield* Effect.fail(
235
+ new Error(
236
+ "Keenable credits exhausted (HTTP 402): the free monthly " +
237
+ "allowance of 100,000 requests is spent. Connect Keenable " +
238
+ "in /connect or set KEENABLE_API_KEY to buy credits, or " +
239
+ "wait for the allowance to reset.",
240
+ ),
241
+ )
242
+ }
243
+ if (response.status === 429) {
244
+ const retry = response.headers.get("Retry-After")
245
+ return yield* Effect.fail(
246
+ new Error(
247
+ "Keenable rate limited the request (HTTP 429)" +
248
+ (retry ? ", retry after " + retry + "s" : "") +
249
+ (key
250
+ ? ""
251
+ : ". The keyless pool allows 1,000 requests an hour and " +
252
+ "10 a second per IP; set KEENABLE_API_KEY to spend your " +
253
+ "own 100,000-request monthly allowance instead."),
254
+ ),
255
+ )
256
+ }
257
+ const detail = yield* Effect.tryPromise({
258
+ try: () => response.text(),
259
+ catch: () => new Error("response body unavailable"),
260
+ })
261
+ return yield* Effect.fail(
262
+ new Error("Keenable API error " + response.status + ": " + detail),
263
+ )
264
+ }
265
+
266
+ const json = yield* Effect.tryPromise({
267
+ try: () => response.json(),
268
+ catch: (error) => new Error("Invalid Keenable response: " + String(error)),
269
+ })
270
+
271
+ const body = yield* Schema.decodeUnknownEffect(KeenableResponse)(json)
272
+ const results = (body.results ?? []).map(toResult)
273
+ writeCache(queryKey, results)
274
+ return results
275
+ }),
276
+ })
277
+ })
278
+ })
279
+
280
+ export default Plugin.define({ id: "keenable-search", effect })
@@ -18,7 +18,10 @@ const LinkupResponse = Schema.Struct({
18
18
  })
19
19
 
20
20
  // The /connect integration ID, and the websearch provider ID it binds to.
21
- const ID = "linkup"
21
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
22
+ // vendor plugin's own entry are bare vendor names, so `linkup` would collide
23
+ // with them, and with the opencode.db row keyed by integration ID.
24
+ const ID = "osp-linkup"
22
25
 
23
26
  function toResult(item: typeof LinkupResult.Type) {
24
27
  return {
@@ -16,7 +16,10 @@ const MetasoResponse = Schema.Struct({
16
16
  })
17
17
 
18
18
  // The /connect integration ID, and the websearch provider ID it binds to.
19
- const ID = "metaso"
19
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
20
+ // vendor plugin's own entry are bare vendor names, so `metaso` would collide
21
+ // with them, and with the opencode.db row keyed by integration ID.
22
+ const ID = "osp-metaso"
20
23
 
21
24
  function envInt(name: string, fallback: number): number {
22
25
  const raw = (process.env[name] ?? "").trim()
@@ -24,7 +24,10 @@ const QueritResponse = Schema.Struct({
24
24
  })
25
25
 
26
26
  // The /connect integration ID, and the websearch provider ID it binds to.
27
- const ID = "querit"
27
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
28
+ // vendor plugin's own entry are bare vendor names, so `querit` would collide
29
+ // with them, and with the opencode.db row keyed by integration ID.
30
+ const ID = "osp-querit"
28
31
 
29
32
  // search_id is documented as both a string and an integer, so read it loosely
30
33
  // rather than letting a strict decode reject an otherwise good response.
@@ -19,7 +19,10 @@ const SerpApiResponse = Schema.Struct({
19
19
  })
20
20
 
21
21
  // The /connect integration ID, and the websearch provider ID it binds to.
22
- const ID = "serpapi"
22
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
23
+ // vendor plugin's own entry are bare vendor names, so `serpapi` would collide
24
+ // with them, and with the opencode.db row keyed by integration ID.
25
+ const ID = "osp-serpapi"
23
26
 
24
27
  function envInt(name: string, fallback: number): number {
25
28
  const raw = (process.env[name] ?? "").trim()
@@ -19,7 +19,10 @@ const SerperResponse = Schema.Struct({
19
19
  })
20
20
 
21
21
  // The /connect integration ID, and the websearch provider ID it binds to.
22
- const ID = "serper"
22
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
23
+ // vendor plugin's own entry are bare vendor names, so `serper` would collide
24
+ // with them, and with the opencode.db row keyed by integration ID.
25
+ const ID = "osp-serper"
23
26
 
24
27
  function envInt(name: string, fallback: number): number {
25
28
  const raw = (process.env[name] ?? "").trim()
@@ -40,7 +40,10 @@ const YouResponse = Schema.Struct({
40
40
  type YouHit = typeof YouResult.Type
41
41
 
42
42
  // The /connect integration ID, and the websearch provider ID it binds to.
43
- const ID = "youcom"
43
+ // The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
44
+ // vendor plugin's own entry are bare vendor names, so `youcom` would collide
45
+ // with them, and with the opencode.db row keyed by integration ID.
46
+ const ID = "osp-youcom"
44
47
 
45
48
  function envInt(name: string, fallback: number): number {
46
49
  const raw = (process.env[name] ?? "").trim()