deepseek-web-retrieval 0.2.0 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/README.md +13 -9
  3. package/dist/cli.js +4228 -178
  4. package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
  5. package/docs/agents/codex.md +193 -0
  6. package/docs/agents/domain.md +51 -0
  7. package/docs/agents/issue-tracker.md +45 -0
  8. package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
  9. package/docs/{pi.md → agents/pi.md} +35 -7
  10. package/docs/agents/triage-labels.md +15 -0
  11. package/docs/design/installer-design.md +184 -0
  12. package/docs/hosts.jsonc +59 -33
  13. package/docs/integration.md +45 -52
  14. package/docs/research/control-matrix.md +257 -0
  15. package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
  16. package/package.json +5 -3
  17. package/src/core/auth.ts +1 -1
  18. package/src/core/client.ts +42 -249
  19. package/src/core/constants.ts +5 -0
  20. package/src/core/descriptions.ts +19 -0
  21. package/src/core/format.ts +31 -1
  22. package/src/core/jsonc.ts +16 -0
  23. package/src/core/options.ts +2 -3
  24. package/src/core/search.ts +5 -7
  25. package/src/core/transport.ts +253 -0
  26. package/src/core/types.ts +13 -11
  27. package/src/installer/apply.ts +176 -0
  28. package/src/installer/cli.ts +215 -0
  29. package/src/installer/detect.ts +85 -0
  30. package/src/installer/formats/index.ts +58 -0
  31. package/src/installer/formats/json.ts +151 -0
  32. package/src/installer/formats/rules.ts +62 -0
  33. package/src/installer/formats/toml.ts +163 -0
  34. package/src/installer/formats/value.ts +57 -0
  35. package/src/installer/hosts/codex.ts +128 -0
  36. package/src/installer/hosts/index.ts +134 -0
  37. package/src/installer/hosts/opencode.ts +118 -0
  38. package/src/installer/hosts/pi.ts +115 -0
  39. package/src/installer/hosts/shared.ts +31 -0
  40. package/src/installer/paths.ts +18 -0
  41. package/src/installer/render.ts +62 -0
  42. package/src/installer/revert.ts +61 -0
  43. package/src/installer/rulesContent.ts +32 -0
  44. package/src/installer/state.ts +63 -0
  45. package/src/installer/types.ts +126 -0
  46. package/src/installer/uninstall.ts +33 -0
  47. package/src/mcp/auth.ts +2 -1
  48. package/src/mcp/cli.ts +21 -5
  49. package/src/mcp/config.ts +70 -27
  50. package/src/mcp/server.ts +81 -65
  51. package/src/opencode/auth.ts +3 -2
  52. package/src/opencode/plugin.ts +93 -56
  53. package/src/mcp/format.ts +0 -31
@@ -1,24 +1,27 @@
1
- import {
2
- FETCH_ENDPOINT,
3
- MAX_ERROR_BODY_CHARS,
4
- MAX_QUERY_CHARS,
5
- MAX_RESPONSE_BYTES,
6
- MAX_RETRY_DELAY_MS,
7
- SEARCH_ENDPOINT,
8
- } from "./constants"
1
+ import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants"
9
2
  import { assertFetchHostPolicy } from "./domains"
10
- import { formatApiError, formatTimeoutError, redactSecrets, truncate } from "./format"
3
+ import { formatFetchedContent } from "./format"
11
4
  import { buildQuery, processResults } from "./search"
5
+ import { postJson } from "./transport"
6
+ import { parseFetchUrl } from "./url"
12
7
  import type {
13
8
  DeepSeekSearchResult,
9
+ FetchedDocument,
14
10
  FetchedPage,
15
11
  FetchOptions,
16
12
  RequestAuth,
17
- RetryConfig,
13
+ RetryOptions,
18
14
  SearchOptions,
19
- WebSearchResult,
15
+ SearchResult,
20
16
  } from "./types"
21
17
 
18
+ export { DeepSeekApiError } from "./transport"
19
+
20
+ /** The shorter of the configured and per-call timeout always wins. */
21
+ function effectiveTimeout(configuredMs: number, perCallMs: number | undefined): number {
22
+ return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs)
23
+ }
24
+
22
25
  interface SearchResponse {
23
26
  readonly results?: unknown
24
27
  }
