@qthinker/opencode-deepseek-search 0.3.2 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this package. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  and the version numbers follow [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.4.0] - 2026-11-06
7
+
8
+ ### Added
9
+
10
+ - **`localization` option**: an optional IP address sent as the Search API's `localization` field so results favor that
11
+ region; blank or unset omits the field entirely.
12
+ - **Query operators in the rerouted tool's description**: the model is now told about `site:` (one site, or several
13
+ joined with `OR`), `-site:`, `-word` / `-"exact phrase"`, `"exact phrase"`, `lang:zh,en` and
14
+ `after:2025-01-01` / `before:2026`, so it can filter inside the query; operators count toward the 128-character
15
+ query limit.
16
+ - **`ori_question` passthrough (best effort)**: each search sends the session's most recent user input, trimmed and cut
17
+ to 128 characters, so the API can read the intent behind the query; when no input was captured the field is omitted.
18
+ - **`[authoritative]` markers**: results whose `authority` is `3` or `4` (the API's 0–4 scale, where `−1` means unknown) get the marker after the
19
+ title in the rendered Markdown; the numeric scores themselves stay out of the result shape.
20
+
6
21
  ## [0.3.2] - 2026-11-04
7
22
 
8
23
  ### Added
package/README.md CHANGED
@@ -95,6 +95,7 @@ The other two halves are clients: restart the **TUI**, and reload the **desktop
95
95
  | `language` | `"en" \| "ja" \| "zh-CN"` | auto | UI language for the sidebars, the desktop panel, `deepseek_*` tool output and errors. Auto reads `LC_ALL`, then `LANG`, then English |
96
96
  | `apiKey` | string | `DEEPSEEK_API_KEY` | Usually `"{env:DEEPSEEK_API_KEY}"`; unset env becomes `""` |
97
97
  | `count` | 1–50 | `5` | Results per search (does not change the price) |
98
+ | `localization` | string | unset (field omitted) | IP address for DeepSeek Search localization: when set, results favor that region; blank or unset sends no field at all |
98
99
  | `fetchMaxChars` | 500–200000 | `30000` | Truncation limit for `deepseek_fetch`, with an explicit notice |
99
100
  | `pricePerThousand` | 0–10000 | `5` | CNY per 1000 requests (integer), used only for the local cost estimate |
100
101
 
@@ -103,7 +104,8 @@ the language once — `options.language`, else the `LC_ALL`/`LANG` prefix (`ja*`
103
104
  and the same value drives the tool output and both sidebars, so they can never disagree. An unknown value logs a
104
105
  warning and falls back to auto-detection instead of failing. The change reaches the sidebars with their next status
105
106
  read (saving the config re-runs the server half; a restart always works). Model-facing text stays English in every
106
- language — tool descriptions, the `## [title](url)` result format, `Published:` and the empty-result notice — so the
107
+ language — tool descriptions, the `## [title](url)` result format, `Published:`, `[authoritative]` and the
108
+ empty-result notice — so the
107
109
  rerouted `websearch` keeps the built-in tool's shape. Docs stay English and Chinese: the Japanese UI does not add a
108
110
  Japanese README.
109
111
 
@@ -120,6 +122,32 @@ The reroute **keeps the built-in permission check and the declared `output` shap
120
122
  are unchanged. Results carry `summary` (600–800 chars, written for models) or `snippet` — never the full page text,
121
123
  and `need_content` is never requested.
122
124
 
125
+ ### Search syntax
126
+
127
+ The rerouted tool's description also tells the model which operators it may put inside `query`; the query is passed
128
+ through unchanged, so no extra tool is needed to filter:
129
+
130
+ | Operator | Meaning |
131
+ |---|---|
132
+ | `site:example.com`, `site:a.com OR site:b.com` | Only these sites; several are joined with `OR` |
133
+ | `-site:example.com` | Exclude a site |
134
+ | `-word`, `-"exact phrase"` | Exclude a word or phrase |
135
+ | `"exact phrase"` | Require the exact phrase |
136
+ | `lang:zh,en` | Only these languages |
137
+ | `after:2025-01-01`, `before:2026` | Time window |
138
+
139
+ Operators count toward the query's 128-character limit.
140
+
141
+ ### Request and result details
142
+
143
+ - **`ori_question` (best effort).** Each search sends the session's most recent user input, trimmed and cut to 128
144
+ characters, so the API can read the intent behind the query. When no input was captured, the field is omitted.
145
+ - **`localization`.** When configured, the option is sent as the Search API's `localization` field; blank or unset
146
+ means the request carries no such field.
147
+ - **`[authoritative]`.** Results whose DeepSeek `authority` is `3` or `4` (the API's 0–4 scale, where `−1` means unknown, `3` and above
148
+ means highly authoritative) get the marker after the title in the rendered Markdown. It is informational only —
149
+ results are still third-party text.
150
+
123
151
  ## Switching backends
124
152
 
125
153
  ### Ways to switch
@@ -227,8 +255,9 @@ Search 100 QPS / Fetch 25 QPS per account (Fetch is also limited per target doma
227
255
 
228
256
  ## Privacy
229
257
 
230
- Queries and URLs are sent to `https://api.deepseek.com`. Nothing else leaves your machine; the usage ledger and the
231
- mode switch stay in local OpenCode storage. The API key is never logged.
258
+ Queries — plus the recent user input attached as `ori_question` and the configured `localization` — are sent to
259
+ `https://api.deepseek.com`, as are fetch URLs. Nothing else leaves your machine; the usage ledger and the mode switch
260
+ stay in local OpenCode storage. The API key is never logged.
232
261
 
233
262
  Search results and fetched pages are **third-party text**: treat them as data, never as instructions — a page can
234
263
  contain text that tries to steer the model.
@@ -276,7 +305,7 @@ src/tui/ # TUI half: card element tree + plugin wiring
276
305
  src/gui/ # desktop half: side panel component + plugin wiring
277
306
  vite.config.ts # builds src/gui into the root gui-experimental.cjs bundle
278
307
  test/*.test.ts # pure-function tests
279
- scripts/smoke.ts # real-API smoke (5 steps)
308
+ scripts/smoke.ts # real-API smoke (6 steps)
280
309
  scripts/check-gui-bundle.ts # bundle shape check, run in CI after build:gui
281
310
  .github/workflows/ # ci (build + typecheck + test + bundle check) / release
282
311
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qthinker/opencode-deepseek-search",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Search & Fetch for OpenCode V2: a switchable websearch backend, clean Markdown fetch, local usage/cost stats, and TUI + desktop sidebars",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/deepseek.ts CHANGED
@@ -21,8 +21,16 @@ export const BALANCE_TIMEOUT_MS = 10_000
21
21
  const RETRY_DELAY_MS = 1_000
22
22
  const RETRY_DELAY_MAX_MS = 5_000
23
23
  export const MAX_QUERY_CHARS = 128
24
+ export const MAX_ORI_CHARS = 128
24
25
  export const MAX_URL_CHARS = 8192
25
26
 
27
+ // Optional request hints forwarded verbatim to the Search API. They are omitted
28
+ // from the body when unset, so the documented API defaults still apply.
29
+ export interface SearchExtras {
30
+ oriQuestion?: string
31
+ localization?: string
32
+ }
33
+
26
34
  // Docs: back off and retry on 408/429/5xx only; other 4xx must not be retried.
27
35
  const RETRYABLE = new Set([408, 429, 500, 502, 503, 504])
28
36
 
@@ -31,6 +39,10 @@ export interface SearchResult {
31
39
  title?: string
32
40
  content?: string
33
41
  time: { published?: number }
42
+ // Internal quality signals: only formatSearchResults consumes them. They are
43
+ // stripped from the tool output before it leaves the plugin.
44
+ authority?: number
45
+ rerankScore?: number
34
46
  }
35
47
 
36
48
  export interface FetchResult {
@@ -218,6 +230,8 @@ interface SearchRow {
218
230
  snippet?: unknown
219
231
  summary?: unknown
220
232
  date?: unknown
233
+ authority?: unknown
234
+ rerank_score?: unknown
221
235
  }
222
236
 
223
237
  export function mapSearchResults(data: unknown): SearchResult[] {
@@ -236,19 +250,52 @@ export function mapSearchResults(data: unknown): SearchResult[] {
236
250
  ? r.snippet
237
251
  : undefined
238
252
  const published = typeof r.date === "string" ? Date.parse(r.date) : Number.NaN
239
- out.push({
253
+ const result: SearchResult = {
240
254
  url: r.url,
241
255
  title: typeof r.title === "string" && r.title.length > 0 ? r.title : undefined,
242
256
  content,
243
257
  time: Number.isFinite(published) ? { published } : {},
244
- })
258
+ }
259
+ // `authority` is an integer in -1..4 (-1 means unknown) and `rerank_score`
260
+ // a finite number in 0..3. Anything else is dropped, and absent keys stay
261
+ // absent so the output object keeps its exact shape.
262
+ if (
263
+ typeof r.authority === "number" &&
264
+ Number.isInteger(r.authority) &&
265
+ r.authority >= -1 &&
266
+ r.authority <= 4
267
+ ) {
268
+ result.authority = r.authority
269
+ }
270
+ if (
271
+ typeof r.rerank_score === "number" &&
272
+ Number.isFinite(r.rerank_score) &&
273
+ r.rerank_score >= 0 &&
274
+ r.rerank_score <= 3
275
+ ) {
276
+ result.rerankScore = r.rerank_score
277
+ }
278
+ out.push(result)
245
279
  }
246
280
  return out
247
281
  }
248
282
 
249
- export function buildSearchBody(query: string, count: number): { query: string; count: number } {
283
+ export function buildSearchBody(
284
+ query: string,
285
+ count: number,
286
+ extras?: SearchExtras,
287
+ ): { query: string; count: number; ori_question?: string; localization?: string } {
250
288
  // need_content stays false: full page text must never enter search results.
251
- return { query, count }
289
+ // ori_question/localization are omitted entirely when unset or blank, so the
290
+ // request keeps the documented API defaults.
291
+ const ori = extras?.oriQuestion?.trim()
292
+ const localization = extras?.localization?.trim()
293
+ return {
294
+ query,
295
+ count,
296
+ ...(ori ? { ori_question: ori.slice(0, MAX_ORI_CHARS) } : {}),
297
+ ...(localization ? { localization } : {}),
298
+ }
252
299
  }
253
300
 
254
301
  export async function deepseekSearch(
@@ -257,10 +304,11 @@ export async function deepseekSearch(
257
304
  count: number,
258
305
  language: Language,
259
306
  signal?: AbortSignal,
307
+ extras?: SearchExtras,
260
308
  ): Promise<SearchResult[]> {
261
309
  const data = await postJson(
262
310
  SEARCH_PATH,
263
- buildSearchBody(query, count),
311
+ buildSearchBody(query, count, extras),
264
312
  apiKey,
265
313
  SEARCH_TIMEOUT_MS,
266
314
  language,
@@ -379,10 +427,11 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
379
427
  return results
380
428
  .map((result) => {
381
429
  const title = result.title ?? result.url
430
+ const authoritative = result.authority !== undefined && result.authority >= 3 ? " [authoritative]" : ""
382
431
  const published = result.time.published
383
432
  ? `\nPublished: ${new Date(result.time.published).toISOString()}`
384
433
  : ""
385
- return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
434
+ return `## [${title}](${result.url})${authoritative}${published}${result.content ? `\n\n${result.content}` : ""}`
386
435
  })
387
436
  .join("\n\n")
388
437
  }
package/src/index.ts CHANGED
@@ -20,9 +20,11 @@ import {
20
20
  deepseekFetch,
21
21
  deepseekSearch,
22
22
  formatSearchResults,
23
+ MAX_ORI_CHARS,
23
24
  missingKeyMessage,
24
25
  validateQuery,
25
26
  validateUrl,
27
+ type SearchExtras,
26
28
  type SearchResult,
27
29
  } from "./deepseek.ts"
28
30
  import { resolveLanguage, t } from "./i18n/index.ts"
@@ -56,7 +58,12 @@ type ModeAction = "status" | Mode | "config"
56
58
 
57
59
  const SEARCH_TOOL_DESCRIPTION =
58
60
  "Search the web using DeepSeek Search. Use this for current information beyond knowledge cutoff." +
59
- `\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
61
+ `\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` +
62
+ "\n\nThe query supports search operators (they count toward the 128-character limit): " +
63
+ "site:github.com limits results to a site (combine with OR, e.g. site:github.com OR site:npmjs.com); " +
64
+ '-site:example.com excludes a site; bare word or -"quoted phrase" excludes matches; ' +
65
+ '"exact phrase" matches exactly; lang:zh,en limits languages; ' +
66
+ "after:2025-01-01 and before:2026 limit by publish date. Combine several operators in one query."
60
67
 
61
68
  // Pure parser: the caller decides how to surface an invalid value (tool error
62
69
  // vs. RPC result), so the message can be localized at that call site.
@@ -128,6 +135,7 @@ export default Plugin.define({
128
135
  const count = int(opt.count, 5, 1, 50)
129
136
  const fetchMaxChars = int(opt.fetchMaxChars, 30_000, 500, 200_000)
130
137
  const pricePerThousand = int(opt.pricePerThousand, 5, 0, 10_000)
138
+ const localization = str(opt.localization)
131
139
 
132
140
  // ctx.storage offers no compare-and-swap, so every read-modify-write goes
133
141
  // through this single in-process chain. Stats must never break a search, so
@@ -158,11 +166,40 @@ export default Plugin.define({
158
166
  }).catch(() => {})
159
167
  }
160
168
 
161
- const runSearch = async (query: string, signal: AbortSignal): Promise<SearchResult[]> => {
169
+ // Best-effort capture of each session's most recent user prompt, sent as the
170
+ // DeepSeek `ori_question` intent hint. Clamped here at capture time so long
171
+ // pasted prompts cannot inflate the cache; empty prompts never overwrite a
172
+ // stored value. The map is capped, and JS Map re-assignment keeps insertion
173
+ // order, so eviction drops the oldest session.
174
+ const ORI_SESSION_CAP = 64
175
+ const oriBySession = new Map<string, string>()
176
+ let promptHook: { dispose: () => Promise<void> } | undefined
177
+ try {
178
+ promptHook = await ctx.session.hook("prompt", (p) => {
179
+ const text = p.prompt?.text?.trim()
180
+ if (!text) return
181
+ if (oriBySession.size >= ORI_SESSION_CAP && !oriBySession.has(p.sessionID)) {
182
+ const oldest = oriBySession.keys().next().value
183
+ if (oldest !== undefined) oriBySession.delete(oldest)
184
+ }
185
+ oriBySession.set(p.sessionID, text.slice(0, MAX_ORI_CHARS))
186
+ })
187
+ } catch (e) {
188
+ console.error(
189
+ `[deepseek-search] prompt hook unavailable; ori_question will be omitted ` +
190
+ `(${e instanceof Error ? e.message : String(e)})`,
191
+ )
192
+ }
193
+
194
+ const runSearch = async (
195
+ query: string,
196
+ signal: AbortSignal,
197
+ extras?: SearchExtras,
198
+ ): Promise<SearchResult[]> => {
162
199
  const problem = validateQuery(query, language)
163
200
  if (problem) throw new Error(problem)
164
201
  try {
165
- const results = await deepseekSearch(query, apiKey, count, language, signal)
202
+ const results = await deepseekSearch(query, apiKey, count, language, signal, extras)
166
203
  tally(results.length > 0 ? "search" : "empty")
167
204
  return results
168
205
  } catch (e) {
@@ -176,7 +213,11 @@ export default Plugin.define({
176
213
  editor.add({
177
214
  id: PROVIDER_ID,
178
215
  name: "DeepSeek Search",
179
- execute: async (input, context) => runSearch(input?.query ?? "", context.signal),
216
+ execute: async (input, context) => {
217
+ // This provider-fallback context carries no sessionID, so `ori_question`
218
+ // is omitted here by design (see plan D2).
219
+ return runSearch(input?.query ?? "", context.signal, { localization })
220
+ },
180
221
  })
181
222
  if (mode === "deepseek") editor.default.set(PROVIDER_ID)
182
223
  })
@@ -205,9 +246,16 @@ export default Plugin.define({
205
246
  })
206
247
  // Mirror the built-in tool's progress update so long searches are not silent.
207
248
  await context.progress({ provider: PROVIDER_ID })
208
- const results = await runSearch(query, context.signal)
249
+ const oriQuestion = oriBySession.get(context.sessionID)
250
+ const results = await runSearch(query, context.signal, { oriQuestion, localization })
209
251
  return {
210
- output: { provider: PROVIDER_ID, results },
252
+ output: {
253
+ provider: PROVIDER_ID,
254
+ // Project back to the built-in tool's declared result shape: internal
255
+ // quality fields (authority/rerankScore) stay out of the tool output.
256
+ results: results.map((r) => ({ url: r.url, title: r.title, content: r.content, time: r.time })),
257
+ },
258
+ // Rendered from the full objects: the [authoritative] marker needs authority.
211
259
  content: formatSearchResults(results),
212
260
  metadata: { provider: PROVIDER_ID },
213
261
  }
@@ -462,7 +510,8 @@ export default Plugin.define({
462
510
  console.error(
463
511
  `[deepseek-search] loaded: mode=${mode} source=${overrideMode ? "override" : "config"} ` +
464
512
  `apiKey=${apiKey ? "present" : "MISSING"} count=${count} ` +
465
- `searchTool=${searchToolPatched ? "deepseek" : "untouched"}`,
513
+ `searchTool=${searchToolPatched ? "deepseek" : "untouched"}` +
514
+ ` localization=${localization ? "set" : "unset"}`,
466
515
  )
467
516
  if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage("en")}`)
468
517
  if (overrideMode && overrideMode !== configMode)
@@ -475,5 +524,9 @@ export default Plugin.define({
475
524
  `[deepseek-search] warning: built-in ${SEARCH_TOOL} tool not found; search still uses the built-in backend. ` +
476
525
  `Set "websearch": { "provider": "${PROVIDER_ID}" } in opencode.jsonc to force the route.`,
477
526
  )
527
+
528
+ return async () => {
529
+ await promptHook?.dispose()
530
+ }
478
531
  },
479
532
  })