@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 +15 -0
- package/README.md +33 -4
- package/package.json +1 -1
- package/src/deepseek.ts +55 -6
- package/src/index.ts +60 -7
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
|
|
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
|
|
231
|
-
|
|
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 (
|
|
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
|
+
"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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
249
|
+
const oriQuestion = oriBySession.get(context.sessionID)
|
|
250
|
+
const results = await runSearch(query, context.signal, { oriQuestion, localization })
|
|
209
251
|
return {
|
|
210
|
-
output: {
|
|
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
|
})
|