@@ -31,42 +34,16 @@ interface FetchResponse {
31
34
  export interface RequestOptions {
32
35
  readonly signal: AbortSignal
33
36
  readonly auth: RequestAuth
34
- readonly retry: RetryConfig
35
- }
36
-
37
- export class DeepSeekApiError extends Error {
38
- readonly operation: string
39
- readonly status: number
40
- readonly body: string
41
- readonly retryable: boolean
42
- readonly retryAfterMs?: number
43
-
44
- constructor(input: {
45
- operation: string
46
- status: number
47
- body: string
48
- retryAfterMs?: number
49
- }) {
50
- const body = redactSecrets(input.body)
51
- super(formatApiError(input.operation, input.status, body))
52
- this.name = "DeepSeekApiError"
53
- this.operation = input.operation
54
- this.status = input.status
55
- this.body = body
56
- this.retryable =
57
- input.status === 408 ||
58
- input.status === 425 ||
59
- input.status === 429 ||
60
- (input.status >= 500 && input.status <= 599)
61
- this.retryAfterMs = input.retryAfterMs
62
- }
37
+ readonly retry: RetryOptions
38
+ /** Per-call cap in milliseconds; the shorter of this and the configured timeout wins. */
39
+ readonly timeoutMs?: number
63
40
  }
64
41
 
65
42
  export async function searchWeb(
66
43
  query: string,
67
44
  options: SearchOptions,
68
45
  request: RequestOptions,
69
- ): Promise<WebSearchResult[]> {
46
+ ): Promise<SearchResult[]> {
70
47
  const trimmed = query.trim()
71
48
  if (!trimmed) throw new Error("DeepSeek search query must not be empty")
72
49
  if (Array.from(trimmed).length > MAX_QUERY_CHARS) {
@@ -83,6 +60,7 @@ export async function searchWeb(
83
60
  )
84
61
  }
85
62
 
63
+ const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs)
86
64
  const payload = await postJson<SearchResponse>(
87
65
  SEARCH_ENDPOINT,
88
66
  {
@@ -91,8 +69,7 @@ export async function searchWeb(
91
69
  need_content: false,
92
70
  },
93
71
  "DeepSeek Search",
94
- request,
95
- options.timeoutMs,
72
+ { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout },
96
73
  )
97
74
 
98
75
  if (!Array.isArray(payload.results)) {
@@ -109,12 +86,12 @@ export async function fetchPage(
109
86
  // Enforced here so direct core consumers cannot bypass the policy. Adapters
110
87
  // may also pre-check to reject before a permission prompt.
111
88
  assertFetchHostPolicy(url, options)
89
+ const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs)
112
90
  const payload = await postJson<FetchResponse>(
113
91
  FETCH_ENDPOINT,
114
92
  { url: url.href },
115
93
  "DeepSeek Fetch",
116
- request,
117
- options.timeoutMs,
94
+ { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout },
118
95
  )
119
96
  const title = typeof payload.title === "string" ? payload.title.trim() : ""
120
97
  const content = typeof payload.content === "string" ? payload.content : ""
@@ -124,213 +101,29 @@ export async function fetchPage(
124
101
  return { url: url.href, title, content }
125
102
  }
126
103
 
127
- async function postJson<T>(
128
- endpoint: string,
129
- body: unknown,
130
- operation: string,
131
- options: RequestOptions,
132
- timeoutMs: number,
133
- ): Promise<T> {
134
- const attempts = options.retry.maxRetries + 1
135
- let lastError: unknown
136
-
137
- for (let attempt = 0; attempt < attempts; attempt++) {
138
- try {
139
- return await requestOnce<T>(endpoint, body, operation, options, timeoutMs)
140
- } catch (error) {
141
- lastError = error
142
- if (!(error instanceof DeepSeekApiError) || !error.retryable || attempt === attempts - 1) {
143
- throw error
144
- }
145
- await sleep(retryDelay(error.retryAfterMs, options.retry.baseDelayMs, attempt), options.signal)
146
- }
147
- }
148
-
149
- throw lastError
150
- }
151
-
152
- async function requestOnce<T>(
153
- endpoint: string,
154
- body: unknown,
155
- operation: string,
156
- options: RequestOptions,
157
- timeoutMs: number,
158
- ): Promise<T> {
159
- return fetchWithTimeout(
160
- endpoint,
161
- {
162
- method: "POST",
163
- headers: {
164
- Authorization: `Bearer ${options.auth.apiKey}`,
165
- "Content-Type": "application/json",
166
- },
167
- body: JSON.stringify(body),
168
- },
169
- options.signal,
170
- timeoutMs,
171
- operation,
172
- async (response) => {
173
- if (!response.ok) {
174
- const text = await readBoundedText(response, MAX_ERROR_BODY_CHARS)
175
- throw new DeepSeekApiError({
176
- operation,
177
- status: response.status,
178
- body: text,
179
- retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
180
- })
181
- }
182
-
183
- const text = await readResponseText(response, MAX_RESPONSE_BYTES, operation)
184
- if (!text.trim()) throw new Error(`${operation} returned an empty response body`)
185
-
186
- try {
187
- return JSON.parse(text) as T
188
- } catch {
189
- throw new Error(`${operation} returned invalid JSON: ${truncate(redactSecrets(text), 500)}`)
190
- }
191
- },
192
- )
104
+ /** Parses the URL and enforces the host policy in one call, returning the fetch target. */
105
+ export function inspectFetchTarget(url: string, options: FetchOptions): URL {
106
+ const target = parseFetchUrl(url)
107
+ assertFetchHostPolicy(target, options)
108
+ return target
193
109
  }
194
110
 
195
111
  /**
196
- * Runs the request and the callback that consumes its body inside one
197
- * timeout/abort scope. The timer must stay active while the response body is
198
- * read, otherwise a slow or stalled body can outlive the configured timeout.
112
+ * The full fetch pipeline adapters share: fetch the page (a URL skips
113
+ * re-parsing) and format it for the model within its budget.
199
114
  */
200
- async function fetchWithTimeout<T>(
201
- endpoint: string,
202
- init: RequestInit,
203
- signal: AbortSignal,
204
- timeoutMs: number,
205
- operation: string,
206
- handleResponse: (response: Response) => Promise<T>,
207
- ): Promise<T> {
208
- const controller = new AbortController()
209
- let timedOut = false
210
- const forwardAbort = () => controller.abort(signal.reason)
211
- if (signal.aborted) forwardAbort()
212
- else signal.addEventListener("abort", forwardAbort, { once: true })
213
-
214
- const timer = signal.aborted
215
- ? undefined
216
- : setTimeout(() => {
217
- timedOut = true
218
- controller.abort(new Error("timeout"))
219
- }, timeoutMs)
220
-
221
- try {
222
- const response = await fetch(endpoint, { ...init, signal: controller.signal })
223
- return await handleResponse(response)
224
- } catch (error) {
225
- if (timedOut) throw new Error(formatTimeoutError(operation, timeoutMs))
226
- if (signal.aborted) throw abortReason(signal, operation)
227
- throw error
228
- } finally {
229
- if (timer !== undefined) clearTimeout(timer)
230
- signal.removeEventListener("abort", forwardAbort)
231
- }
232
- }
233
-
234
- function parseRetryAfter(value: string | null): number | undefined {
235
- if (!value) return undefined
236
- const trimmed = value.trim()
237
- if (!trimmed) return undefined
238
-
239
- if (/^\d+$/.test(trimmed)) {
240
- return Math.min(MAX_RETRY_DELAY_MS, Number(trimmed) * 1000)
241
- }
242
-
243
- const timestamp = Date.parse(trimmed)
244
- if (!Number.isFinite(timestamp)) return undefined
245
- return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, timestamp - Date.now()))
246
- }
247
-
248
- function retryDelay(retryAfterMs: number | undefined, baseDelayMs: number, attempt: number): number {
249
- if (retryAfterMs !== undefined) return retryAfterMs
250
- return Math.min(MAX_RETRY_DELAY_MS, baseDelayMs * 2 ** attempt)
251
- }
252
-
253
- function sleep(ms: number, signal: AbortSignal): Promise<void> {
254
- if (ms <= 0) return Promise.resolve()
255
- return new Promise((resolve, reject) => {
256
- const timer = setTimeout(() => {
257
- signal.removeEventListener("abort", onAbort)
258
- resolve()
259
- }, ms)
260
- const onAbort = () => {
261
- clearTimeout(timer)
262
- reject(abortReason(signal, "DeepSeek request"))
263
- }
264
- if (signal.aborted) {
265
- clearTimeout(timer)
266
- reject(abortReason(signal, "DeepSeek request"))
267
- return
268
- }
269
- signal.addEventListener("abort", onAbort, { once: true })
270
- })
271
- }
272
-
273
- function abortReason(signal: AbortSignal, operation: string): unknown {
274
- return signal.reason instanceof Error ? signal.reason : new Error(`${operation} request aborted`)
275
- }
276
-
277
- /** Reads at most `maxChars + 1` characters, enough for formatApiError to add its marker. */
278
- async function readBoundedText(response: Response, maxChars: number): Promise<string> {
279
- if (!response.body) return truncate(await response.text(), maxChars + 1)
280
-
281
- const reader = response.body.getReader()
282
- const decoder = new TextDecoder()
283
- const limit = maxChars + 1
284
- let text = ""
285
- let chars = 0
286
- try {
287
- while (chars < limit) {
288
- const { done, value } = await reader.read()
289
- if (done) break
290
- const chunk = decoder.decode(value, { stream: true })
291
- text += chunk
292
- chars += Array.from(chunk).length
293
- }
294
- text += decoder.decode()
295
- } finally {
296
- await reader.cancel().catch(() => undefined)
297
- reader.releaseLock()
298
- }
299
- return truncate(text, limit)
300
- }
301
-
302
- /** Reads a successful response body, rejecting payloads above `maxBytes`. */
303
- async function readResponseText(
304
- response: Response,
305
- maxBytes: number,
306
- operation: string,
307
- ): Promise<string> {
308
- if (!response.body) {
309
- const text = await response.text()
310
- if (new TextEncoder().encode(text).byteLength > maxBytes) {
311
- throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
312
- }
313
- return text
314
- }
315
-
316
- const reader = response.body.getReader()
317
- const decoder = new TextDecoder()
318
- let text = ""
319
- let bytes = 0
320
- try {
321
- while (true) {
322
- const { done, value } = await reader.read()
323
- if (done) break
324
- bytes += value.byteLength
325
- if (bytes > maxBytes) {
326
- throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
327
- }
328
- text += decoder.decode(value, { stream: true })
329
- }
330
- text += decoder.decode()
331
- } finally {
332
- await reader.cancel().catch(() => undefined)
333
- reader.releaseLock()
115
+ export async function fetchDocument(
116
+ url: string | URL,
117
+ options: FetchOptions,
118
+ request: RequestOptions,
119
+ ): Promise<FetchedDocument> {
120
+ if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL')
121
+ const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request)
122
+ const formatted = formatFetchedContent(page.title, page.content, options.maxChars)
123
+ return {
124
+ url: page.url,
125
+ title: page.title,
126
+ content: formatted.content,
127
+ truncated: formatted.truncated,
334
128
  }
335
- return text
336
129
  }
@@ -23,3 +23,8 @@ export const DEFAULT_MAX_RETRIES = 0
23
23
  export const MAX_RETRIES = 5
24
24
  export const DEFAULT_RETRY_BASE_DELAY_MS = 250
25
25
  export const MAX_RETRY_DELAY_MS = 10000
26
+
27
+ /** The single source for every DEEPSEEK_* environment variable name. */
28
+ export const API_KEY_ENV = "DEEPSEEK_API_KEY"
29
+ export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH"
30
+ export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH"
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Capability fact fragments shared by every host's tool descriptions. Hosts
3
+ * compose these with their own framing; the facts must never drift per host.
4
+ */
5
+
6
+ export const FETCH_SERVICE_LIMITS =
7
+ "DeepSeek does not render JavaScript and cannot reach private networks; PDFs, images, and other binary files are unsupported."
8
+
9
+ export const FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size."
10
+
11
+ export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.'
12
+
13
+ export const SEARCH_RANKED_FILTERED =
14
+ "Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum."
15
+
16
+ export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error."
17
+
18
+ export const SEARCH_QUERY_OPERATORS =
19
+ "Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters."
@@ -2,7 +2,7 @@ import {
2
2
  FETCH_TRUNCATION_MARKER,
3
3
  MAX_ERROR_BODY_CHARS,
4
4
  } from "./constants"
5
- import type { FormattedFetchContent } from "./types"
5
+ import type { FormattedFetchContent, SearchResult } from "./types"
6
6
 
7
7
  /** Truncates by Unicode code points so emoji and surrogate pairs are not split. */
8
8
  export function truncate(value: string, maxChars: number): string {
@@ -95,3 +95,33 @@ export function formatApiError(operation: string, status: number, body: string):
95
95
  export function formatTimeoutError(operation: string, timeoutMs: number): string {
96
96
  return `${operation} timed out after ${timeoutMs} ms`
97
97
  }
98
+
99
+ /**
100
+ * Renders processed search results as Markdown for a text content block.
101
+ * Hosts that ignore structured output read this text.
102
+ */
103
+ export function formatSearchResults(results: readonly SearchResult[]): string {
104
+ if (results.length === 0) return "No results found."
105
+
106
+ return results
107
+ .map((result, index) => {
108
+ const title = result.title?.trim() || result.url
109
+ const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})`]
110
+ if (result.content) {
111
+ lines.push(` ${result.content.replaceAll("\n", " ")}`)
112
+ }
113
+ if (result.published !== undefined) {
114
+ lines.push(` Published: ${formatPublished(result.published)}`)
115
+ }
116
+ return lines.join("\n")
117
+ })
118
+ .join("\n\n")
119
+ }
120
+
121
+ function escapeBrackets(value: string): string {
122
+ return value.replaceAll("[", "\\[").replaceAll("]", "\\]")
123
+ }
124
+
125
+ function formatPublished(epochMs: number): string {
126
+ return new Date(epochMs).toISOString().slice(0, 10)
127
+ }
@@ -0,0 +1,16 @@
1
+ import { parse } from "jsonc-parser/lib/esm/main.js"
2
+
3
+ /**
4
+ * Parses JSONC (JSON with comments and trailing commas) via jsonc-parser,
5
+ * the library behind VS Code's JSON support. Throws a SyntaxError when the
6
+ * text has errors beyond tolerated comments and trailing commas.
7
+ */
8
+ export function parseJsonc(text: string): unknown {
9
+ const errors: { error: number; offset: number; length: number }[] = []
10
+ const value = parse(text, errors, { allowTrailingComma: true })
11
+ if (errors.length > 0) {
12
+ const first = errors[0]
13
+ throw new SyntaxError(`JSONC parse error at offset ${first.offset} (code ${first.error})`)
14
+ }
15
+ return value
16
+ }
@@ -46,7 +46,7 @@ export function resolveSearchOptions(input?: PluginOptions): SearchOptions {
46
46
  language: optionalText(input?.language),
47
47
  maxSummaryChars: clampInt(input?.maxSummaryChars, DEFAULT_MAX_SUMMARY_CHARS, 0, 1_000_000),
48
48
  timeoutMs: clampInt(input?.searchTimeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
49
- setDefault: input?.setDefaultWebsearch === true,
49
+ enabled: input?.searchEnabled !== false,
50
50
  }
51
51
  }
52
52
 
@@ -56,9 +56,8 @@ export function resolveFetchOptions(input?: PluginOptions): FetchOptions {
56
56
  allowedDomains: normalizeDomains(input?.fetchAllowedDomains),
57
57
  blockedDomains: normalizeDomains(input?.fetchBlockedDomains),
58
58
  timeoutMs: clampInt(input?.fetchTimeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
59
- overrideWebfetch: input?.fetchOverrideWebfetch === true,
60
- registerTool: input?.fetchRegisterTool !== false,
61
59
  blockPrivateHosts: input?.fetchBlockPrivateHosts !== false,
60
+ enabled: input?.fetchEnabled !== false,
62
61
  }
63
62
  }
64
63
 
@@ -1,6 +1,6 @@
1
1
  import { normalizeDomains } from "./domains"
2
2
  import { truncate } from "./format"
3
- import type { DeepSeekSearchResult, SearchOptions, WebSearchResult } from "./types"
3
+ import type { DeepSeekSearchResult, SearchOptions, SearchResult } from "./types"
4
4
  import { normalizeResultUrl } from "./url"
5
5
 
6
6
  /**
@@ -29,15 +29,15 @@ export function parsePublished(value: unknown): number | undefined {
29
29
  }
30
30
 
31
31
  /**
32
- * Filters and maps DeepSeek results to OpenCode's WebSearch.Result shape.
32
+ * Filters and maps DeepSeek results into the neutral SearchResult shape.
33
33
  * Missing/non-finite rerank_score never passes. Authority -1/missing is
34
34
  * "unknown" and only passes when allowUnknownAuthority is enabled.
35
35
  */
36
36
  export function processResults(
37
37
  raw: readonly DeepSeekSearchResult[],
38
38
  options: SearchOptions,
39
- ): WebSearchResult[] {
40
- const results: WebSearchResult[] = []
39
+ ): SearchResult[] {
40
+ const results: SearchResult[] = []
41
41
  const seen = new Set<string>()
42
42
 
43
43
  for (const item of raw) {
@@ -64,15 +64,13 @@ export function processResults(
64
64
  optionalText(item.summary) ?? optionalText(item.snippet) ?? "",
65
65
  options.maxSummaryChars,
66
66
  )
67
- const time: { published?: number } = {}
68
67
  const published = parsePublished(item.date)
69
- if (published !== undefined) time.published = published
70
68
  const title = optionalText(item.title)
71
69
  results.push({
72
70
  url,
73
71
  ...(title ? { title } : {}),
74
72
  ...(content ? { content } : {}),
75
- time,
73
+ ...(published !== undefined ? { published } : {}),
76
74
  })
77
75
  }
78
76