opencode-search-plugin 3.1.0 → 3.2.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 +63 -45
- package/README.zh-CN.md +64 -46
- package/index.ts +3 -1
- package/package.json +3 -2
- package/plugins/perplexity-search.ts +332 -0
package/README.md
CHANGED
|
@@ -8,35 +8,54 @@
|
|
|
8
8
|
|
|
9
9
|
**Web search for OpenCode that works out of the box, no API key needed.**
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
AnySearch and Keenable work with no configuration at all. Other providers only activate when you add an API key, so installing this plugin will not charge you for services you do not use.
|
|
12
|
+
|
|
13
|
+
OpenCode selects providers using `random` mode: when one hits an HTTP 429 rate limit, it switches to the next available provider. Providers without keys are excluded from the pool, so `random` won't attempt to use an unconfigured service.
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
16
|
opencode plugin add opencode-search-plugin
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
Install
|
|
19
|
+
Install the plugin, then ask OpenCode any question that requires a web search.
|
|
20
|
+
|
|
21
|
+
## Skip the provider prompt
|
|
22
|
+
|
|
23
|
+
OpenCode asks to pick a search provider on your first search. You can set `websearch.provider` in your config to bypass this prompt:
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://opencode.ai/config.json",
|
|
28
|
+
"websearch": { "provider": "random" }
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`"random"` uses one provider per session, and automatically falls back to another if it gets rate-limited. You can also pin a specific provider using its ID (like `"osp-brave"`), or disable search with `false`.
|
|
18
33
|
|
|
19
|
-
<!-- TODO: replace this comment with a 10-second GIF
|
|
20
|
-
ask a question
|
|
34
|
+
<!-- TODO: replace this comment with a 10-second GIF:
|
|
35
|
+
set websearch.provider -> ask a question -> provider responds with sources -->
|
|
21
36
|
|
|
22
|
-
|
|
37
|
+
## Supported providers
|
|
23
38
|
|
|
24
|
-
[AnySearch](https://www.anysearch.com/) · [Bocha](https://bocha.ai/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Ollama](https://ollama.com/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
39
|
+
[AnySearch](https://www.anysearch.com/) · [Bocha](https://bocha.ai/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Ollama](https://ollama.com/) · [Perplexity](https://www.perplexity.ai/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
25
40
|
|
|
26
41
|
## Why this exists
|
|
27
42
|
|
|
28
|
-
OpenCode v2 ships with
|
|
43
|
+
OpenCode v2 ships with built-in search providers: `exa`, `firecrawl`, `parallel`, `tavily`, and `tinyfish`. However, many external search APIs offer free tiers or generous free quotas.
|
|
44
|
+
|
|
45
|
+
This plugin adds more providers to your pool:
|
|
29
46
|
|
|
30
|
-
|
|
47
|
+
- AnySearch and Keenable work immediately without an API key or account.
|
|
48
|
+
- Several providers offer free tiers, making it practical to maintain a backup pool.
|
|
49
|
+
- OpenCode's `random` mode needs multiple providers to handle rate limits effectively. If one provider returns an HTTP 429, OpenCode retries with another.
|
|
31
50
|
|
|
32
51
|
## Install
|
|
33
52
|
|
|
34
|
-
|
|
53
|
+
Pick whichever installation method you prefer. **npm is recommended**: published versions are immutable, so pinning with `@<version>` always gets you the exact same build.
|
|
35
54
|
|
|
36
55
|
### 1. From npm (recommended)
|
|
37
56
|
|
|
38
57
|
```sh
|
|
39
|
-
# add
|
|
58
|
+
# add @<version> to pin
|
|
40
59
|
opencode plugin add opencode-search-plugin
|
|
41
60
|
```
|
|
42
61
|
|
|
@@ -52,7 +71,7 @@ or declare it in `opencode.json(c)`:
|
|
|
52
71
|
### 2. From GitHub
|
|
53
72
|
|
|
54
73
|
```sh
|
|
55
|
-
# add #
|
|
74
|
+
# add #v<version> to pin
|
|
56
75
|
opencode plugin add github:guotree/opencode-search-plugin
|
|
57
76
|
```
|
|
58
77
|
|
|
@@ -82,9 +101,9 @@ For a location that is not auto-discovered, list it explicitly:
|
|
|
82
101
|
|
|
83
102
|
> Do not install the package *and* copy its files. Both register the same websearch provider IDs, so the pool would contain each service twice.
|
|
84
103
|
|
|
85
|
-
### Skip
|
|
104
|
+
### Skip providers you don't use
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
Installing the package registers all providers, but any provider without an API key stays inactive; see [Keys](#keys). To exclude specific providers completely, use `disable` or `enable`:
|
|
88
107
|
|
|
89
108
|
```jsonc
|
|
90
109
|
{
|
|
@@ -97,31 +116,23 @@ Loading the package registers all thirteen, but a provider without a key never j
|
|
|
97
116
|
}
|
|
98
117
|
```
|
|
99
118
|
|
|
100
|
-
- `disable
|
|
101
|
-
- `enable
|
|
119
|
+
- `disable`: a deny-list of provider IDs (from the table below). Since AnySearch and Keenable require no key, `disable` is the only way to keep them out of the pool.
|
|
120
|
+
- `enable`: an allow-list. Only the listed IDs are registered. For example, `"enable": ["osp-brave", "osp-jina"]` runs just those two.
|
|
102
121
|
|
|
103
|
-
|
|
122
|
+
If you specify both, **`disable` takes priority on any overlap**:
|
|
104
123
|
|
|
105
124
|
```jsonc
|
|
106
125
|
{
|
|
107
126
|
"options": {
|
|
108
127
|
"enable": ["osp-brave", "osp-jina", "osp-serper"],
|
|
109
128
|
"disable": ["osp-serper", "osp-youcom"]
|
|
110
|
-
// osp-serper stays
|
|
111
|
-
// osp-youcom
|
|
129
|
+
// osp-serper stays excluded: disable overrides enable.
|
|
130
|
+
// osp-youcom wasn't in enable, so this is safely ignored.
|
|
112
131
|
}
|
|
113
132
|
}
|
|
114
133
|
```
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
```text
|
|
119
|
-
enable = {osp-brave, osp-jina, osp-serper, osp-keenable, osp-linkup}
|
|
120
|
-
disable = {osp-keenable, osp-linkup, osp-youcom}
|
|
121
|
-
pool = osp-brave, osp-jina -- enable minus disable
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Configuration that cannot take effect is reported in the log rather than silently dropped: an ID this package does not register (a typo) is named, as is an option that is not an array. An `enable` list that ends up naming no known provider leaves the pool empty; the log says so.
|
|
135
|
+
The active pool is simply `enable - disable`. Overlapping IDs are dropped, and invalid or unrecognized IDs trigger a warning in the logs rather than failing silently.
|
|
125
136
|
|
|
126
137
|
## Upgrade
|
|
127
138
|
|
|
@@ -134,7 +145,7 @@ opencode plugin update github:guotree/opencode-search-plugin
|
|
|
134
145
|
opencode plugin update
|
|
135
146
|
```
|
|
136
147
|
|
|
137
|
-
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 (
|
|
148
|
+
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 (`@<version>` on npm, `#v<version>` 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.
|
|
138
149
|
|
|
139
150
|
## Uninstall
|
|
140
151
|
|
|
@@ -160,19 +171,13 @@ A provider with no key stays unbound: it is absent from the websearch pool, so `
|
|
|
160
171
|
|
|
161
172
|
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.
|
|
162
173
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
```jsonc
|
|
166
|
-
{ "websearch": { "provider": "random" } }
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
`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.
|
|
174
|
+
With keys in place, `websearch.provider` decides who answers: `"random"` spreads queries across every bound provider (set it once as in [Skip the provider prompt](#skip-the-provider-prompt)); a specific ID such as `"osp-brave"` pins one, and `false` turns web search off.
|
|
170
175
|
|
|
171
176
|
## Providers
|
|
172
177
|
|
|
173
|
-
|
|
178
|
+
Each ID below carries the `osp-` prefix to avoid colliding with OpenCode's built-in providers or official vendor plugins (which use bare names like `exa` or `tavily`). Use the prefixed ID in `websearch.provider` and in `enable`/`disable`.
|
|
174
179
|
|
|
175
|
-
|
|
180
|
+
In OpenCode's `/connect` settings, each provider is labeled with an `(osp)` suffix (e.g. `DeepSeek (osp)`) so you can tell them apart from built-in integrations. The search pool itself displays the clean provider name without the suffix.
|
|
176
181
|
|
|
177
182
|
| Provider | ID | Key (env / `/connect`) | Anonymous | Notes |
|
|
178
183
|
| --- | --- | --- | --- | --- |
|
|
@@ -185,6 +190,7 @@ The namespace also shows on the display name: `/connect` lists each of this pack
|
|
|
185
190
|
| Linkup | `osp-linkup` | `OSP_LINKUP_API_KEY` | no | `flash`/`fast`/`standard`/`deep` reasoning depth |
|
|
186
191
|
| Metaso | `osp-metaso` | `OSP_METASO_API_KEY` | no | Billed per search, not per result |
|
|
187
192
|
| Ollama | `osp-ollama` | `OSP_OLLAMA_API_KEY` | no | Free personal tier; each hit carries the full page as markdown, so results are context-heavy, capped at 10, and carry no dates |
|
|
193
|
+
| Perplexity | `osp-perplexity` | `OSP_PERPLEXITY_API_KEY` | no | Search API returns bare results with no generated answer to strip; billed per request, $5.00 per 1,000 and $1.00 with `fast` |
|
|
188
194
|
| Querit | `osp-querit` | `OSP_QUERIT_API_KEY` | no | Falls back to sentence matches when a snippet is missing |
|
|
189
195
|
| SerpApi | `osp-serpapi` | `OSP_SERPAPI_API_KEY` | no | Google via SerpApi; also has its own free 1-hour server cache |
|
|
190
196
|
| Serper | `osp-serper` | `OSP_SERPER_API_KEY` | no | Google via Serper; billed per query in credits |
|
|
@@ -192,9 +198,9 @@ The namespace also shows on the display name: `/connect` lists each of this pack
|
|
|
192
198
|
|
|
193
199
|
## Configuration
|
|
194
200
|
|
|
195
|
-
|
|
201
|
+
Configuration options are read as environment variables on startup. If you change a setting, restart OpenCode for it to take effect. The defaults reflect what each API recommends.
|
|
196
202
|
|
|
197
|
-
### Shared by
|
|
203
|
+
### Shared by every provider
|
|
198
204
|
|
|
199
205
|
| Variable | Default | Meaning |
|
|
200
206
|
| --- | --- | --- |
|
|
@@ -274,6 +280,18 @@ Knobs are environment variables, read once when OpenCode starts; change one and
|
|
|
274
280
|
| --- | --- | --- |
|
|
275
281
|
| `OLLAMA_MAX_RESULTS` | `5` | Results per search, clamped to 1–10 (the API's maximum). `content` is a whole page of markdown rather than a snippet, so even the default 5 results run to thousands of characters of context |
|
|
276
282
|
|
|
283
|
+
### Perplexity
|
|
284
|
+
|
|
285
|
+
| Variable | Default | Meaning |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `PERPLEXITY_MAX_RESULTS` | `10` | Results per search, clamped to 1–20. Web search tops out at 20; only `search_type=people` accepts up to 50 |
|
|
288
|
+
| `PERPLEXITY_SEARCH_TYPE` | — | `web` (the API's own default, $5.00 per 1,000 requests), `fast` ($1.00, same response shape, lower latency) or `people`; unset omits the field |
|
|
289
|
+
| `PERPLEXITY_RECENCY` | — | `hour`, `day`, `week`, `month`, or `year` |
|
|
290
|
+
| `PERPLEXITY_COUNTRY` | — | Two-letter ISO 3166-1 country code, e.g. `US` |
|
|
291
|
+
| `PERPLEXITY_LANGUAGE` | — | ISO 639-1 codes, comma-separated, e.g. `en,zh`. At most 10 are sent: the spec allows 20 where the quickstart warns of 10, and this honours the stricter of the two |
|
|
292
|
+
|
|
293
|
+
There is deliberately no domain or source knob: the API has no news, Reddit or academic switch, and its docs reach those verticals through `search_domain_filter` allowlists, which is a per-query decision rather than an installation-wide one.
|
|
294
|
+
|
|
277
295
|
### Querit
|
|
278
296
|
|
|
279
297
|
| Variable | Default | Meaning |
|
|
@@ -308,13 +326,13 @@ There is deliberately no result-count knob: Google withdrew the `num` parameter,
|
|
|
308
326
|
|
|
309
327
|
## How a query flows
|
|
310
328
|
|
|
311
|
-
1. OpenCode
|
|
312
|
-
2. The normalized query (trimmed
|
|
313
|
-
3. On a miss the key is resolved
|
|
314
|
-
4. The request goes out
|
|
315
|
-
5. The response is schema-
|
|
329
|
+
1. OpenCode requests a search from the active provider.
|
|
330
|
+
2. The normalized query (trimmed and whitespace-collapsed) is checked against an in-memory cache. Cache hits return immediately without making an API request.
|
|
331
|
+
3. On a cache miss, the API key is resolved fresh, so rotated keys in `/connect` take effect right away.
|
|
332
|
+
4. The request goes out with an abort deadline (`<PROVIDER>_TIMEOUT`, default 25s) to prevent stalled connections from hanging the session.
|
|
333
|
+
5. The response is schema-validated, mapped to `{ url, title, content, time }`, and cached for repeat queries.
|
|
316
334
|
|
|
317
|
-
|
|
335
|
+
Relative dates (`3 hours ago`, `3小时前`), absolute dates (`Sep 30, 2026`, `31 Mar 2026`), and ISO 8601 timestamps are parsed automatically. Incomplete strings that might mislead lenient parsers (like `Nov 1`) are ignored instead of guessing an incorrect year.
|
|
318
336
|
|
|
319
337
|
## Troubleshooting
|
|
320
338
|
|
package/README.zh-CN.md
CHANGED
|
@@ -8,35 +8,54 @@
|
|
|
8
8
|
|
|
9
9
|
**不用 API key,装上就能搜。**
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
AnySearch 和 Keenable 默认开箱即用,无需任何配置。其余 provider 只有在填入对应 API key 后才会激活,不用担心产生意外账单。
|
|
12
|
+
|
|
13
|
+
配合 OpenCode 的 `random` 轮询模式,当某家遭遇 HTTP 429 限流时,会自动无缝切换至下一家可用服务。未配置 key 的 provider 不会进入候选池,彻底避免路由落空。
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
16
|
opencode plugin add opencode-search-plugin
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
装好后,直接向 OpenCode 提问即可联网检索。
|
|
20
|
+
|
|
21
|
+
## 省掉选 provider 的弹窗
|
|
22
|
+
|
|
23
|
+
初次搜索时,OpenCode 默认会弹出提示让你挑选 provider。在配置文件中加上 `websearch.provider` 即可跳过询问:
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://opencode.ai/config.json",
|
|
28
|
+
"websearch": { "provider": "random" }
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`"random"` 会在会话内固定使用同一家,遇到限流自动顺延换家。若想固定指定渠道,填写对应 ID 即可(如 `"osp-brave"`);设为 `false` 则彻底停用联网搜索。
|
|
18
33
|
|
|
19
|
-
<!-- TODO: 录一段 10 秒 GIF
|
|
20
|
-
|
|
34
|
+
<!-- TODO: 录一段 10 秒 GIF:
|
|
35
|
+
配置 websearch.provider -> 提问 -> provider 正常应答带引用 -->
|
|
21
36
|
|
|
22
|
-
|
|
37
|
+
## 支持的渠道
|
|
23
38
|
|
|
24
|
-
[AnySearch](https://www.anysearch.com/) · [Bocha](https://bocha.ai/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Ollama](https://ollama.com/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
39
|
+
[AnySearch](https://www.anysearch.com/) · [Bocha](https://bocha.ai/) · [Brave Search](https://brave.com/search/api/) · [DeepSeek](https://api-docs.deepseek.com/guides/search/) · [Jina](https://jina.ai/) · [Keenable](https://keenable.ai/) · [Linkup](https://www.linkup.so/) · [Metaso](https://metaso.cn/) · [Ollama](https://ollama.com/) · [Perplexity](https://www.perplexity.ai/) · [Querit](https://querit.ai/) · [SerpApi](https://serpapi.com/) · [Serper](https://serper.dev/) · [You.com](https://you.com/)
|
|
25
40
|
|
|
26
41
|
## 为什么做这个插件
|
|
27
42
|
|
|
28
|
-
OpenCode v2
|
|
43
|
+
OpenCode v2 本身内置了几个官方搜索渠道:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。除此之外,市面上还有不少提供免费额度的搜索 API。
|
|
44
|
+
|
|
45
|
+
本插件为这些服务补齐了适配:
|
|
29
46
|
|
|
30
|
-
|
|
47
|
+
- AnySearch 和 Keenable 支持免 key 匿名直接搜索。
|
|
48
|
+
- 部分服务附带免费调用额度,便于低成本搭建备用轮询池。
|
|
49
|
+
- 扩充 OpenCode `random` 模式的备选池。单家触发 HTTP 429 时,能够自动换家重试,降低搜索中断的几率。
|
|
31
50
|
|
|
32
51
|
## 安装
|
|
33
52
|
|
|
34
|
-
|
|
53
|
+
支持三种安装方式,选适合你的就行。**推荐 npm**:发布到 npm 的版本不可变,用 `@<version>` 锁定后始终能拿到一致的文件。
|
|
35
54
|
|
|
36
55
|
### 1. 从 npm 安装(推荐)
|
|
37
56
|
|
|
38
57
|
```sh
|
|
39
|
-
# 追加
|
|
58
|
+
# 追加 @<version> 可固定到该版本
|
|
40
59
|
opencode plugin add opencode-search-plugin
|
|
41
60
|
```
|
|
42
61
|
|
|
@@ -52,7 +71,7 @@ opencode plugin add opencode-search-plugin
|
|
|
52
71
|
### 2. 从 GitHub 安装
|
|
53
72
|
|
|
54
73
|
```sh
|
|
55
|
-
# 追加 #
|
|
74
|
+
# 追加 #v<version> 可固定到该 tag
|
|
56
75
|
opencode plugin add github:guotree/opencode-search-plugin
|
|
57
76
|
```
|
|
58
77
|
|
|
@@ -82,9 +101,9 @@ opencode plugin add github:guotree/opencode-search-plugin
|
|
|
82
101
|
|
|
83
102
|
> **不要既装包、又复制它的文件。** 两者注册的是同一批 websearch provider ID,搜索池里每家服务会出现两次。
|
|
84
103
|
|
|
85
|
-
###
|
|
104
|
+
### 过滤不需要的渠道
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
装包后所有 provider 都会注册,但没有配置 key 的渠道会保持未激活状态(见[密钥](#密钥))。如果希望彻底排除某些渠道,可以使用 `disable` 或 `enable`:
|
|
88
107
|
|
|
89
108
|
```jsonc
|
|
90
109
|
{
|
|
@@ -97,31 +116,23 @@ opencode plugin add github:guotree/opencode-search-plugin
|
|
|
97
116
|
}
|
|
98
117
|
```
|
|
99
118
|
|
|
100
|
-
- `disable
|
|
101
|
-
- `enable`
|
|
119
|
+
- `disable`:黑名单。AnySearch 和 Keenable 默认免 key,如果不想让它们进入搜索池,只能通过 `disable` 排除。
|
|
120
|
+
- `enable`:白名单。只注册指定的渠道,例如 `"enable": ["osp-brave", "osp-jina"]` 仅保留这两家。
|
|
102
121
|
|
|
103
|
-
|
|
122
|
+
如果两者同时配置,**`disable` 优先**:
|
|
104
123
|
|
|
105
124
|
```jsonc
|
|
106
125
|
{
|
|
107
126
|
"options": {
|
|
108
127
|
"enable": ["osp-brave", "osp-jina", "osp-serper"],
|
|
109
128
|
"disable": ["osp-serper", "osp-youcom"]
|
|
110
|
-
// osp-serper
|
|
111
|
-
// osp-youcom
|
|
129
|
+
// osp-serper 不会注册:disable 覆盖 enable。
|
|
130
|
+
// osp-youcom 本来就不在 enable 中,会被静默忽略。
|
|
112
131
|
}
|
|
113
132
|
}
|
|
114
133
|
```
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
```text
|
|
119
|
-
enable = {osp-brave, osp-jina, osp-serper, osp-keenable, osp-linkup}
|
|
120
|
-
disable = {osp-keenable, osp-linkup, osp-youcom}
|
|
121
|
-
pool = osp-brave, osp-jina -- enable 减 disable
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
无法生效的配置会在日志里提示,而不是被悄悄丢弃:写错的 ID(本包没注册的名字)会被点名,选项不是数组也会被点名。`enable` 如果最终一个已知 provider 都没匹配上,搜索池会是空的,日志里会写明这一点。
|
|
135
|
+
实际生效的渠道等于 `enable - disable`。重叠项会被自动剔除;如果配置了不存在的 ID 或格式错误,日志会给出明确提示,不会静默失效。
|
|
125
136
|
|
|
126
137
|
## 升级
|
|
127
138
|
|
|
@@ -134,7 +145,7 @@ opencode plugin update github:guotree/opencode-search-plugin
|
|
|
134
145
|
opencode plugin update
|
|
135
146
|
```
|
|
136
147
|
|
|
137
|
-
升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式,用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的会停在原地(npm 形式是
|
|
148
|
+
升级目标用的是配置时的说明符:npm 安装用包名,GitHub 安装只认自己的 `github:` 形式,用裸包名会报 `Plugin is not configured`。先跑 `opencode plugin check` 查看有哪些过期。未固定版本的安装会跟上新版本;固定过的会停在原地(npm 形式是 `@<version>`,GitHub 形式是 `#v<version>`),直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件用新版本覆盖旧文件即可。
|
|
138
149
|
|
|
139
150
|
## 卸载
|
|
140
151
|
|
|
@@ -160,19 +171,13 @@ opencode plugin remove github:guotree/opencode-search-plugin
|
|
|
160
171
|
|
|
161
172
|
AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册,只有存在 key 时才附带各自的凭据头。Keenable 的两层额度互不通用:有 key 时消耗每月 100,000 次的免费额度,无 key 的请求则改走 `/public` 端点,按速率限制:每个 IP 每小时 1,000 次、每秒 10 次,且与同一出口地址上的所有人共用。想摆脱这个共享池的不确定性,就得配一把 key。
|
|
162
173
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
```jsonc
|
|
166
|
-
{ "websearch": { "provider": "random" } }
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
`random` 会把查询分散到所有已绑定的 provider。填具体 ID(例如 `"osp-brave"`)则固定用某一家,填 `false` 则完全关闭网页搜索。
|
|
174
|
+
密钥就位后,由 `websearch.provider` 决定谁来应答:`"random"` 会把查询分散到所有已绑定的 provider(按上文[省掉选 provider 的弹窗](#省掉选-provider-的弹窗)设一次即可),填具体 ID(例如 `"osp-brave"`)则固定用某一家,填 `false` 则完全关闭网页搜索。
|
|
170
175
|
|
|
171
176
|
## 支持的服务
|
|
172
177
|
|
|
173
|
-
|
|
178
|
+
下表中的 ID 均带有 `osp-` 前缀,用以避免与 OpenCode 内置渠道或官方插件命名冲突(它们使用的是 `exa`、`tavily` 这类直接名字)。在 `websearch.provider` 和 `enable`/`disable` 中请使用带前缀的完整 ID。
|
|
174
179
|
|
|
175
|
-
|
|
180
|
+
在 OpenCode 的 `/connect` 界面中,本插件的条目会带有 `(osp)` 后缀(如 `DeepSeek (osp)`),方便与内置同名服务区分。搜索池内部仍会使用干净的原始名称。
|
|
176
181
|
|
|
177
182
|
| 服务 | ID | 密钥(环境变量 / `/connect`) | 匿名可用 | 说明 |
|
|
178
183
|
| --- | --- | --- | --- | --- |
|
|
@@ -185,6 +190,7 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
185
190
|
| Linkup | `osp-linkup` | `OSP_LINKUP_API_KEY` | 否 | `flash` / `fast` / `standard` / `deep` 四档推理深度 |
|
|
186
191
|
| Metaso | `osp-metaso` | `OSP_METASO_API_KEY` | 否 | 按次搜索计费,不按结果条数 |
|
|
187
192
|
| Ollama | `osp-ollama` | `OSP_OLLAMA_API_KEY` | 否 | 个人档免费;每条 content 是整页 markdown,上下文偏重;上限 10 条,且不带日期 |
|
|
193
|
+
| Perplexity | `osp-perplexity` | `OSP_PERPLEXITY_API_KEY` | 否 | Search API 直接返回裸搜索结果,不夹带生成的问答正文;按请求计费,每千次 5 美元,用 `fast` 则 1 美元 |
|
|
188
194
|
| Querit | `osp-querit` | `OSP_QUERIT_API_KEY` | 否 | 摘要缺失时回退到句子级匹配 |
|
|
189
195
|
| SerpApi | `osp-serpapi` | `OSP_SERPAPI_API_KEY` | 否 | 经 SerpApi 调用 Google;另有其自带的免费 1 小时服务端缓存 |
|
|
190
196
|
| Serper | `osp-serper` | `OSP_SERPER_API_KEY` | 否 | 经 Serper 调用 Google;按查询消耗积分计费 |
|
|
@@ -192,9 +198,9 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
192
198
|
|
|
193
199
|
## 配置
|
|
194
200
|
|
|
195
|
-
|
|
201
|
+
配置项以环境变量形式在启动时加载。如修改了变量,重启 OpenCode 即可生效。默认值均对齐各家 API 文档推荐,通常保持默认即可。
|
|
196
202
|
|
|
197
|
-
###
|
|
203
|
+
### 所有 provider 通用
|
|
198
204
|
|
|
199
205
|
| 变量 | 默认值 | 含义 |
|
|
200
206
|
| --- | --- | --- |
|
|
@@ -274,6 +280,18 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
274
280
|
| --- | --- | --- |
|
|
275
281
|
| `OLLAMA_MAX_RESULTS` | `5` | 每次搜索的结果数,限制在 1–10(API 的上限)。`content` 是整页 markdown 而非 snippet,即使默认 5 条也是数千字符的上下文 |
|
|
276
282
|
|
|
283
|
+
### Perplexity
|
|
284
|
+
|
|
285
|
+
| 变量 | 默认值 | 含义 |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `PERPLEXITY_MAX_RESULTS` | `10` | 每次搜索的结果数,限制在 1–20。网页搜索最多 20 条,只有 `search_type=people` 才接受到 50 条 |
|
|
288
|
+
| `PERPLEXITY_SEARCH_TYPE` | — | `web`(API 自身的默认值,每千次 5 美元)、`fast`(1 美元,响应结构相同、延迟更低)或 `people`;不设即不传该字段 |
|
|
289
|
+
| `PERPLEXITY_RECENCY` | — | `hour`、`day`、`week`、`month` 或 `year` |
|
|
290
|
+
| `PERPLEXITY_COUNTRY` | — | 两位 ISO 3166-1 国家代码,例如 `US` |
|
|
291
|
+
| `PERPLEXITY_LANGUAGE` | — | ISO 639-1 语言代码,用英文逗号分隔,例如 `en,zh`。最多只发 10 个:规范写的是 20,快速上手却提示 10,这里按两者中更严的那个来 |
|
|
292
|
+
|
|
293
|
+
这里**刻意没有**域名或信源配置项:该 API 没有新闻、Reddit 或学术的独立开关,官方文档是靠 `search_domain_filter` 白名单去够到这些垂直领域的,而那是单次查询该决定的事,不是装完之后一锤定音的事。
|
|
294
|
+
|
|
277
295
|
### Querit
|
|
278
296
|
|
|
279
297
|
| 变量 | 默认值 | 含义 |
|
|
@@ -306,15 +324,15 @@ AnySearch 和 Keenable 是例外。两家都有匿名层级,因此始终注册
|
|
|
306
324
|
| `YOUCOM_LANGUAGE` / `YOUCOM_COUNTRY` | — | 例如 `EN` / `US` |
|
|
307
325
|
| `YOUCOM_FRESHNESS` | — | `day`、`week`、`month`、`year`,或 `YYYY-MM-DDtoYYYY-MM-DD` |
|
|
308
326
|
|
|
309
|
-
##
|
|
327
|
+
## 查询处理流程
|
|
310
328
|
|
|
311
|
-
1. OpenCode
|
|
312
|
-
2.
|
|
313
|
-
3.
|
|
314
|
-
4.
|
|
315
|
-
5.
|
|
329
|
+
1. OpenCode 向当前分配的 provider 发起搜索。
|
|
330
|
+
2. 归一化后的搜索词(去除首尾空白并合并多余空格)优先匹配内存缓存。命中的查询直接返回,不产生外部调用。
|
|
331
|
+
3. 未命中缓存时,重新解析 API key,确保在 `/connect` 中轮换的密钥即时生效。
|
|
332
|
+
4. 请求发出时附带超时截断(`<PROVIDER>_TIMEOUT`,默认 25s),避免上游无响应挂住会话。
|
|
333
|
+
5. 返回的数据经过 schema 校验,映射为 `{ url, title, content, time }` 并写入缓存。
|
|
316
334
|
|
|
317
|
-
|
|
335
|
+
时间解析支持相对时间(`3 hours ago`、`3小时前`)、绝对格式(`Sep 30, 2026`、`31 Mar 2026`)和 ISO 8601。不完整的日期文本(如缺少年份的 `Nov 1`)会被直接忽略,避免宽容解析器推导错误年份。
|
|
318
336
|
|
|
319
337
|
## 排错
|
|
320
338
|
|
package/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { effect as keenable } from "./plugins/keenable-search"
|
|
|
11
11
|
import { effect as linkup } from "./plugins/linkup-search"
|
|
12
12
|
import { effect as metaso } from "./plugins/metaso-search"
|
|
13
13
|
import { effect as ollama } from "./plugins/ollama-search"
|
|
14
|
+
import { effect as perplexity } from "./plugins/perplexity-search"
|
|
14
15
|
import { effect as querit } from "./plugins/querit-search"
|
|
15
16
|
import { effect as serpapi } from "./plugins/serpapi-search"
|
|
16
17
|
import { effect as serper } from "./plugins/serper-search"
|
|
@@ -19,7 +20,7 @@ import { effect as youcom } from "./plugins/youcom-search"
|
|
|
19
20
|
type ProviderEffect = (ctx: Plugin.Context) => Effect.Effect<void, never, Scope.Scope>
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* The
|
|
23
|
+
* The fourteen providers this package registers, in alphabetical order. Each entry
|
|
23
24
|
* is the same effect its standalone file uses as a default export, so a file
|
|
24
25
|
* copied out of this repository behaves exactly as it does here.
|
|
25
26
|
*
|
|
@@ -38,6 +39,7 @@ const PROVIDERS: ReadonlyArray<readonly [id: string, run: ProviderEffect]> = [
|
|
|
38
39
|
["osp-linkup", linkup],
|
|
39
40
|
["osp-metaso", metaso],
|
|
40
41
|
["osp-ollama", ollama],
|
|
42
|
+
["osp-perplexity", perplexity],
|
|
41
43
|
["osp-querit", querit],
|
|
42
44
|
["osp-serpapi", serpapi],
|
|
43
45
|
["osp-serper", serper],
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-search-plugin",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "Fourteen self-contained websearch provider plugins for OpenCode v2: AnySearch, Bocha, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Ollama, Perplexity, Querit, SerpApi, Serper and You.com.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./linkup-search": "./plugins/linkup-search.ts",
|
|
32
32
|
"./metaso-search": "./plugins/metaso-search.ts",
|
|
33
33
|
"./ollama-search": "./plugins/ollama-search.ts",
|
|
34
|
+
"./perplexity-search": "./plugins/perplexity-search.ts",
|
|
34
35
|
"./querit-search": "./plugins/querit-search.ts",
|
|
35
36
|
"./serpapi-search": "./plugins/serpapi-search.ts",
|
|
36
37
|
"./serper-search": "./plugins/serper-search.ts",
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import { Effect, Schema, Stream } from "effect"
|
|
3
|
+
|
|
4
|
+
// Perplexity's Search API answers with the bare ranked pages an agent reads:
|
|
5
|
+
// `results[]` of {title, url, snippet, date, last_updated}, with no generated
|
|
6
|
+
// prose to strip. That is what separates it from the vendor's Agent API
|
|
7
|
+
// (`/v1/agent`, which answers in prose) and the Sonar API it retired in
|
|
8
|
+
// September 2026. Every field except `url` is optional -- a missing one means
|
|
9
|
+
// "the source provided none", not an error -- and `snippet` is documented as
|
|
10
|
+
// "" for PDFs and short pages, which the mapping below tolerates rather than
|
|
11
|
+
// dropping the row over.
|
|
12
|
+
const PerplexityPage = Schema.Struct({
|
|
13
|
+
title: Schema.optional(Schema.String),
|
|
14
|
+
url: Schema.String,
|
|
15
|
+
snippet: Schema.optional(Schema.String),
|
|
16
|
+
date: Schema.optional(Schema.NullOr(Schema.String)),
|
|
17
|
+
last_updated: Schema.optional(Schema.NullOr(Schema.String)),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The envelope carries a required `id`, a `server_time` the quickstart shows
|
|
21
|
+
// as null, and -- in some documented examples only -- a `query_info` object
|
|
22
|
+
// absent from the published schema. None of them is read here; the fields
|
|
23
|
+
// this provider needs are the ones declared above.
|
|
24
|
+
const PerplexityResponse = Schema.Struct({
|
|
25
|
+
results: Schema.optional(Schema.Array(PerplexityPage)),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// The /connect integration ID, and the websearch provider ID it binds to.
|
|
29
|
+
// The `osp-` prefix is this package's namespace: OpenCode's built-ins and a
|
|
30
|
+
// vendor plugin's own entry are bare vendor names, so `perplexity` would
|
|
31
|
+
// collide with them, and with the opencode.db row keyed by integration ID.
|
|
32
|
+
const ID = "osp-perplexity"
|
|
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
|
+
// Perplexity stamps both of its date fields as `YYYY-MM-DD`, which the parser
|
|
46
|
+
// below accepts as-is. The relative-date tables still ship byte-identical with
|
|
47
|
+
// the other providers in this package -- the parser comes from the same shared
|
|
48
|
+
// block, and `test/structure.test.ts` compares it byte for byte.
|
|
49
|
+
// See https://serpapi.com/blog/how-to-parse-dates-from-google-search/
|
|
50
|
+
const RELATIVE_MS: Record<string, number> = {
|
|
51
|
+
minute: 60_000,
|
|
52
|
+
hour: 3_600_000,
|
|
53
|
+
day: 86_400_000,
|
|
54
|
+
week: 604_800_000,
|
|
55
|
+
month: 2_592_000_000,
|
|
56
|
+
year: 31_536_000_000,
|
|
57
|
+
分钟: 60_000,
|
|
58
|
+
小时: 3_600_000,
|
|
59
|
+
天: 86_400_000,
|
|
60
|
+
周: 604_800_000,
|
|
61
|
+
个月: 2_592_000_000,
|
|
62
|
+
月: 2_592_000_000,
|
|
63
|
+
年: 31_536_000_000,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RELATIVE_DATE =
|
|
67
|
+
/^(\d+)\s*(minute|hour|day|week|month|year|分钟|小时|天|周|个月|月|年)s?\s*(?:ago|前)$/i
|
|
68
|
+
|
|
69
|
+
const ABSOLUTE_DATE = [
|
|
70
|
+
/^\d{1,2}\s+[A-Za-z]{3,9}\s+\d{4}$/, // "3 Jan 2022", "31 Mar 2026"
|
|
71
|
+
/^[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}$/, // "Sep 30, 2026", "Jan 27, 2025"
|
|
72
|
+
/^\d{4}-\d{2}-\d{2}$/, // "2026-10-16"
|
|
73
|
+
// ISO 8601 with a time component, which some providers return verbatim.
|
|
74
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
function parseSearchDate(value: string | undefined): number {
|
|
78
|
+
const text = value?.trim()
|
|
79
|
+
if (!text) return NaN
|
|
80
|
+
|
|
81
|
+
const relative = RELATIVE_DATE.exec(text)
|
|
82
|
+
if (relative) {
|
|
83
|
+
const unit = relative[2] !== undefined ? RELATIVE_MS[relative[2].toLowerCase()] : undefined
|
|
84
|
+
const amount = Number(relative[1])
|
|
85
|
+
if (unit === undefined || !Number.isFinite(amount)) return NaN
|
|
86
|
+
return Date.now() - amount * unit
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Only trust Date.parse on shapes these providers actually emit -- its lenient
|
|
90
|
+
// parser invents a year for strings like "Nov 1", which would date the result
|
|
91
|
+
// to 2001.
|
|
92
|
+
return ABSOLUTE_DATE.some((shape) => shape.test(text)) ? Date.parse(text) : NaN
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toResult(item: {
|
|
96
|
+
title?: string | undefined
|
|
97
|
+
url: string
|
|
98
|
+
snippet?: string | undefined
|
|
99
|
+
date?: string | null | undefined
|
|
100
|
+
last_updated?: string | null | undefined
|
|
101
|
+
}) {
|
|
102
|
+
// `date` is the publication date and `last_updated` the page's own revision;
|
|
103
|
+
// both answer "when is this from", and OpenCode's result carries a single
|
|
104
|
+
// slot, so the update date stands in when the page declares no publication
|
|
105
|
+
// date. A page that declares neither simply reports no time.
|
|
106
|
+
const published = parseSearchDate(item.date ?? item.last_updated ?? undefined)
|
|
107
|
+
return {
|
|
108
|
+
url: item.url,
|
|
109
|
+
// `url` is the one field the schema insists on, so it is also the one thing
|
|
110
|
+
// a title can fall back to rather than leave the result untitled.
|
|
111
|
+
title: item.title || item.url,
|
|
112
|
+
// `||`, not `??`: an empty snippet means Perplexity extracted nothing from
|
|
113
|
+
// the page (its own domain-filter example returns "" for every PDF), and an
|
|
114
|
+
// empty string still leaves a result worth reading for its link.
|
|
115
|
+
content: item.snippet || "",
|
|
116
|
+
time: Number.isFinite(published) ? { published } : {},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type SearchResult = ReturnType<typeof toResult>
|
|
121
|
+
|
|
122
|
+
// Perplexity charges per successful request, not per token: $5.00 per 1,000
|
|
123
|
+
// requests for `web`, $1.00 for `fast`. A repeat query is a fresh billed
|
|
124
|
+
// dispatch, so responses are cached per normalized query. Knobs are read once
|
|
125
|
+
// at load time, restart after changing them.
|
|
126
|
+
//
|
|
127
|
+
// PERPLEXITY_CACHE_TTL ms to reuse a response for the same query (0 disables)
|
|
128
|
+
// PERPLEXITY_MAX_RESULTS results per search, clamped to 1-20
|
|
129
|
+
// PERPLEXITY_SEARCH_TYPE "web" (the API's default), "fast" (a fifth of the
|
|
130
|
+
// price, same response shape) or "people"; unset
|
|
131
|
+
// omits the field and lets the API default apply
|
|
132
|
+
// PERPLEXITY_RECENCY "hour", "day", "week", "month" or "year"
|
|
133
|
+
// PERPLEXITY_COUNTRY ISO 3166-1 alpha-2 code, e.g. "US"
|
|
134
|
+
// PERPLEXITY_LANGUAGE ISO 639-1 codes, comma-separated; at most 10 are
|
|
135
|
+
// sent, the stricter of the two limits the docs state
|
|
136
|
+
// PERPLEXITY_TIMEOUT ms to wait before failing (0 removes the deadline)
|
|
137
|
+
// OSP_PERPLEXITY_API_KEY required; from https://console.perplexity.ai/project/keys
|
|
138
|
+
const CACHE_TTL = envInt("PERPLEXITY_CACHE_TTL", 600_000)
|
|
139
|
+
// OpenCode's own providers each bounded themselves at 25s, and the host adds
|
|
140
|
+
// no deadline of its own around `execute`, so this is the only one that exists.
|
|
141
|
+
const SEARCH_TIMEOUT = envInt("PERPLEXITY_TIMEOUT", 25_000)
|
|
142
|
+
// Web search takes at most 20 (`max_results` above 20 requires `search_type=
|
|
143
|
+
// people`), so the clamp stays inside what the default search type accepts.
|
|
144
|
+
const SEARCH_SIZE = Math.min(Math.max(envInt("PERPLEXITY_MAX_RESULTS", 10), 1), 20)
|
|
145
|
+
const SEARCH_TYPE = ["web", "fast", "people"].includes(envStr("PERPLEXITY_SEARCH_TYPE"))
|
|
146
|
+
? envStr("PERPLEXITY_SEARCH_TYPE")
|
|
147
|
+
: ""
|
|
148
|
+
const RECENCY = ["hour", "day", "week", "month", "year"].includes(
|
|
149
|
+
envStr("PERPLEXITY_RECENCY"),
|
|
150
|
+
)
|
|
151
|
+
? envStr("PERPLEXITY_RECENCY")
|
|
152
|
+
: ""
|
|
153
|
+
const COUNTRY = envStr("PERPLEXITY_COUNTRY")
|
|
154
|
+
// The spec allows 20 codes where the quickstart warns of 10; the stricter of
|
|
155
|
+
// the two documented limits is the one enforced here, so a request built from
|
|
156
|
+
// this knob cannot be the one that trips validation.
|
|
157
|
+
const LANGUAGE = envStr("PERPLEXITY_LANGUAGE")
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((code) => code.trim())
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.slice(0, 10)
|
|
162
|
+
|
|
163
|
+
const CACHE_LIMIT = 200
|
|
164
|
+
const cache = new Map<string, { at: number; results: SearchResult[] }>()
|
|
165
|
+
|
|
166
|
+
function cacheKey(query: string): string {
|
|
167
|
+
return query.trim().replace(/\s+/g, " ")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readCache(queryKey: string): SearchResult[] | undefined {
|
|
171
|
+
if (CACHE_TTL <= 0) return undefined
|
|
172
|
+
const hit = cache.get(queryKey)
|
|
173
|
+
return hit && Date.now() - hit.at < CACHE_TTL ? hit.results : undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeCache(queryKey: string, results: SearchResult[]): void {
|
|
177
|
+
if (CACHE_TTL <= 0) return
|
|
178
|
+
cache.set(queryKey, { at: Date.now(), results })
|
|
179
|
+
if (cache.size > CACHE_LIMIT) {
|
|
180
|
+
const oldest = cache.keys().next().value
|
|
181
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A hung upstream must not hang the agent: the deadline bounds it, and the
|
|
186
|
+
// thunk's signal carries host cancellation into the socket. `timeout <= 0`
|
|
187
|
+
// removes the deadline but never the cancellation, because
|
|
188
|
+
// `AbortSignal.timeout(0)` aborts instantly rather than "never".
|
|
189
|
+
function timedSignal(signal: AbortSignal, timeout: number): AbortSignal {
|
|
190
|
+
return timeout > 0 ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const effect = (ctx: Plugin.Context) =>
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
// Make "Perplexity" show up in /connect with an API-key method.
|
|
196
|
+
yield* ctx.integration.transform((editor) => {
|
|
197
|
+
editor.update(ID, (integration) => {
|
|
198
|
+
integration.name = "Perplexity (osp)"
|
|
199
|
+
})
|
|
200
|
+
editor.method.update({
|
|
201
|
+
integrationID: ID,
|
|
202
|
+
method: { type: "key", label: "Perplexity API key" },
|
|
203
|
+
})
|
|
204
|
+
editor.method.update({
|
|
205
|
+
integrationID: ID,
|
|
206
|
+
method: { type: "env", names: ["OSP_PERPLEXITY_API_KEY"] },
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// The host instantiates this API's failure channel as `unknown`, which would
|
|
211
|
+
// make the whole plugin effect unrunnable. A credential read must never take
|
|
212
|
+
// the plugin down: a failed read degrades to "no stored key", and the
|
|
213
|
+
// environment fallback below applies exactly as it does when the credential
|
|
214
|
+
// simply does not exist.
|
|
215
|
+
const soften = <A>(read: Effect.Effect<A, unknown>): Effect.Effect<A | undefined, never> =>
|
|
216
|
+
read.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
217
|
+
|
|
218
|
+
// The key comes from the /connect credential, falling back to the
|
|
219
|
+
// environment. Resolved per call so a rotated key needs no restart.
|
|
220
|
+
const resolveKey = () =>
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
const connection = yield* soften(ctx.integration.connection.active(ID))
|
|
223
|
+
const credential = connection
|
|
224
|
+
? yield* soften(ctx.integration.connection.resolve(connection))
|
|
225
|
+
: undefined
|
|
226
|
+
return credential?.type === "key" ? credential.key : process.env.OSP_PERPLEXITY_API_KEY
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Perplexity has no anonymous tier -- `POST /search` answers 401 without a
|
|
230
|
+
// key -- so it is bound to websearch only while a key exists. Deleting the
|
|
231
|
+
// key unbinds the provider, so `random` can never pick a keyless Perplexity.
|
|
232
|
+
// The transform replays on reload and reads `bound` as it stands then.
|
|
233
|
+
let bound = Boolean(yield* resolveKey())
|
|
234
|
+
|
|
235
|
+
yield* ctx.websearch.transform((editor) => {
|
|
236
|
+
if (!bound) return
|
|
237
|
+
editor.add({
|
|
238
|
+
id: ID,
|
|
239
|
+
name: "Perplexity",
|
|
240
|
+
execute: ({ query }) =>
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const queryKey = cacheKey(query)
|
|
243
|
+
const cached = readCache(queryKey)
|
|
244
|
+
if (cached) return cached
|
|
245
|
+
|
|
246
|
+
// Rechecked per call: binding can lag a key deletion by the
|
|
247
|
+
// event round-trip, so this keeps that window explicit.
|
|
248
|
+
const key = yield* resolveKey()
|
|
249
|
+
if (!key) {
|
|
250
|
+
return yield* Effect.fail(
|
|
251
|
+
new Error("Connect Perplexity in /connect, or set OSP_PERPLEXITY_API_KEY"),
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const response = yield* Effect.tryPromise({
|
|
256
|
+
try: (signal) =>
|
|
257
|
+
fetch("https://api.perplexity.ai/search", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: {
|
|
260
|
+
Accept: "application/json",
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
Authorization: "Bearer " + key,
|
|
263
|
+
},
|
|
264
|
+
body: JSON.stringify({
|
|
265
|
+
query: queryKey,
|
|
266
|
+
max_results: SEARCH_SIZE,
|
|
267
|
+
...(SEARCH_TYPE ? { search_type: SEARCH_TYPE } : {}),
|
|
268
|
+
...(RECENCY ? { search_recency_filter: RECENCY } : {}),
|
|
269
|
+
...(COUNTRY ? { country: COUNTRY } : {}),
|
|
270
|
+
...(LANGUAGE.length > 0 ? { search_language_filter: LANGUAGE } : {}),
|
|
271
|
+
}),
|
|
272
|
+
signal: timedSignal(signal, SEARCH_TIMEOUT),
|
|
273
|
+
}),
|
|
274
|
+
// A timeout is its own failure: naming it stops the user hunting
|
|
275
|
+
// for a key problem that was never there.
|
|
276
|
+
catch: (error) =>
|
|
277
|
+
error instanceof Error && error.name === "TimeoutError"
|
|
278
|
+
? new Error("Perplexity search timed out after " + SEARCH_TIMEOUT + "ms")
|
|
279
|
+
: new Error("Perplexity request failed: " + String(error)),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
const detail = yield* Effect.tryPromise({
|
|
284
|
+
try: () => response.text(),
|
|
285
|
+
catch: () => new Error("response body unavailable"),
|
|
286
|
+
})
|
|
287
|
+
return yield* Effect.fail(
|
|
288
|
+
new Error("Perplexity API error " + response.status + ": " + detail),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const json = yield* Effect.tryPromise({
|
|
293
|
+
try: () => response.json(),
|
|
294
|
+
catch: (error) => new Error("Invalid Perplexity response: " + String(error)),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const body = yield* Schema.decodeUnknownEffect(PerplexityResponse)(json)
|
|
298
|
+
const results = (body.results ?? []).map(toResult)
|
|
299
|
+
|
|
300
|
+
// Perplexity charges per dispatch, so a response that never enters
|
|
301
|
+
// the cache is a query paid for again on every repeat.
|
|
302
|
+
writeCache(queryKey, results)
|
|
303
|
+
return results
|
|
304
|
+
}),
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// Rebind on credential changes: connect, delete, or rotate a key and the
|
|
309
|
+
// provider joins or leaves the websearch pool without a restart. Env-var
|
|
310
|
+
// keys cannot change under a running process, so they bind at startup.
|
|
311
|
+
yield* Effect.forkScoped(
|
|
312
|
+
ctx.event.subscribe().pipe(
|
|
313
|
+
Stream.filter(
|
|
314
|
+
(event) =>
|
|
315
|
+
event.type === "credential.updated" ||
|
|
316
|
+
event.type === "credential.switched" ||
|
|
317
|
+
event.type === "integration.updated",
|
|
318
|
+
),
|
|
319
|
+
Stream.mapEffect(() =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const next = Boolean(yield* resolveKey())
|
|
322
|
+
if (next === bound) return
|
|
323
|
+
bound = next
|
|
324
|
+
yield* ctx.websearch.reload()
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
Stream.runDrain,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
export default Plugin.define({ id: "perplexity-search", effect })
|