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 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
- One command adds thirteen search providers to OpenCode v2, growing the pool from five to eighteen. AnySearch and Keenable work with no setup at all; the other eleven only activate once you add a key.
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 it, then just ask OpenCode something that needs the web.
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 once you record one:
20
- ask a question → the websearch tool fires → results come back -->
34
+ <!-- TODO: replace this comment with a 10-second GIF:
35
+ set websearch.provider -> ask a question -> provider responds with sources -->
21
36
 
22
- Thirteen providers in one package, use whichever you like:
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 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.
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
- This repository adds plugins for thirteen more of them, taking the pool from five providers to eighteen. AnySearch and Keenable run without a key at all; the other eleven 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.
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
- Three equivalent ways to install; pick one. **npm is recommended**: a version published to the registry is immutable, so a pinned `@3.1.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 thirteen); use the third for one or two files only.
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 @3.1.0 to pin
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 #v3.1.0 to pin
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 the services you have no key for
104
+ ### Skip providers you don't use
86
105
 
87
- Loading the package registers all thirteen, but a provider without a key never joins the search pool; see [Keys](#keys). To keep some out of the registry entirely, use either or both of these optional options:
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` is a deny-list: it never registers the IDs it names (the provider IDs in the table below). AnySearch and Keenable need no key, so `disable` is the only way to keep those two out of the pool.
101
- - `enable` is an allow-list: only the IDs it names are registered, and everything else is skipped. `"enable": ["osp-brave", "osp-jina"]` runs those two alone.
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
- When both are set, **`disable` wins on any overlap**. A per-provider exclusion is never overridden by a bulk allow-list:
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 out: disable overrides enable.
111
- // osp-youcom was never allowed in; that entry is inert.
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
- Read as set arithmetic, the registered pool is `enable − disable`. The overlap drops out; a `disable` ID the allow-list never contained was never a candidate to begin with, redundant but harmless and unreported. The overlap is the only redundancy that earns a warning, and the log names the IDs in it.
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 (`@3.1.0` on npm, `#v3.1.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.
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
- Once keys are in place, pick how OpenCode chooses among them:
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
- Every ID below carries an `osp-` prefix; it is 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 `enable`/`disable`.
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
- The namespace also shows on the display name: `/connect` lists each of this package's entries with an `(osp)` marker (`DeepSeek (osp)` beside a built-in `DeepSeek`), so a key saved here cannot be mistaken for a built-in provider's. The websearch pool keeps the plain provider name, since the pool holds only search providers and the collision does not exist there.
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
- 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.
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 all thirteen
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 asks the selected provider for the query.
312
- 2. The normalized query (trimmed, whitespace collapsed) is checked against an in-memory cache. A hit returns immediately; repeat queries are free and instant.
313
- 3. On a miss the key is resolved again, so a key rotated in `/connect` applies to the very next search.
314
- 4. The request goes out under a deadline (`<PROVIDER>_TIMEOUT`, default 25s). OpenCode applies no timeout of its own around a search, so without this a provider that accepted the connection and then went silent would hang the whole turn. Failures surface the HTTP status and response body, or name the timeout when that is what happened.
315
- 5. The response is schema-decoded, mapped to `{ url, title, content, time }`, and cached.
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
- Dates are parsed from relative phrases (`3 hours ago`, `3小时前`), absolute formats (`Sep 30, 2026`, `31 Mar 2026`), and ISO 8601. Strings whose lenient parsing would invent a wrong year (`Nov 1`, which `Date.parse` reads as 2001) are dropped rather than reported as a bogus timestamp.
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
- 一条命令,给 OpenCode v2 加上十三个搜索 provider,搜索池从五家扩到十八家。AnySearch 和 Keenable 不用 key 直接能用;其余十一家等你配好 key 才会启用。
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
- 装完就能问 OpenCode 需要联网的问题。
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
- 提问 → websearch 工具触发 → 结果返回 -->
34
+ <!-- TODO: 录一段 10 秒 GIF:
35
+ 配置 websearch.provider -> 提问 -> provider 正常应答带引用 -->
21
36
 
22
- 十三个 provider 装一次全有,想用哪家用哪家:
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 内置了五个网页搜索 provider:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。在这个范围之外,还有大量搜索 API 提供免费额度或免费层级,足以支撑 agent 的日常检索而不必为此付费。
43
+ OpenCode v2 本身内置了几个官方搜索渠道:`exa`、`firecrawl`、`parallel`、`tavily`、`tinyfish`。除此之外,市面上还有不少提供免费额度的搜索 API。
44
+
45
+ 本插件为这些服务补齐了适配:
29
46
 
30
- 本仓库为其中十三家补上了插件,把可用池从五个 provider 扩到十八个。AnySearch 和 Keenable 无需任何 key 即可使用;其余十一家只在你提供 key 之后才会绑定,因此装上这个包也不会把查询路由到一个注定失败的服务上。至于哪几家值得用,取决于它们各自当前的免费额度,长期依赖某家之前建议先确认它的定价。
47
+ - AnySearch 和 Keenable 支持免 key 匿名直接搜索。
48
+ - 部分服务附带免费调用额度,便于低成本搭建备用轮询池。
49
+ - 扩充 OpenCode `random` 模式的备选池。单家触发 HTTP 429 时,能够自动换家重试,降低搜索中断的几率。
31
50
 
32
51
  ## 安装
33
52
 
34
- 三种方式等价,任选其一。**推荐 npm**:已发布到 registry 的版本不可变,固定 `@3.1.0` 后每次安装都拿到同一份字节;GitHub 的 tag 在理论上可以被维护者移动、指向别的 commit。前两种装的是同一个完整包(十三个全有),只想用一两个就用第三种。
53
+ 支持三种安装方式,选适合你的就行。**推荐 npm**:发布到 npm 的版本不可变,用 `@<version>` 锁定后始终能拿到一致的文件。
35
54
 
36
55
  ### 1. 从 npm 安装(推荐)
37
56
 
38
57
  ```sh
39
- # 追加 @3.1.0 可固定到该版本
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
- # 追加 #v3.1.0 可固定到该 tag
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
- ### 跳过你没有 key 的服务
104
+ ### 过滤不需要的渠道
86
105
 
87
- 装包会注册全部十三家,但没有 key 的 provider 永远不会进入搜索池(见下文[密钥](#密钥))。想让它们连注册表都不出现,可以单独或同时使用下面两个可选选项:
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` 是黑名单:它列出的 provider ID(见下文[支持的服务](#支持的服务)的表格)永远不会注册。AnySearch 和 Keenable 不需要 key,想把这两家也排除在搜索池之外,只能用 `disable`。
101
- - `enable` 是白名单:只注册列出的 ID,其余一律跳过;`"enable": ["osp-brave", "osp-jina"]` 只跑这两家。
119
+ - `disable`:黑名单。AnySearch 和 Keenable 默认免 key,如果不想让它们进入搜索池,只能通过 `disable` 排除。
120
+ - `enable`:白名单。只注册指定的渠道,例如 `"enable": ["osp-brave", "osp-jina"]` 仅保留这两家。
102
121
 
103
- 两者同时配置时,**交集由 `disable` 胜出**:逐家的排除绝不会被批量白名单覆盖。
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 仍然不注册:disable 压过 enable。
111
- // osp-youcom 本来就不在白名单里,这条是冗余的。
129
+ // osp-serper 不会注册:disable 覆盖 enable。
130
+ // osp-youcom 本来就不在 enable 中,会被静默忽略。
112
131
  }
113
132
  }
114
133
  ```
115
134
 
116
- 用集合运算来读:实际注册的池 = `enable − disable`。交集被剔除;`disable` 里不在 `enable` 中的 ID 从一开始就不在候选内,属于无害的冗余,不报警告。唯一会触发警告的冗余是交集,日志会点出其中的 ID。
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 形式是 `@3.1.0`,GitHub 形式是 `#v3.1.0`),直到你用新 pin 重新执行 `plugin add`,或直接修改 `opencode.json(c)` 里的说明符。本地复制的文件用新版本覆盖旧文件即可。
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
- 密钥就位后,再决定 OpenCode 如何在它们之间做选择:
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
- 下表每个 ID 都带 `osp-` 前缀,这是本包的命名空间。OpenCode 内置 provider 用的是裸厂商名(`exa`、`tavily`),厂商自己的插件也会注册裸名(Keenable 就已经有一个),所以前缀是避免撞名的唯一保障:既避开搜索池里的重复,也避开 `/connect` 里按 integration ID 存凭据的那张表。在 `websearch.provider` 和 `enable`/`disable` 里请原样使用带前缀的 ID。
178
+ 下表中的 ID 均带有 `osp-` 前缀,用以避免与 OpenCode 内置渠道或官方插件命名冲突(它们使用的是 `exa`、`tavily` 这类直接名字)。在 `websearch.provider` 和 `enable`/`disable` 中请使用带前缀的完整 ID。
174
179
 
175
- 命名空间同样体现在显示名上:`/connect` 里本包的每个条目都带 `(osp)` 标记(内置的 `DeepSeek` 旁边是 `DeepSeek (osp)`),存在这里的 key 不会和内置 provider 的混淆。搜索池里仍用纯品牌名,因为池中只有搜索 provider,不存在撞名问题。
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
- 所有配置项都是环境变量,OpenCode 启动时读取一次,改了要重启。默认值取自各家 API 文档,所以一开始什么都不设就是合理的起点。
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 向选定的 provider 发起查询。
312
- 2. 归一化后的查询(去首尾、压缩空白)先查内存缓存;命中则直接返回,重复查询免费且即时。
313
- 3. 未命中时重新解析密钥,因此在 `/connect` 里轮换的 key 立即对下一次搜索生效。
314
- 4. 发出请求,且带期限(`<PROVIDER>_TIMEOUT`,默认 25 秒)。OpenCode 自身并不给搜索加任何超时,所以没有这道期限的话,一个接受了连接却不再应答的 provider 会挂住整轮对话。失败时会带上 HTTP 状态码和响应体;如果是超时,报错会直接点明是超时。
315
- 5. 响应经 schema 解码,映射为 `{ url, title, content, time }`,然后写入缓存。
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
- 日期解析支持相对表述(`3 hours ago`、`3小时前`)、绝对格式(`Sep 30, 2026`、`31 Mar 2026`)和 ISO 8601。凡是宽松解析会凭空造出错误年份的字符串(比如 `Nov 1`,`Date.parse` 会读成 2001 年)一律丢弃,而不是返回一个错误的时间戳。
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 thirteen providers this package registers, in alphabetical order. Each entry
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.1.0",
4
- "description": "Thirteen self-contained websearch provider plugins for OpenCode v2: AnySearch, Bocha, Brave, DeepSeek, Jina, Keenable, Linkup, Metaso, Ollama, Querit, SerpApi, Serper and You.com.",
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 })