deepseek-web-retrieval 0.5.1 → 0.7.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +167 -45
  3. package/dist/cli.js +2303 -1836
  4. package/docs/adr/0002-shared-config-base-layer.md +43 -0
  5. package/docs/agents/codex.md +60 -32
  6. package/docs/agents/opencode-v2.md +69 -39
  7. package/docs/agents/pi.md +60 -31
  8. package/docs/design/config-layering-design.md +180 -0
  9. package/docs/design/installer-design.md +33 -20
  10. package/docs/integration.md +41 -7
  11. package/docs/research/control-matrix.md +17 -4
  12. package/docs/research/mcp-compatibility.md +9 -2
  13. package/docs/research/ori-question-sourcing.md +336 -0
  14. package/docs/research/result-quality-passthrough.md +159 -0
  15. package/docs/research/search-api-defaults.md +18 -2
  16. package/docs/research/search-inline-content.md +244 -0
  17. package/examples/opencode.global.jsonc +3 -2
  18. package/package.json +2 -1
  19. package/src/core/auth.ts +3 -0
  20. package/src/core/client.ts +26 -4
  21. package/src/core/config-file.ts +168 -0
  22. package/src/core/config-summary.ts +82 -0
  23. package/src/core/constants.ts +15 -1
  24. package/src/core/descriptions.ts +6 -2
  25. package/src/core/env.ts +99 -32
  26. package/src/core/format.ts +19 -1
  27. package/src/core/options.ts +22 -2
  28. package/src/core/search.ts +6 -0
  29. package/src/core/types.ts +11 -0
  30. package/src/installer/apply.ts +5 -3
  31. package/src/installer/cli.ts +92 -25
  32. package/src/installer/config-command.ts +116 -0
  33. package/src/installer/hosts/codex.ts +5 -3
  34. package/src/installer/hosts/opencode.ts +9 -5
  35. package/src/installer/hosts/pi.ts +10 -6
  36. package/src/installer/ownership.ts +54 -1
  37. package/src/installer/paths.ts +7 -1
  38. package/src/installer/state.ts +73 -19
  39. package/src/installer/types.ts +12 -0
  40. package/src/mcp/auth.ts +2 -3
  41. package/src/mcp/cli.ts +9 -51
  42. package/src/mcp/serve.ts +60 -0
  43. package/src/mcp/server.ts +7 -2
  44. package/src/opencode/plugin.ts +22 -2
  45. package/src/pi/extension.ts +78 -9
  46. package/src/version.ts +12 -0
@@ -0,0 +1,244 @@
1
+ # Search with inline content: `need_content` facts
2
+
3
+ This note answers one question: if DeepSeek's Search API were called with
4
+ `need_content: true` (today the repo hardcodes `false`), what would the response provably
5
+ contain, what would it cost, how large could it get, and where does that collide with
6
+ per-host output caps? It extends
7
+ [search-api-defaults.md](search-api-defaults.md) — which surveyed factory defaults — with the
8
+ one question that survey did not ask: which vendors ship search-with-inline-content as the
9
+ default architecture versus opt-in, and with what per-result content budget. It records facts
10
+ only; the closing section lines the facts up against the current two-tool design (search
11
+ summary-only plus a separate fetch tool) without recommending for or against it.
12
+
13
+ **Research baseline: 2026-10-23.** Claims are cited to primary sources only: official
14
+ DeepSeek documentation (api-docs.deepseek.com, English and 中文 pages, plus the official FAQ
15
+ bundle), other vendors' official documentation and SDK/API references, and OpenAI Codex
16
+ source at the repo's locked tag. Secondhand blogs are excluded. Where no primary source pins
17
+ a fact — most importantly the size of `content` — that is stated as such: an undocumented
18
+ size is itself a decision-relevant fact.
19
+
20
+ ## What `need_content=true` provably returns
21
+
22
+ From the official Search & Fetch guide, the parameter and its response field, verbatim:
23
+
24
+ | Item | 中文 | English |
25
+ | --- | --- | --- |
26
+ | `need_content` (request) | “是否在 content 中返回网页正文,为 false 时 content 恒为空字符串。” | "Whether to return page content in content, which is always an empty string when false." |
27
+ | `content` (search response) | “网页正文,need_content 为 false 或正文缺失时为空字符串。” | "The page content, which is empty when need_content is false or when the content is unavailable." |
28
+
29
+ ([指南](https://api-docs.deepseek.com/zh-cn/guides/search) ·
30
+ [guide](https://api-docs.deepseek.com/guides/search), checked 2026-10-23.)
31
+
32
+ Three facts follow, all of them absences:
33
+
34
+ 1. **No size bound of any kind is documented for `content`.** The same guide gives "通常"
35
+ sizing for the other two text fields — `snippet` "通常为 100~200 字符" ("typically 100 to
36
+ 200 characters"), `summary` "通常为 600~800 字符" ("typically 600 to 800 characters") —
37
+ but has **no analogous statement** for `content`: no "通常" range, no maximum, no
38
+ truncation rule, no server-side character cap. An undocumented content size is the
39
+ single most decision-relevant fact in this note.
40
+ 2. **No format statement for search `content`.** The Fetch API's `content` field is
41
+ explicitly Markdown — "The fetched page content as Markdown text." /
42
+ “抓取后的 Markdown 正文” — but the Search API's `content` field carries no format
43
+ statement. Whether search-side `content` is the same Markdown rendering the Fetch
44
+ endpoint returns, or something else, is not documented. “正文缺失时为空字符串”
45
+ ("or when the content is unavailable") implies per-result content can be missing
46
+ independently of `need_content`, which matches the Fetch API's own empty-content failure
47
+ mode; no recovery behavior is specified for search.
48
+ 3. **No `need_content` × `count` interaction is documented.** The only sentence linking a
49
+ request parameter to result shape is about count and price (below); nothing says content
50
+ is returned for only the top N results, or that `count` behaves differently under
51
+ `need_content`.
52
+
53
+ ## Billing and rate limits
54
+
55
+ **Billing is per successful request, and `need_content` is never mentioned in connection
56
+ with price.** Verbatim from the guide and pricing page:
57
+
58
+ - “Search 和 Fetch 按请求次数计费,不按 token 或返回结果条数计费” — Search and Fetch are
59
+ billed per request, **not by token or by number of results returned**
60
+ ([指南](https://api-docs.deepseek.com/zh-cn/guides/search)).
61
+ - "count only controls how many results are returned and does not affect the per-request
62
+ price." / “count 只控制返回的结果条数,不影响单次请求的价格。” (same guide, both languages.)
63
+ - Price: Search and Fetch are each "Per 1,000 successful requests $0.75"
64
+ ([pricing](https://api-docs.deepseek.com/quick_start/pricing); the 中文 pages state
65
+ 每 1,000 次成功请求 5 元). A request is billable "only when it returns HTTP 200 and
66
+ produces a result" (Search must return non-empty `results`; Fetch must return page
67
+ content); failures, empty results, and timeouts are free.
68
+ - Neither the guide, the pricing page, nor the official FAQ
69
+ (`static.deepseek.com/faq`, whose embedded question set was inspected — it contains zero
70
+ entries about the Search/Fetch API) mentions `need_content` as a price modifier. Under
71
+ the documented model, a `need_content: true` search is one request at the Search
72
+ per-request price; there is no token-metered billing tier for Search/Fetch to move into.
73
+
74
+ One dated note on the pricing page (verbatim, EN): "when you use the DeepSeek API through
75
+ DeepSeek Harness, Claude Code, or Codex," server-side tool calls already hit the Search and
76
+ Fetch APIs; "These calls are currently free of charge;" — “这部分 Search/Fetch 接口调用目前未计费,
77
+ 并将于北京时间 2026 年 11 月 1 日 00:00 起按次收取调用费用” (per-request billing for those
78
+ server-side-tool calls starts 00:00 Beijing time on 2026-11-01 — nine days after this note's
79
+ baseline). This note concerns the repo's direct `/v1/tools/search` calls, which follow the
80
+ per-request price above, not those chat-completions-internal tool calls.
81
+
82
+ **Rate limits are per endpoint and independent; `need_content` is not mentioned.** Search
83
+ 100 QPS, Fetch 25 QPS per account; “Search 和 Fetch 分别独立限速,互不占用额度” ("traffic to
84
+ one does not consume the other's quota"); “除了账号级限制,Fetch 还针对目标域名有 QPS 限制”
85
+ (Fetch additionally has an unspecified per-domain QPS limit)
86
+ ([guide](https://api-docs.deepseek.com/guides/search)). A `need_content: true` call is a
87
+ Search-endpoint call; no page documents it counting against anything but the Search quota.
88
+
89
+ ## Official guidance on `need_content` vs the Fetch endpoint
90
+
91
+ None exists. The guide gives only the two positioning sentences — Search “按 query 搜索网页,
92
+ 返回结构化结果”, Fetch “抓取单个网页,以 Markdown 文本返回页面内容” — with no use-case,
93
+ latency, or cost trade-off statement, in either language. (Checked 2026-10-23; also absent
94
+ from the FAQ.)
95
+
96
+ ## Vendor architectures: who ships content-bearing search by default
97
+
98
+ Extending [search-api-defaults.md](search-api-defaults.md) (which established the defaults)
99
+ with the architecture reading. "Inline by default" means the caller gets page bodies in the
100
+ search response without setting any parameter.
101
+
102
+ | Vendor | Content inline by default? | Opt-in mechanism | Per-result content budget knob | Separate fetch tool? |
103
+ | --- | --- | --- | --- | --- |
104
+ | **Tavily** | Partial — default response carries per-result `content` of **relevance chunks**: `chunks_per_source` **3** chunks of ~500 chars (~1,500 chars/result) | Full body via `include_raw_content` (default **false**; accepts `markdown`/`text`/`true`) | Chunk count only (`chunks_per_source` 1–3, sizes `content`, **not** `raw_content`); no knob limits `raw_content` size in `/search` | Yes — `/extract` (up to 20 URLs; `extract_depth` basic/advanced; no per-URL cap; ~800-char chunks only when `query` is given) |
105
+ | **Exa** | No — `contents.text` defaults **false** | `contents.text: true` in the **same** `POST /search` body | **`maxCharacters`** (1–1,000,000, no published default) plus `verbosity` `compact`/`standard`/`full` | Yes — `/contents` endpoint by result ID/URL |
106
+ | **You.com** | No — “Omit `extraction` for a plain search that returns snippets only” | `extraction.full_page` (“crawls each result and returns the page content”) | **None for `full_page`** (only `crawl_timeout`); the `highlights` mode has a response-wide `total_max_tokens` budget of **8192** (1,024–8,192) | No dedicated fetch endpoint documented on `/v1/search` |
107
+ | **Perplexity** | No — Search API results are `title`/`url`/`snippet`/`date` only | Token budgets: `max_tokens` ("Maximum total webpage content tokens to return across all search results") and **`max_tokens_per_page`** ("Maximum webpage content tokens to extract from each result page"), on both the Search API and the Agent API `web_search` tool; **no default published** for either ("the default budgets apply"); on the Search API the extracted text surfaces through `snippet` | **`max_tokens_per_page`** (min 1, max 1,000,000, no default) | Yes — Agent API "Fetch URL Content" tool ("Fetch full content from known URLs") |
108
+ | **Anthropic** | Not caller-visible — `web_search` results carry `url`/`title`/`page_age` plus `encrypted_content` (“Encrypted content that you must pass back in multi-turn conversations”); the model reads content in its context, the caller cannot | `response_inclusion` (`"full"` default / `"excluded"`) only controls whether consumed search blocks are echoed in the response — it is not a content-size knob | **None** (dynamic filtering on `web_search_20260209+` has Claude write code to filter results before they reach context, but no caller-set budget) | Yes — `web_fetch` with token-based `max_content_tokens` (no published default) |
109
+ | **Firecrawl** | No — search returns `title`/`description`/`url` (+ `highlights`, default true) | `scrapeOptions` (default `{}`): "Include `scrapeOptions` with `formats: [{"type": "markdown"}] to get complete markdown content for each search result" | **None** for content size (`onlyMainContent` default true trims boilerplate, not length) | Collapsed into search via `scrapeOptions`; standalone `/scrape` endpoint also exists |
110
+ | **Jina** (`s.jina.ai`) | **Yes — the only default-content architecture surveyed**: "Reader searches the web, fetches the top 5 results, visits each URL, and applies `r.jina.ai` to it"; result count is fixed at 5, not a parameter | None needed (content is the product) | **`x-token-budget`** header, no default (parsed only when sent) — a global budget, not per-result | Same product (`r.jina.ai`) serves plain fetch |
111
+ | **DeepSeek (subject of this note)** | No — `need_content` defaults **false** | `need_content: true` | **None documented** (see above) | Yes — Fetch API, Markdown, no documented size cap |
112
+
113
+ The split, stated plainly: of the eight architectures surveyed, exactly one (Jina
114
+ `s.jina.ai`) ships content-bearing search as the default, and it pins the result count at 5.
115
+ Every other vendor makes inline content opt-in (Tavily `raw_content`, Exa `contents.text`,
116
+ You.com `extraction`, Firecrawl `scrapeOptions`, Perplexity token budgets) or keeps page
117
+ content out of the caller's hands entirely (Anthropic `encrypted_content`). Where budgets
118
+ exist they are per-result character caps (Exa `maxCharacters`) or per-result token caps
119
+ (Perplexity `max_tokens_per_page`); Tavily and You.com ship full-content modes with **no**
120
+ per-result size knob at all — the two closest analogues to DeepSeek's `need_content`.
121
+
122
+ Sources: [Tavily /search](https://docs.tavily.com/documentation/api-reference/endpoint/search) ·
123
+ [Tavily /extract](https://docs.tavily.com/documentation/api-reference/endpoint/extract) ·
124
+ [Exa /search](https://exa.ai/docs/reference/search) ·
125
+ [You.com /v1/search](https://you.com/docs/api-reference/search/v1-search) ·
126
+ [Perplexity /search](https://docs.perplexity.ai/api-reference/search-post) ·
127
+ [Perplexity Agent web_search](https://docs.perplexity.ai/docs/agent-api/tools/web-search) ·
128
+ [Anthropic web search tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool) ·
129
+ [Anthropic web fetch tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-fetch-tool) ·
130
+ [Firecrawl /search](https://docs.firecrawl.dev/api-reference/endpoint/search) ·
131
+ [Jina reader README](https://github.com/jina-ai/reader/blob/main/README.md) — all checked
132
+ 2026-10-23; Tavily/Exa/You.com/Jina defaults cross-checked against
133
+ [search-api-defaults.md](search-api-defaults.md).
134
+
135
+ ## Volume math against host caps
136
+
137
+ **Assumptions, labeled as such (not source-backed):** a typical article page body rendered
138
+ to Markdown is ~2,000–10,000 characters (assumption; it is consistent with this repo's own
139
+ calibration that 16,000 chars is a sensible budget for one *fetched* page, but no vendor
140
+ publishes a typical body size); English text ≈ 1 byte and CJK ≈ 3 bytes per UTF-8 char;
141
+ English ≈ 4 chars per token and CJK ≈ 1–1.5 chars per token (rough tokenizer conventions,
142
+ assumptions).
143
+
144
+ With `count=10` (the repo's default, and DeepSeek's) and `need_content: true`:
145
+
146
+ - **Response size, order of magnitude:** 10 × 2k–10k chars of body ≈ **20k–100k chars**,
147
+ plus ~1k chars of metadata per result (title/url/snippet/summary/scores) → roughly
148
+ **30k–110k chars**, i.e. **~30–110 KB** for English pages and up to **~330 KB** if pages
149
+ are CJK-heavy. A single malformed huge page (a pasted log, a link farm) can exceed this
150
+ unboundedly — there is no documented server-side cap (the key fact above).
151
+ - **Pi, MCP path: collides at the low end of the assumption.** Text results over **20 KB**
152
+ are middle-truncated (full text saved to a file) and the per-request timeout is **60 s**
153
+ ([control-matrix.md](control-matrix.md)). Even 10 × 2k-char bodies plus metadata (~30 KB)
154
+ exceeds 20 KB. Staying under 20 KB at count=10 requires ≲**1.8k chars of content per
155
+ result** after metadata. Caveat from the same note: the *shipped* Pi adapter is the native
156
+ extension, whose results bypass the MCP layer — "neither the 20 KB truncation nor the 60 s
157
+ request timeout applies"; the cap binds the MCP fallback path only.
158
+ - **Claude Code: collides at the high end.** Tool output is capped at **25k tokens** by
159
+ default (`MAX_MCP_OUTPUT_TOKENS`), oversized text saved to a file
160
+ ([control-matrix.md](control-matrix.md)). 30k–110k English chars ≈ 8k–28k tokens —
161
+ brushing or overflowing the cap at the top of the assumed range; CJK at 1–1.5 chars/token
162
+ ≈ 20k–110k tokens — overflowing from the middle of the range up. Staying under 25k tokens
163
+ at count=10 requires ≲**2.5k tokens (~10k English chars) per result**.
164
+ - **Codex: collides by policy, magnitude unpublished.** Codex truncates tool output by a
165
+ per-model, server-provided truncation policy (bytes or tokens — "Server-provided
166
+ truncation policy metadata for a model"), using **middle truncation**, with a 20%
167
+ serialization allowance added (`with_serialization_allowance` = policy × 1.2); no default
168
+ number is published in the repo or config reference (the only literal in source,
169
+ `bytes(10_000)`, is a `#[cfg(test)]` fixture). The per-tool override
170
+ `mcp_servers.<id>.tools.<tool>.output_token_limit` exists ("Token budget for one MCP
171
+ tool's output, before the standard 20% serialization allowance")
172
+ ([config reference](https://learn.chatgpt.com/docs/config-file/config-reference);
173
+ [mcp_types.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/mcp_types.rs)).
174
+ [control-matrix.md](control-matrix.md) records Codex's 300 s MCP tool timeout — latency,
175
+ not size, is that host's documented ceiling.
176
+ - **The binding constraint is Pi's 20 KB**, roughly an order of magnitude tighter than
177
+ Claude Code's 25k-token default: a per-result content budget in the **~1k–2k char**
178
+ range keeps count=10 under every published cap; reusing the repo's fetch budget (16,000
179
+ chars) *per search result* would reach 160k chars per response — 8× Pi's cap and ~40k
180
+ tokens on Claude Code.
181
+ - **The repo's own 5 MiB `MAX_RESPONSE_BYTES` is not the binding constraint** at these
182
+ assumptions (10 results would need to average ~500 KB each to reach it), but note it
183
+ **rejects** oversized bodies with an error rather than truncating
184
+ ([transport.ts](../../src/core/transport.ts) `readResponseText`), and with no documented
185
+ server-side cap, a pathological page could trip it.
186
+
187
+ ## What this means for this repo
188
+
189
+ Fact-by-fact alignment with the current code. No recommendation is made here; the
190
+ maintainer decides.
191
+
192
+ - **The toggle point is one hardcoded literal.** `searchWeb` posts `need_content: false`
193
+ ([client.ts](../../src/core/client.ts)); everything else about a content-bearing mode
194
+ would be new surface.
195
+ - **Today's pipeline would silently drop the content.** `DeepSeekSearchResult`
196
+ ([types.ts](../../src/core/types.ts)) has no `content` field, and `processResults`
197
+ ([search.ts](../../src/core/search.ts)) maps `SearchResult.content` from
198
+ `summary ?? snippet` only — a `need_content` response's page bodies would be received and
199
+ discarded, unbilled-for-nothing but also unrendered.
200
+ - **The only per-result clamp in the search path is `maxSummaryChars` (1,200)** — a
201
+ summary/snippet budget. `fetchMaxChars` (16,000) and `formatFetchedContent`'s
202
+ truncation-marker logic apply exclusively to the fetch tool path. There is **no per-result
203
+ page-content budget knob of any kind**; the vendor table above shows the two shapes such
204
+ a knob takes elsewhere (Exa's per-result `maxCharacters`, Perplexity's per-result
205
+ `max_tokens_per_page`).
206
+ - **`MAX_RESPONSE_BYTES` (5 MiB) is the only existing guard a need_content response meets**,
207
+ and it errors rather than truncates — the one host cap it protects against is the
208
+ undocumented-content-size scenario at its pathological extreme.
209
+ - **Text rendering flattens structure.** `formatSearchResults` ([format.ts](../../src/core/format.ts))
210
+ renders each result's `content` with newlines replaced by single spaces — sized for a
211
+ 1,200-char summary. Multi-thousand-character Markdown bodies in the text content block
212
+ would lose heading/list structure; the `structuredContent` mirror preserves it as-is.
213
+ - **The two facts that make this decision unusual:** DeepSeek documents **no size bound and
214
+ no per-result budget knob** for `need_content` content (unlike Exa/Perplexity, whose
215
+ opt-in modes ship with budget parameters), and **no vendor but Jina ships
216
+ content-bearing search by default** — with Jina pinning count at 5, exactly the
217
+ count-shrinking direction the Pi 20 KB arithmetic above pushes toward.
218
+
219
+ ## Sources
220
+
221
+ - DeepSeek: [Search & Fetch guide (EN)](https://api-docs.deepseek.com/guides/search) ·
222
+ [网页搜索与抓取 (ZH)](https://api-docs.deepseek.com/zh-cn/guides/search) ·
223
+ [Pricing](https://api-docs.deepseek.com/quick_start/pricing) ·
224
+ [FAQ (static.deepseek.com/faq)](https://static.deepseek.com/faq/index.html?lang=en) —
225
+ question set inspected from the app bundle, 2026-10-23
226
+ - Tavily: [search endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/search) ·
227
+ [extract endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/extract)
228
+ - Exa: [search reference](https://exa.ai/docs/reference/search)
229
+ - You.com: [POST /v1/search reference](https://you.com/docs/api-reference/search/v1-search)
230
+ - Perplexity: [Search API reference](https://docs.perplexity.ai/api-reference/search-post) ·
231
+ [Agent API web_search tool](https://docs.perplexity.ai/docs/agent-api/tools/web-search)
232
+ - Anthropic: [web search tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool) ·
233
+ [web fetch tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-fetch-tool)
234
+ - Firecrawl: [search endpoint](https://docs.firecrawl.dev/api-reference/endpoint/search)
235
+ - Jina: [reader README](https://github.com/jina-ai/reader/blob/main/README.md)
236
+ - Codex: [config reference](https://learn.chatgpt.com/docs/config-file/config-reference) ·
237
+ [mcp_types.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/mcp_types.rs) ·
238
+ [openai_models.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/protocol/src/openai_models.rs) ·
239
+ [output truncation lib at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/utils/output-truncation/src/lib.rs)
240
+ - Repo: [search-api-defaults.md](search-api-defaults.md) ·
241
+ [control-matrix.md](control-matrix.md) ·
242
+ [constants.ts](../../src/core/constants.ts) · [client.ts](../../src/core/client.ts) ·
243
+ [search.ts](../../src/core/search.ts) · [format.ts](../../src/core/format.ts) ·
244
+ [transport.ts](../../src/core/transport.ts)
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
 
4
- // Global config: ~/.config/opencode/opencode.jsonc
5
- // Assumes the repo is cloned to ~/.config/opencode/vendor/deepseek-web-retrieval.
4
+ // Global config: ~/.config/opencode/opencode.jsonc (%APPDATA%\opencode\opencode.jsonc on Windows)
5
+ // Assumes the repo is cloned to the vendor/ directory beside the config
6
+ // (~/.config/opencode/vendor/deepseek-web-retrieval; %APPDATA%\opencode\vendor\... on Windows).
6
7
  "plugins": [
7
8
  {
8
9
  "package": "./vendor/deepseek-web-retrieval",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "deepseek-web-retrieval",
4
- "version": "0.5.1",
4
+ "version": "0.7.0",
5
5
  "description": "Token-efficient DeepSeek Search and Fetch for coding agents, starting with OpenCode v2.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "lint": "biome check --error-on-warnings .",
51
+ "clean": "bun run scripts/clean.ts",
51
52
  "lint:ci": "biome ci --error-on-warnings .",
52
53
  "lint:fix": "biome check --write --error-on-warnings .",
53
54
  "format": "biome format --write .",
package/src/core/auth.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { API_KEY_ENV } from "./constants";
2
2
 
3
+ /** Shared missing-key guidance; every surface that needs a key prints this one message. */
4
+ export const MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
5
+
3
6
  export function apiKeyFromEnv(env: Readonly<Record<string, string | undefined>> = process.env): string | undefined {
4
7
  const key = env[API_KEY_ENV]?.trim();
5
8
  return key ? key : undefined;
@@ -1,6 +1,6 @@
1
- import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants";
1
+ import { FETCH_ENDPOINT, MAX_ORI_QUESTION_CHARS, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants";
2
2
  import { assertFetchHostPolicy } from "./domains";
3
- import { formatFetchedContent } from "./format";
3
+ import { formatFetchedContent, truncate } from "./format";
4
4
  import { buildQuery, processResults } from "./search";
5
5
  import { postJson } from "./transport";
6
6
  import type {
@@ -22,6 +22,16 @@ function effectiveTimeout(configuredMs: number, perCallMs: number | undefined):
22
22
  return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs);
23
23
  }
24
24
 
25
+ /**
26
+ * The wire value for ori_question: absent/whitespace-only means "not
27
+ * provided"; longer values are clamped rather than rejected — the field is
28
+ * auxiliary and a hard error would punish the model for verbosity.
29
+ */
30
+ function oriQuestionValue(oriQuestion: string | undefined): string | undefined {
31
+ const trimmed = oriQuestion?.trim();
32
+ return trimmed ? truncate(trimmed, MAX_ORI_QUESTION_CHARS) : undefined;
33
+ }
34
+
25
35
  interface SearchResponse {
26
36
  readonly results?: unknown;
27
37
  }
@@ -41,10 +51,19 @@ export interface RequestOptions {
41
51
  readonly timeoutMs?: number;
42
52
  }
43
53
 
54
+ export interface SearchRequest extends RequestOptions {
55
+ /**
56
+ * Per-call only — the option-resolution pipeline never populates this. The
57
+ * user's original question behind the search, sent as DeepSeek's optional
58
+ * ori_question to sharpen intent; adapters fill it from tool input.
59
+ */
60
+ readonly oriQuestion?: string;
61
+ }
62
+
44
63
  export async function searchWeb(
45
64
  query: string,
46
65
  options: SearchOptions,
47
- request: RequestOptions,
66
+ request: SearchRequest,
48
67
  ): Promise<SearchResult[]> {
49
68
  const trimmed = query.trim();
50
69
  if (!trimmed) throw new Error("DeepSeek search query must not be empty");
@@ -63,12 +82,15 @@ export async function searchWeb(
63
82
  }
64
83
 
65
84
  const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
85
+ const oriQuestion = oriQuestionValue(request.oriQuestion);
66
86
  const payload = await postJson<SearchResponse>(
67
87
  SEARCH_ENDPOINT,
68
88
  {
69
89
  query: builtQuery,
70
90
  count: options.count,
71
91
  need_content: false,
92
+ ...(oriQuestion === undefined ? {} : { ori_question: oriQuestion }),
93
+ ...(options.localization === undefined ? {} : { localization: options.localization }),
72
94
  },
73
95
  "DeepSeek Search",
74
96
  { ...request, timeoutMs: timeout },
@@ -129,7 +151,7 @@ export async function fetchDocument(
129
151
  export function createRetrievalClient(dependencies: { readonly fetchImpl?: typeof fetch } = {}) {
130
152
  const bind = (request: RequestOptions): RequestOptions => ({ ...request, ...dependencies });
131
153
  return {
132
- searchWeb: (query: string, options: SearchOptions, request: RequestOptions) =>
154
+ searchWeb: (query: string, options: SearchOptions, request: SearchRequest) =>
133
155
  searchWeb(query, options, bind(request)),
134
156
  fetchPage: (url: URL, options: FetchOptions, request: RequestOptions) => fetchPage(url, options, bind(request)),
135
157
  fetchDocument: (url: string | URL, options: FetchOptions, request: RequestOptions) =>
@@ -0,0 +1,168 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+ import { CONFIG_KEYS } from "./config-summary";
5
+ import type { Env } from "./env";
6
+ import { isPlainObject } from "./object";
7
+ import type { PluginOptions } from "./types";
8
+
9
+ /** The user-scope shared config file: one file, under the user config home (userConfigHome). */
10
+ export const SHARED_CONFIG_DIR = "deepseek-web-retrieval";
11
+ export const SHARED_CONFIG_FILE = "config.json";
12
+
13
+ /**
14
+ * The shared file's vocabulary: API-behavior and host-sensitive plugin option
15
+ * keys only, derived from the ordered config vocabulary (CONFIG_KEYS) so the
16
+ * file, the startup summary, and the config command can never drift apart.
17
+ * Values pass through raw — validation and clamping stay in
18
+ * resolvePluginOptions so every channel feeds one pipeline. Host-integration
19
+ * keys are deliberately absent (see HOST_INTEGRATION_KEYS).
20
+ */
21
+ export const SHARED_CONFIG_KEYS = CONFIG_KEYS;
22
+
23
+ const SHARED_KEY_SET = new Set<string>(SHARED_CONFIG_KEYS);
24
+
25
+ /**
26
+ * Host-integration keys describe the host's tool landscape, not the DeepSeek
27
+ * call, so they stay in host-native config; the shared file rejects them.
28
+ */
29
+ const HOST_INTEGRATION_KEYS = new Set<string>([
30
+ "searchEnabled",
31
+ "fetchEnabled",
32
+ "setDefaultWebsearch",
33
+ "fetchOverrideWebfetch",
34
+ "fetchRegisterTool",
35
+ "apiKeySource",
36
+ ]);
37
+
38
+ /**
39
+ * Joins with the separators of the platform being resolved, not the host's —
40
+ * identical to `join` in production (the platform is process.platform), but
41
+ * deterministic when tests inject win32 on another host.
42
+ */
43
+ function joinFor(platform: NodeJS.Platform, ...parts: readonly string[]): string {
44
+ return (platform === "win32" ? win32 : posix).join(...parts);
45
+ }
46
+
47
+ /**
48
+ * The inputs every config-location resolution shares, bundled so they travel
49
+ * as one (the installer's FsRoots is the precedent for this shape). Every
50
+ * function below accepts a partial and fills the machine's real values for
51
+ * anything omitted.
52
+ */
53
+ export interface UserConfigContext {
54
+ readonly env: Env;
55
+ readonly home: string;
56
+ readonly platform: NodeJS.Platform;
57
+ }
58
+
59
+ function resolveContext(context: Partial<UserConfigContext> = {}): UserConfigContext {
60
+ return {
61
+ env: context.env ?? process.env,
62
+ home: context.home ?? homedir(),
63
+ platform: context.platform ?? process.platform,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The canonical user config home — the one resolution every consumer of the
69
+ * per-user config location shares (the shared config file, the installer's
70
+ * global state records, and the OpenCode global config).
71
+ *
72
+ * A non-empty XDG_CONFIG_HOME wins on every platform: power users and CI pin it
73
+ * deliberately, Windows included. Otherwise win32 uses the conventional Roaming
74
+ * root — %APPDATA% from the environment, `<home>\AppData\Roaming` when unset —
75
+ * and every other platform keeps the XDG default `<home>/.config`. Pure so
76
+ * tests inject env, home, and platform directly.
77
+ */
78
+ export function userConfigHome(context: Partial<UserConfigContext>): string {
79
+ const { env, home, platform } = resolveContext(context);
80
+ const xdg = env.XDG_CONFIG_HOME;
81
+ if (xdg) return xdg;
82
+ if (platform === "win32") return env.APPDATA || joinFor(platform, home, "AppData", "Roaming");
83
+ return joinFor(platform, home, ".config");
84
+ }
85
+
86
+ /** The directory this tool owns inside the user config home (see userConfigHome). */
87
+ export function userConfigDir(context: Partial<UserConfigContext>): string {
88
+ const { env, home, platform } = resolveContext(context);
89
+ return joinFor(platform, userConfigHome({ env, home, platform }), SHARED_CONFIG_DIR);
90
+ }
91
+
92
+ /** The shared file's location on this machine; see userConfigHome for the rules. */
93
+ export function configFilePath(context: Partial<UserConfigContext>): string {
94
+ const { env, home, platform } = resolveContext(context);
95
+ return joinFor(platform, userConfigDir({ env, home, platform }), SHARED_CONFIG_FILE);
96
+ }
97
+
98
+ /**
99
+ * What reading the shared config file produced: the accepted options plus the
100
+ * problems as structured data. `readFileOptions` prints the warnings; other
101
+ * consumers (the config command) replay them through their own channel.
102
+ */
103
+ export interface FileOptionsReport {
104
+ readonly options: PluginOptions;
105
+ /** One entry per problem the runtime would warn about on stderr. */
106
+ readonly warnings: readonly string[];
107
+ /** False only when the file is absent — the normal, silent case. */
108
+ readonly exists: boolean;
109
+ }
110
+
111
+ /**
112
+ * Reads the shared config file synchronously (the Pi settings reader's
113
+ * convention — adapters resolve once at startup) and reports what happened.
114
+ * Absence is normal and silent; an unreadable or unparseable file and every
115
+ * rejected key produce one warning each and are otherwise dropped, so a bad
116
+ * file never blocks startup.
117
+ */
118
+ export function readFileOptionsReport(file: string): FileOptionsReport {
119
+ let text: string;
120
+ try {
121
+ text = readFileSync(file, "utf8");
122
+ } catch (error) {
123
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
124
+ return { options: {}, warnings: [], exists: false };
125
+ }
126
+ return { options: {}, warnings: [`cannot read ${file} — file ignored`], exists: true };
127
+ }
128
+
129
+ let parsed: unknown;
130
+ try {
131
+ parsed = JSON.parse(text);
132
+ } catch {
133
+ return { options: {}, warnings: [`${file} is not valid JSON — file ignored`], exists: true };
134
+ }
135
+ if (!isPlainObject(parsed)) {
136
+ return { options: {}, warnings: [`${file} is not a JSON object — file ignored`], exists: true };
137
+ }
138
+
139
+ const options: Record<string, unknown> = {};
140
+ const warnings: string[] = [];
141
+ for (const [key, value] of Object.entries(parsed)) {
142
+ if (SHARED_KEY_SET.has(key)) options[key] = value;
143
+ else if (HOST_INTEGRATION_KEYS.has(key))
144
+ warnings.push(`"${key}" is a host-integration key (host config only) — ignored`);
145
+ else warnings.push(`unknown key "${key}" — ignored`);
146
+ }
147
+ return { options, warnings, exists: true };
148
+ }
149
+
150
+ /**
151
+ * Reads the shared config file and warns about problems on stderr (stdout is
152
+ * the MCP server's JSON-RPC channel). See readFileOptionsReport for the
153
+ * structured shape; this is the production entry adapters use.
154
+ */
155
+ export function readFileOptions(file: string): PluginOptions {
156
+ const report = readFileOptionsReport(file);
157
+ for (const warning of report.warnings) warn(warning);
158
+ return report.options;
159
+ }
160
+
161
+ /** The production entry point: compute the path for this machine and read it. */
162
+ export function sharedFileOptions(context: Partial<UserConfigContext> = {}): PluginOptions {
163
+ return readFileOptions(configFilePath(context));
164
+ }
165
+
166
+ function warn(line: string): void {
167
+ console.error(`deepseek-web-retrieval config: ${line}`);
168
+ }
@@ -0,0 +1,82 @@
1
+ import { resolvePluginOptions } from "./options";
2
+ import type { ResolvedOptions } from "./types";
3
+
4
+ /**
5
+ * The ordered config vocabulary: every diffable knob's config key and where it
6
+ * lives in ResolvedOptions. The single source three surfaces share — the
7
+ * startup deviation line's key order (via configSummaryValues), the config
8
+ * command's listing, and the shared file's accepted keys (SHARED_CONFIG_KEYS
9
+ * derives from it) — so adding a knob is one edit here.
10
+ */
11
+ const VOCABULARY = [
12
+ { key: "count", read: (o: ResolvedOptions) => o.search.count },
13
+ { key: "maxResults", read: (o: ResolvedOptions) => o.search.maxResults },
14
+ { key: "minRerank", read: (o: ResolvedOptions) => o.search.minRerank },
15
+ { key: "minAuthority", read: (o: ResolvedOptions) => o.search.minAuthority },
16
+ { key: "allowUnknownAuthority", read: (o: ResolvedOptions) => o.search.allowUnknownAuthority },
17
+ { key: "blockedDomains", read: (o: ResolvedOptions) => o.search.blockedDomains },
18
+ { key: "includeDomains", read: (o: ResolvedOptions) => o.search.includeDomains },
19
+ { key: "language", read: (o: ResolvedOptions) => o.search.language },
20
+ { key: "searchLocalization", read: (o: ResolvedOptions) => o.search.localization },
21
+ { key: "maxSummaryChars", read: (o: ResolvedOptions) => o.search.maxSummaryChars },
22
+ { key: "searchTimeoutMs", read: (o: ResolvedOptions) => o.search.timeoutMs },
23
+ { key: "fetchMaxChars", read: (o: ResolvedOptions) => o.fetch.maxChars },
24
+ { key: "fetchAllowedDomains", read: (o: ResolvedOptions) => o.fetch.allowedDomains },
25
+ { key: "fetchBlockedDomains", read: (o: ResolvedOptions) => o.fetch.blockedDomains },
26
+ { key: "fetchTimeoutMs", read: (o: ResolvedOptions) => o.fetch.timeoutMs },
27
+ { key: "fetchBlockPrivateHosts", read: (o: ResolvedOptions) => o.fetch.blockPrivateHosts },
28
+ { key: "maxRetries", read: (o: ResolvedOptions) => o.retry.maxRetries },
29
+ { key: "retryBaseDelayMs", read: (o: ResolvedOptions) => o.retry.baseDelayMs },
30
+ ] as const;
31
+
32
+ /** The config keys in vocabulary order; the shared config file accepts exactly these. */
33
+ export const CONFIG_KEYS: readonly string[] = VOCABULARY.map((entry) => entry.key);
34
+
35
+ /**
36
+ * Renders only the knobs whose effective value differs from the built-in
37
+ * defaults, as space-separated `key=value` tokens in the config vocabulary.
38
+ * Host-integration fields (the tool `enabled` flags, `apiKeySource`) are
39
+ * excluded — they are not in the vocabulary; tool visibility is the startup
40
+ * tools line's job and the key source is host-native config. Empty diff means
41
+ * empty string.
42
+ */
43
+ export function configDeviations(resolved: ResolvedOptions): string {
44
+ const defaults = configSummaryValues(resolvePluginOptions({}));
45
+ const effective = configSummaryValues(resolved);
46
+ const tokens: string[] = [];
47
+ for (const key of Object.keys(effective)) {
48
+ const value = effective[key];
49
+ if (sameValue(value, defaults[key])) continue;
50
+ tokens.push(`${key}=${formatConfigValue(value, "length")}`);
51
+ }
52
+ return tokens.join(" ");
53
+ }
54
+
55
+ /**
56
+ * Flattens the diffable resolved fields onto their config-vocabulary names,
57
+ * in vocabulary order (Object key insertion order preserves it).
58
+ */
59
+ export function configSummaryValues(options: ResolvedOptions): Record<string, unknown> {
60
+ const values: Record<string, unknown> = {};
61
+ for (const entry of VOCABULARY) values[entry.key] = entry.read(options);
62
+ return values;
63
+ }
64
+
65
+ /** How arrays render: lengths for the noise-constrained startup line, entries for the human CLI. */
66
+ export type ConfigArrayStyle = "length" | "join";
67
+
68
+ /** One value renderer for both surfaces; `undefined` renders as "unset". */
69
+ export function formatConfigValue(value: unknown, arrays: ConfigArrayStyle): string {
70
+ if (Array.isArray(value)) {
71
+ if (arrays === "length") return String(value.length);
72
+ return value.length > 0 ? value.join(", ") : "[]";
73
+ }
74
+ return value === undefined ? "unset" : String(value);
75
+ }
76
+
77
+ function sameValue(value: unknown, fallback: unknown): boolean {
78
+ if (Array.isArray(value) && Array.isArray(fallback)) {
79
+ return value.length === fallback.length && value.every((entry, index) => entry === fallback[index]);
80
+ }
81
+ return value === fallback;
82
+ }
@@ -8,6 +8,11 @@ export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch";
8
8
  * results client-side by score or authority — thresholds stay opt-in.
9
9
  */
10
10
  export const DEFAULT_SEARCH_COUNT = 10;
11
+ /**
12
+ * The fallback only when neither knob is set; whenever `count` is explicitly
13
+ * configured, an unset `maxResults` follows the effective count instead (see
14
+ * resolveSearchOptions) — the two are coupled in one direction, not independent.
15
+ */
11
16
  export const DEFAULT_MAX_RESULTS = 10;
12
17
  export const DEFAULT_MIN_RERANK = 0;
13
18
  export const DEFAULT_MIN_AUTHORITY = 0;
@@ -19,13 +24,21 @@ export const MIN_TIMEOUT_MS = 1000;
19
24
  export const MAX_TIMEOUT_MS = 120000;
20
25
 
21
26
  export const MAX_QUERY_CHARS = 128;
27
+ /** DeepSeek documents ori_question as 1-128 characters; longer values are clamped, not rejected. */
28
+ export const MAX_ORI_QUESTION_CHARS = 128;
22
29
  export const MAX_URL_CHARS = 8192;
23
30
  export const MAX_ERROR_BODY_CHARS = 2000;
24
31
  export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
25
32
  export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]";
26
33
 
27
34
  export const DEFAULT_API_KEY_SOURCE = "auto";
28
- export const DEFAULT_MAX_RETRIES = 0;
35
+ /**
36
+ * 2, not 0: a transient failure inside a tool call is cheaper to retry here than
37
+ * to leave to the model, whose only fallback is re-issuing the whole billed tool
38
+ * call — the same trade-off behind OpenAI/Anthropic SDKs defaulting to 2; see
39
+ * docs/research/search-api-defaults.md.
40
+ */
41
+ export const DEFAULT_MAX_RETRIES = 2;
29
42
  export const MAX_RETRIES = 5;
30
43
  export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
31
44
  export const MAX_RETRY_DELAY_MS = 10000;
@@ -34,3 +47,4 @@ export const MAX_RETRY_DELAY_MS = 10000;
34
47
  export const API_KEY_ENV = "DEEPSEEK_API_KEY";
35
48
  export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH";
36
49
  export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
50
+ export const SEARCH_LOCALIZATION_ENV = "DEEPSEEK_SEARCH_LOCALIZATION";