@vfvrpq/dsh-search 0.0.0-stage → 0.1.1
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 +12 -0
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/cordis.patch.yml +26 -0
- package/lib/client.js +167 -0
- package/lib/config.js +90 -0
- package/lib/format.js +105 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +79 -0
- package/lib/keys.js +70 -0
- package/lib/prompt.js +25 -0
- package/lib/query.js +53 -0
- package/lib/tools/fetch.js +57 -0
- package/lib/tools/research.js +155 -0
- package/lib/tools/search.js +78 -0
- package/lib/tools/shared.js +51 -0
- package/lib/tools.js +21 -0
- package/package.json +69 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
首个版本。
|
|
6
|
+
|
|
7
|
+
- `deepseek_search`:结构化搜索结果,支持 `site:` / `-site:` / 排除词 / 精确短语 / `lang:` / `after:` / `before:`,`count` 1–50,可选 `need_content`。
|
|
8
|
+
- `deepseek_fetch`:单页转 Markdown,正文按 `maxChars` 截断并标注。
|
|
9
|
+
- `deepseek_research`:搜索 + 抓前 N 页正文,返回 `1 + N` 计费口径。
|
|
10
|
+
- key 四层解析(配置 / 环境变量 / `~/.secrets/keys.env` / `$DSH_HOME/.credentials.yaml`),key 不落 profile。
|
|
11
|
+
- 超时、429/5xx 退避重试、状态码到可执行提示的翻译。
|
|
12
|
+
- 单测 22 项(mock fetch)+ 真机自测脚本。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 439436269-ctrl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vfvrpq/dsh-search
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek 官方 Search / Fetch 接口的 DeepSeek Harness 插件:三个工具、一个配置项、零运行时依赖。
|
|
4
|
+
|
|
5
|
+
给 agent 的是**结构化**网页结果——rerank 分、权威度、发布时间、模型友好长摘要——而不是一坨 HTML。
|
|
6
|
+
比内置 `web_search` / `web_fetch` 多出来的东西:搜索语法、条数与正文可控、成本可推算。
|
|
7
|
+
|
|
8
|
+
## 装
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
# 从 npm(已发布版本)
|
|
12
|
+
dsh plugin --profile desktop add @vfvrpq/dsh-search@0.1.0
|
|
13
|
+
|
|
14
|
+
# 或从源码包(插件 import 了 @deepseek-ai/*,必须走 tgz,目录软链会让宿主包解析不到)
|
|
15
|
+
pnpm pack
|
|
16
|
+
dsh plugin --profile desktop add /绝对路径/vfvrpq-dsh-search-0.1.0.tgz
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
装完工具即出现在工具表里,**不用重启**(升级才需要重启)。
|
|
20
|
+
|
|
21
|
+
## 三个工具
|
|
22
|
+
|
|
23
|
+
| 工具 | 干什么 | 计费请求数 |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `deepseek_search` | 搜索,返回标题/URL/snippet/summary/可选正文/日期/权威度/rerank 分 | 1 |
|
|
26
|
+
| `deepseek_fetch` | 单页转 Markdown(标题 + 正文) | 1 |
|
|
27
|
+
| `deepseek_research` | 搜索 + 抓前 N 页正文,一次给结论 | 1 + N |
|
|
28
|
+
|
|
29
|
+
`deepseek_search` 支持的操作符,写在 `query` 里或用参数:`site:`、`-site:`、`-词`、`-"短语"`、`"精确"`、`lang:`、`after:`、`before:`。
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
deepseek_search query="llama.cpp 安装" site="github.com" after=2025 count=5
|
|
33
|
+
deepseek_fetch url="https://api-docs.deepseek.com/zh-cn/guides/search"
|
|
34
|
+
deepseek_research query="DeepSeek V4 上下文长度" count=5 top=2
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
判读口径:`rerankScore >= 1.5` 为优质(渲染里标 ★),`authority >= 3` 为高权威。
|
|
38
|
+
`summary` 是二手摘要,版本号/日期/价格/API 字段一律 `deepseek_fetch` 原文核实;引用给原始 URL。
|
|
39
|
+
|
|
40
|
+
## 配置
|
|
41
|
+
|
|
42
|
+
| 键 | 默认 | 说明 |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `apiKey` | `""` | 空则运行时按下面四层解析,**不要把 key 写进 profile** |
|
|
45
|
+
| `baseUrl` | `https://api.deepseek.com` | 仅兼容代理时才改 |
|
|
46
|
+
| `defaultCount` | `5` | 调用没给 `count` 时的条数 |
|
|
47
|
+
| `maxContentChars` | `8000` | `deepseek_search` 每条正文上限 |
|
|
48
|
+
| `fetchMaxChars` | `20000` | `deepseek_fetch` 正文上限 |
|
|
49
|
+
| `timeoutMs` | `30000` | 单请求超时 |
|
|
50
|
+
| `maxRetries` | `2` | 408 / 429 / 5xx 重试次数 |
|
|
51
|
+
| `promptEnabled` | `true` | 是否向系统提示贡献用法章节 |
|
|
52
|
+
| `promptOrder` | `55` | 该章节排序 |
|
|
53
|
+
|
|
54
|
+
key 解析顺序(**key 不落 profile**):
|
|
55
|
+
|
|
56
|
+
1. 插件配置 `apiKey`
|
|
57
|
+
2. `$DEEPSEEK_API_KEY`
|
|
58
|
+
3. `~/.secrets/keys.env` 里的 `DEEPSEEK_API_KEY=...`
|
|
59
|
+
4. `$DSH_HOME/.credentials.yaml` 的 `refs: DEEPSEEK_API_KEY: sk-...`
|
|
60
|
+
|
|
61
|
+
## 成本
|
|
62
|
+
|
|
63
|
+
- 5 元 / 1000 次成功请求 ≈ **0.005 元 / 次**;Search 与 Fetch 同价。
|
|
64
|
+
- 只有 HTTP 200 且产生结果才计费;空结果、失败、超时不计费。
|
|
65
|
+
- `count` 不影响单价——一次取够,别拆成多次小请求。
|
|
66
|
+
- 限速:Search 与 Fetch **各自** 5 QPS(账号级);超过抛 429,插件按退避重试。
|
|
67
|
+
|
|
68
|
+
## 结构
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
lib/index.js 入口:只接线(校验配置、持有 client、注册工具、贡献提示章节)
|
|
72
|
+
lib/config.js Config schema + 代码侧默认值 + 加载期校验
|
|
73
|
+
lib/keys.js 四层 key 解析
|
|
74
|
+
lib/query.js query 拼装与 site: 展开
|
|
75
|
+
lib/client.js HTTP:超时 / 重试 / 错误翻译
|
|
76
|
+
lib/format.js 结果归一化与渲染
|
|
77
|
+
lib/prompt.js 系统提示章节
|
|
78
|
+
lib/tools/ 一能力一文件(search / fetch / research / shared)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 开发
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
node --test test/*.test.js # 单测:mock fetch,不联网、不花钱
|
|
85
|
+
node scripts/live-selftest.js # 真机自测:1 次搜索 + 1 次抓取(约 0.01 元)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 已知边界
|
|
89
|
+
|
|
90
|
+
- 抓不了需要登录态、点击交互、或返回非 HTML 的页面 → 用浏览器自动化插件。
|
|
91
|
+
- SPA 文档站可能对不存在的路径返回 200 + 站点壳内容而不是 404;正文里出现首页标语即说明是 fallback。
|
|
92
|
+
- Fetch 返回的 Markdown 会把 `\/`、`\.`、`\#` 等标点反斜杠转义,属上游产物,按需 unescape。
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-search bundle patch: one row inserted over whatever layers came before it.
|
|
2
|
+
#
|
|
3
|
+
# A patch replaces the targeted row's whole `config`, so a profile that overrides
|
|
4
|
+
# this row must restate every key it wants to keep.
|
|
5
|
+
#
|
|
6
|
+
# The API key is NOT stored here. Leave `apiKey` empty and the plugin resolves it
|
|
7
|
+
# at call time from, in order: $DEEPSEEK_API_KEY, `~/.secrets/keys.env`,
|
|
8
|
+
# `$DSH_HOME/.credentials.yaml`. That keeps the key out of profile files that get
|
|
9
|
+
# copied, shared, or committed.
|
|
10
|
+
#
|
|
11
|
+
# Every value below has a code-side default, so this patch stays valid on a host
|
|
12
|
+
# that cannot evaluate helpers.
|
|
13
|
+
|
|
14
|
+
- insert:
|
|
15
|
+
- id: deepseek-search
|
|
16
|
+
name: "@vfvrpq/dsh-search"
|
|
17
|
+
config:
|
|
18
|
+
apiKey: ""
|
|
19
|
+
baseUrl: https://api.deepseek.com
|
|
20
|
+
defaultCount: 5
|
|
21
|
+
maxContentChars: 8000
|
|
22
|
+
fetchMaxChars: 20000
|
|
23
|
+
timeoutMs: 30000
|
|
24
|
+
maxRetries: 2
|
|
25
|
+
promptEnabled: true
|
|
26
|
+
promptOrder: 55
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the DeepSeek Search / Fetch endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Both are plain JSON APIs behind the same Bearer auth as the chat API:
|
|
5
|
+
* POST /v1/tools/search { query, count, ori_question, need_content }
|
|
6
|
+
* POST /v1/tools/fetch { url }
|
|
7
|
+
*
|
|
8
|
+
* The client owns timeout, retry, and error translation, so tool files only see
|
|
9
|
+
* successes or a thrown `SearchClientError` that already carries a hint.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-search/client
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolveApiKey } from "./keys.js";
|
|
15
|
+
|
|
16
|
+
/** Status codes worth retrying: transient by contract. */
|
|
17
|
+
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
|
|
18
|
+
|
|
19
|
+
/** Operator-facing explanation per status. */
|
|
20
|
+
const HINTS = {
|
|
21
|
+
400: "请求参数不合法(query 1~128 字符、count 1~50)",
|
|
22
|
+
401: "API Key 缺失或无效,检查 DEEPSEEK_API_KEY / ~/.secrets/keys.env / ~/.dsh/.credentials.yaml",
|
|
23
|
+
402: "账号余额不足,请先充值",
|
|
24
|
+
403: "Fetch 被目标站点的安全策略拒绝",
|
|
25
|
+
404: "抓不到可提取的正文(站点拦截、非 HTML、或页面没有 Markdown 正文)",
|
|
26
|
+
429: "超出限速(Search / Fetch 各自 5 QPS,账号级),稍等重试",
|
|
27
|
+
500: "服务端内部错误,稍后重试",
|
|
28
|
+
504: "下游超时,稍后重试",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class SearchClientError extends Error {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} message - human-readable failure.
|
|
34
|
+
* @param {{status?: number, hint?: string}} [meta] - HTTP status and advice.
|
|
35
|
+
*/
|
|
36
|
+
constructor(message, meta = {}) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "SearchClientError";
|
|
39
|
+
this.status = meta.status;
|
|
40
|
+
this.hint = meta.hint;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SearchClient {
|
|
45
|
+
/**
|
|
46
|
+
* @param {{baseUrl: string, apiKey?: string, timeoutMs?: number, maxRetries?: number, fetchImpl?: typeof fetch}} options - client options.
|
|
47
|
+
*/
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
50
|
+
this.apiKeyOption = options.apiKey ?? "";
|
|
51
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
52
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
53
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
54
|
+
if (typeof this.fetchImpl !== "function") {
|
|
55
|
+
throw new SearchClientError("deepseek-search: 运行环境没有 fetch 实现");
|
|
56
|
+
}
|
|
57
|
+
/** @type {(explicit: string) => {key: string, source: string}} */
|
|
58
|
+
this.keyResolver = options.keyResolver ?? ((explicit) => resolveApiKey({ explicit }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the key for this call and report its provenance.
|
|
63
|
+
*
|
|
64
|
+
* @returns {{key: string, source: string}} resolved key.
|
|
65
|
+
*/
|
|
66
|
+
keyInfo() {
|
|
67
|
+
return this.keyResolver(this.apiKeyOption);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* POST one JSON body with retry, timeout, and error translation.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} path - endpoint path.
|
|
74
|
+
* @param {Record<string, unknown>} body - request body.
|
|
75
|
+
* @returns {Promise<Record<string, any>>} parsed response.
|
|
76
|
+
*/
|
|
77
|
+
async request(path, body) {
|
|
78
|
+
const { key, source } = this.keyInfo();
|
|
79
|
+
if (key === "") {
|
|
80
|
+
throw new SearchClientError("deepseek-search: 找不到 API Key", {
|
|
81
|
+
hint: "设置 DEEPSEEK_API_KEY 环境变量,或写入 ~/.secrets/keys.env 的 DEEPSEEK_API_KEY=...",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let lastError;
|
|
86
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
87
|
+
if (attempt > 0) await sleep(400 * 2 ** (attempt - 1));
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await this.fetchImpl(this.baseUrl + path, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
lastError = new SearchClientError(`deepseek-search: 网络请求失败(${error?.name ?? "Error"}: ${error?.message ?? error})`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const text = await response.text();
|
|
102
|
+
if (response.ok) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(text);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new SearchClientError("deepseek-search: 响应不是合法 JSON", { status: response.status, hint: text.slice(0, 200) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const message = parseErrorMessage(text) || `HTTP ${response.status}`;
|
|
111
|
+
const hint = HINTS[response.status];
|
|
112
|
+
lastError = new SearchClientError(`deepseek-search: ${message}(HTTP ${response.status},key 来源 ${source})`, {
|
|
113
|
+
status: response.status,
|
|
114
|
+
hint,
|
|
115
|
+
});
|
|
116
|
+
if (!RETRYABLE.has(response.status)) throw lastError;
|
|
117
|
+
}
|
|
118
|
+
throw lastError;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run one web search.
|
|
123
|
+
*
|
|
124
|
+
* @param {{query: string, count: number, oriQuestion?: string, needContent?: boolean}} args - search arguments.
|
|
125
|
+
* @returns {Promise<Array<Record<string, any>>>} raw result objects.
|
|
126
|
+
*/
|
|
127
|
+
async search(args) {
|
|
128
|
+
const body = { query: args.query, count: args.count };
|
|
129
|
+
if (args.oriQuestion) body.ori_question = args.oriQuestion;
|
|
130
|
+
if (args.needContent) body.need_content = true;
|
|
131
|
+
const data = await this.request("/v1/tools/search", body);
|
|
132
|
+
return Array.isArray(data?.results) ? data.results : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fetch one page as Markdown.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} url - http(s) URL.
|
|
139
|
+
* @returns {Promise<{title: string, content: string}>} page title and body.
|
|
140
|
+
*/
|
|
141
|
+
async fetchPage(url) {
|
|
142
|
+
const data = await this.request("/v1/tools/fetch", { url });
|
|
143
|
+
return { title: String(data?.title ?? ""), content: String(data?.content ?? "") };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pull `error.message` out of an OpenAI-shaped error body.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} text - raw response body.
|
|
151
|
+
* @returns {string} message, or "" when unparseable.
|
|
152
|
+
*/
|
|
153
|
+
function parseErrorMessage(text) {
|
|
154
|
+
try {
|
|
155
|
+
return String(JSON.parse(text)?.error?.message ?? "");
|
|
156
|
+
} catch {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {number} ms - delay.
|
|
163
|
+
* @returns {Promise<void>} resolves after the delay.
|
|
164
|
+
*/
|
|
165
|
+
function sleep(ms) {
|
|
166
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
167
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: the schema the host validates against, the code-side
|
|
3
|
+
* defaults (so direct construction works without the loader), and the bounds
|
|
4
|
+
* check that fails at load time instead of at the first tool call.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-search/config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import z from "@deepseek-ai/schemastery";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Code-side defaults.
|
|
13
|
+
*
|
|
14
|
+
* The Cordis loader already applies `Config`'s defaults, but `apply` is also
|
|
15
|
+
* reachable through direct construction (tests, tooling), so the plugin
|
|
16
|
+
* resolves its own defaults instead of trusting the caller.
|
|
17
|
+
*
|
|
18
|
+
* @type {Record<string, any>}
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULTS = Object.freeze({
|
|
21
|
+
apiKey: "",
|
|
22
|
+
baseUrl: "https://api.deepseek.com",
|
|
23
|
+
defaultCount: 5,
|
|
24
|
+
maxContentChars: 8_000,
|
|
25
|
+
fetchMaxChars: 20_000,
|
|
26
|
+
timeoutMs: 30_000,
|
|
27
|
+
maxRetries: 2,
|
|
28
|
+
promptEnabled: true,
|
|
29
|
+
promptOrder: 55,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const Config = z.object({
|
|
33
|
+
/** Explicit API key; empty means resolve from env / secrets files at call time. */
|
|
34
|
+
apiKey: z.string().default(""),
|
|
35
|
+
/** API origin. Point it elsewhere only for a compatible proxy. */
|
|
36
|
+
baseUrl: z.string().default("https://api.deepseek.com"),
|
|
37
|
+
/** Result count when the tool call omits `count`. */
|
|
38
|
+
defaultCount: z.natural().default(5),
|
|
39
|
+
/** Per-result ceiling for `content` returned by `deepseek_search`. */
|
|
40
|
+
maxContentChars: z.natural().default(8_000),
|
|
41
|
+
/** Ceiling for the Markdown returned by `deepseek_fetch`. */
|
|
42
|
+
fetchMaxChars: z.natural().default(20_000),
|
|
43
|
+
/** Per-request timeout in milliseconds. */
|
|
44
|
+
timeoutMs: z.natural().default(30_000),
|
|
45
|
+
/** Retries for 408 / 429 / 5xx responses. */
|
|
46
|
+
maxRetries: z.natural().default(2),
|
|
47
|
+
/** Whether to contribute the search guide to the system prompt. */
|
|
48
|
+
promptEnabled: z.boolean().default(true),
|
|
49
|
+
/** Ordering of that section. */
|
|
50
|
+
promptOrder: z.natural().default(55),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge caller config over the defaults, ignoring explicit `undefined`.
|
|
55
|
+
*
|
|
56
|
+
* @param {Record<string, any>|undefined} config - caller config.
|
|
57
|
+
* @returns {Record<string, any>} effective config.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveConfig(config) {
|
|
60
|
+
const given = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
|
|
61
|
+
return { ...DEFAULTS, ...given };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reject unusable configuration at load time instead of at the first tool call.
|
|
66
|
+
*
|
|
67
|
+
* @param {Record<string, any>} config - schema-validated config.
|
|
68
|
+
* @throws {Error} when a bound is not a positive integer or an origin is malformed.
|
|
69
|
+
*/
|
|
70
|
+
export function validateConfig(config) {
|
|
71
|
+
const bounds = [
|
|
72
|
+
["defaultCount", config.defaultCount, 1],
|
|
73
|
+
["maxContentChars", config.maxContentChars, 200],
|
|
74
|
+
["fetchMaxChars", config.fetchMaxChars, 500],
|
|
75
|
+
["timeoutMs", config.timeoutMs, 1_000],
|
|
76
|
+
["maxRetries", config.maxRetries, 0],
|
|
77
|
+
["promptOrder", config.promptOrder, 0],
|
|
78
|
+
];
|
|
79
|
+
for (const [field, value, min] of bounds) {
|
|
80
|
+
if (!Number.isInteger(value) || value < min) {
|
|
81
|
+
throw new Error(`deepseek-search: 配置项 ${field}=${value} 必须是 >= ${min} 的整数`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!/^https?:\/\//.test(config.baseUrl)) {
|
|
85
|
+
throw new Error(`deepseek-search: baseUrl 必须以 http:// 或 https:// 开头(当前 ${config.baseUrl})`);
|
|
86
|
+
}
|
|
87
|
+
if (config.defaultCount > 50) {
|
|
88
|
+
throw new Error(`deepseek-search: defaultCount=${config.defaultCount} 超出接口上限 50`);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation helpers: normalize one API result into the tool's output shape,
|
|
3
|
+
* clip text, and render the plain-text card body the GUI shows.
|
|
4
|
+
*
|
|
5
|
+
* Keeping formatting here means every tool file stays a thin capability.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-search/format
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Score at or above this counts as a good hit (per the API docs). */
|
|
11
|
+
export const GOOD_SCORE = 1.5;
|
|
12
|
+
|
|
13
|
+
/** Authority at or above this counts as highly authoritative. */
|
|
14
|
+
export const STRONG_AUTHORITY = 3;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clip text to a character budget.
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} text - candidate text.
|
|
20
|
+
* @param {number} max - character ceiling.
|
|
21
|
+
* @returns {{text: string, truncated: boolean}} clipped text and whether it was cut.
|
|
22
|
+
*/
|
|
23
|
+
export function clip(text, max) {
|
|
24
|
+
const value = String(text ?? "");
|
|
25
|
+
if (value.length <= max) return { text: value, truncated: false };
|
|
26
|
+
return { text: value.slice(0, max) + "\n…(已截断)", truncated: true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize one raw search result.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, any>} raw - API result object.
|
|
33
|
+
* @param {number} rank - 1-based position.
|
|
34
|
+
* @param {{contentLimit?: number}} [options] - clipping options.
|
|
35
|
+
* @returns {Record<string, any>} normalized result.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeResult(raw, rank, options = {}) {
|
|
38
|
+
const content = clip(raw?.content ?? "", options.contentLimit ?? 0);
|
|
39
|
+
return {
|
|
40
|
+
rank,
|
|
41
|
+
title: String(raw?.title ?? ""),
|
|
42
|
+
url: String(raw?.url ?? ""),
|
|
43
|
+
snippet: String(raw?.snippet ?? ""),
|
|
44
|
+
summary: String(raw?.summary ?? ""),
|
|
45
|
+
content: content.text,
|
|
46
|
+
siteName: raw?.site_name == null ? "" : String(raw.site_name),
|
|
47
|
+
date: raw?.date == null ? "" : String(raw.date),
|
|
48
|
+
authority: Number.isInteger(raw?.authority) ? raw.authority : -1,
|
|
49
|
+
rerankScore: Number.isFinite(raw?.rerank_score) ? Number(raw.rerank_score) : 0,
|
|
50
|
+
good: Number(raw?.rerank_score ?? 0) >= GOOD_SCORE,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Format a score for display.
|
|
56
|
+
*
|
|
57
|
+
* @param {number} score - rerank score.
|
|
58
|
+
* @returns {string} two-decimal score.
|
|
59
|
+
*/
|
|
60
|
+
export function fmtScore(score) {
|
|
61
|
+
return Number(score ?? 0).toFixed(2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Format an RFC 3339 timestamp as a date.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} iso - timestamp.
|
|
68
|
+
* @returns {string} `YYYY-MM-DD`, or "—" when absent.
|
|
69
|
+
*/
|
|
70
|
+
export function fmtDate(iso) {
|
|
71
|
+
return iso ? String(iso).slice(0, 10) : "—";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One result as display lines.
|
|
76
|
+
*
|
|
77
|
+
* @param {Record<string, any>} result - normalized result.
|
|
78
|
+
* @returns {string} multi-line block.
|
|
79
|
+
*/
|
|
80
|
+
export function resultBlock(result) {
|
|
81
|
+
const head = `${result.good ? "★" : " "}[${result.rank}] ${result.title || "(无标题)"}`;
|
|
82
|
+
const meta = ` ${fmtScore(result.rerankScore)} 分 · 权威 ${result.authority} · ${fmtDate(result.date)} · ${result.siteName || "—"}`;
|
|
83
|
+
const body = result.summary || result.snippet;
|
|
84
|
+
return [head, ` ${result.url}`, meta, body ? ` ${String(body).replace(/\n+/g, " ")}` : ""].filter(Boolean).join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render a search report body.
|
|
89
|
+
*
|
|
90
|
+
* @param {Record<string, any>} value - tool output.
|
|
91
|
+
* @returns {string} card text.
|
|
92
|
+
*/
|
|
93
|
+
export function renderSearchReport(value) {
|
|
94
|
+
if (value.results.length === 0) {
|
|
95
|
+
return `query: ${value.query}\n没有匹配结果(本次请求不计费)。换个关键词,或用 site: / after: 收窄范围后重试。`;
|
|
96
|
+
}
|
|
97
|
+
const blocks = value.results.map(resultBlock).join("\n\n");
|
|
98
|
+
return [
|
|
99
|
+
`query: ${value.query} 命中 ${value.total} 条`,
|
|
100
|
+
"",
|
|
101
|
+
blocks,
|
|
102
|
+
"",
|
|
103
|
+
"★ = rerank ≥ 1.5 优质;正文用 deepseek_fetch <url> 取全文。",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of dsh-search.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SearchClient } from "./client.js";
|
|
6
|
+
|
|
7
|
+
export declare const name: string;
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
export declare const Config: unknown;
|
|
10
|
+
export { SearchClient };
|
|
11
|
+
export declare function apply(ctx: any, config: Record<string, any>): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-search — DeepSeek first-party Search / Fetch tools for
|
|
3
|
+
* DeepSeek Harness.
|
|
4
|
+
*
|
|
5
|
+
* This entry point only wires things together: it validates config, owns the
|
|
6
|
+
* client lifecycle, contributes the guide section, and registers the tools
|
|
7
|
+
* described in `./tools/`. Configuration lives in `./config.js`, key resolution
|
|
8
|
+
* in `./keys.js`, the HTTP protocol in `./client.js`, the guide text in
|
|
9
|
+
* `./prompt.js`.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-search
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
15
|
+
|
|
16
|
+
import { SearchClient } from "./client.js";
|
|
17
|
+
import { Config, resolveConfig, validateConfig } from "./config.js";
|
|
18
|
+
import { guideText } from "./prompt.js";
|
|
19
|
+
import { createToolSpecs } from "./tools.js";
|
|
20
|
+
|
|
21
|
+
export const name = "deepseek-search";
|
|
22
|
+
|
|
23
|
+
export const inject = ["tools", "systemPrompt"];
|
|
24
|
+
|
|
25
|
+
export { Config, SearchClient };
|
|
26
|
+
|
|
27
|
+
export function apply(ctx, config) {
|
|
28
|
+
const settings = resolveConfig(config);
|
|
29
|
+
validateConfig(settings);
|
|
30
|
+
|
|
31
|
+
/** @type {SearchClient|undefined} */
|
|
32
|
+
let client;
|
|
33
|
+
ctx.effect(() => {
|
|
34
|
+
client = new SearchClient({
|
|
35
|
+
baseUrl: settings.baseUrl,
|
|
36
|
+
apiKey: settings.apiKey,
|
|
37
|
+
timeoutMs: settings.timeoutMs,
|
|
38
|
+
maxRetries: settings.maxRetries,
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
client = undefined;
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The live client, or a loud failure — reached only while the fiber is active.
|
|
47
|
+
*
|
|
48
|
+
* @returns {SearchClient} live client.
|
|
49
|
+
*/
|
|
50
|
+
const getClient = () => {
|
|
51
|
+
if (!client) throw new Error("deepseek-search: client is not available");
|
|
52
|
+
return client;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (settings.promptEnabled) {
|
|
56
|
+
ctx.systemPrompt.section({
|
|
57
|
+
name: "deepseek-search:guide",
|
|
58
|
+
order: settings.promptOrder,
|
|
59
|
+
text: () => guideText(settings),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const spec of createToolSpecs({ client: getClient(), config: settings })) {
|
|
64
|
+
ctx.tools.register(
|
|
65
|
+
defineTool({
|
|
66
|
+
name: spec.name,
|
|
67
|
+
description: spec.description,
|
|
68
|
+
parameters: spec.parameters,
|
|
69
|
+
output: {
|
|
70
|
+
schema: spec.outputSchema,
|
|
71
|
+
render: spec.render,
|
|
72
|
+
...(spec.presentationMeta ? { presentationMeta: spec.presentationMeta } : {}),
|
|
73
|
+
},
|
|
74
|
+
...(spec.call ? { presentCall: spec.call } : {}),
|
|
75
|
+
execute: spec.execute,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/keys.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key resolution — three layers, in order, so no key ever lives in the
|
|
3
|
+
* plugin's own config file:
|
|
4
|
+
*
|
|
5
|
+
* 1. `apiKey` from the plugin config (explicit opt-in)
|
|
6
|
+
* 2. `$DEEPSEEK_API_KEY`
|
|
7
|
+
* 3. `~/.secrets/keys.env` (`DEEPSEEK_API_KEY=...`)
|
|
8
|
+
* 4. `$DSH_HOME/.credentials.yaml` (`refs: DEEPSEEK_API_KEY: sk-...`)
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-search/keys
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read one key out of a dotenv-style file.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} file - absolute path.
|
|
21
|
+
* @param {string} name - variable name.
|
|
22
|
+
* @returns {string} the value, or "" when absent.
|
|
23
|
+
*/
|
|
24
|
+
function fromDotEnv(file, name) {
|
|
25
|
+
if (!existsSync(file)) return "";
|
|
26
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.+?)\\s*$`, "m");
|
|
27
|
+
const match = readFileSync(file, "utf8").match(pattern);
|
|
28
|
+
return match ? match[1].replace(/^["']|["']$/g, "").trim() : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read one key out of DSH's credentials store (`refs:` block).
|
|
33
|
+
*
|
|
34
|
+
* @param {string} file - absolute path.
|
|
35
|
+
* @param {string} name - variable name.
|
|
36
|
+
* @returns {string} the value, or "" when absent.
|
|
37
|
+
*/
|
|
38
|
+
function fromCredentials(file, name) {
|
|
39
|
+
if (!existsSync(file)) return "";
|
|
40
|
+
const pattern = new RegExp(`^\\s*${name}:\\s*(\\S+)\\s*$`, "m");
|
|
41
|
+
const match = readFileSync(file, "utf8").match(pattern);
|
|
42
|
+
return match ? match[1].replace(/^["']|["']$/g, "").trim() : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the API key and report which layer answered.
|
|
47
|
+
*
|
|
48
|
+
* @param {{explicit?: string, env?: NodeJS.ProcessEnv, home?: string, dshHome?: string}} [options] - lookup overrides.
|
|
49
|
+
* @returns {{key: string, source: string}} resolved key plus provenance.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveApiKey(options = {}) {
|
|
52
|
+
const env = options.env ?? process.env;
|
|
53
|
+
const home = options.home ?? homedir();
|
|
54
|
+
const dshHome = options.dshHome ?? env.DSH_HOME ?? join(home, ".dsh");
|
|
55
|
+
const name = "DEEPSEEK_API_KEY";
|
|
56
|
+
|
|
57
|
+
const explicit = String(options.explicit ?? "").trim();
|
|
58
|
+
if (explicit !== "") return { key: explicit, source: "plugin config" };
|
|
59
|
+
|
|
60
|
+
const fromEnv = String(env[name] ?? "").trim();
|
|
61
|
+
if (fromEnv !== "") return { key: fromEnv, source: `env ${name}` };
|
|
62
|
+
|
|
63
|
+
const dotenv = fromDotEnv(join(home, ".secrets", "keys.env"), name);
|
|
64
|
+
if (dotenv !== "") return { key: dotenv, source: "~/.secrets/keys.env" };
|
|
65
|
+
|
|
66
|
+
const cred = fromCredentials(join(dshHome, ".credentials.yaml"), name);
|
|
67
|
+
if (cred !== "") return { key: cred, source: "~/.dsh/.credentials.yaml" };
|
|
68
|
+
|
|
69
|
+
return { key: "", source: "missing" };
|
|
70
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The system-prompt section this plugin contributes.
|
|
3
|
+
*
|
|
4
|
+
* Short on purpose: it only states when the tools beat the built-in ones and
|
|
5
|
+
* what each successful call costs, since the tool descriptions carry the rest.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-search/prompt
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Record<string, any>} config - effective plugin config.
|
|
12
|
+
* @returns {string} prompt section text.
|
|
13
|
+
*/
|
|
14
|
+
export function guideText(config) {
|
|
15
|
+
const key = "(key 从 DEEPSEEK_API_KEY / ~/.secrets/keys.env / ~/.dsh/.credentials.yaml 解析)";
|
|
16
|
+
return [
|
|
17
|
+
"联网搜索与抓取默认走 DeepSeek 官方接口,而不是内置 web_search / web_fetch:",
|
|
18
|
+
"- deepseek_search:要线索。返回 rerank 分、权威度、发布时间,可用 site: / after: / before: / lang: / 精确短语 / 排除词。",
|
|
19
|
+
"- deepseek_fetch:要原文。单页转 Markdown,核实版本号、日期、价格、API 字段时用。",
|
|
20
|
+
"- deepseek_research:要结论。一次搜索+抓前 N 页正文(默认 2)。",
|
|
21
|
+
"判读:rerankScore ≥ 1.5 为优质,authority ≥ 3 为高权威;summary 是二手摘要,关键事实必须 fetch 原文核实;引用给原始 URL。",
|
|
22
|
+
`成本:每次成功请求计费(约 5 元/千次),count 不影响单价——一次取够,别拆分多次;空结果不计费。限速 Search/Fetch 各 5 QPS。${key}`,
|
|
23
|
+
"仅在不想计费、或需要登录态/点击交互的页面时,才回退内置通道或浏览器自动化。",
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
package/lib/query.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query assembly: turn the tool's structured arguments into one DeepSeek query
|
|
3
|
+
* string, applying the operators the search backend understands.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-search/query
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Longest query the endpoint accepts. */
|
|
9
|
+
export const MAX_QUERY_CHARS = 128;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Expand a site argument into `site:` clauses joined by OR.
|
|
13
|
+
*
|
|
14
|
+
* A caller may pass `github.com`, `github.com huggingface.co`, or
|
|
15
|
+
* `github.com,huggingface.co`; a value that already carries an operator
|
|
16
|
+
* (`site:`, a leading `-`, or an explicit `OR`) is passed through untouched.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} site - raw argument.
|
|
19
|
+
* @returns {string} clause(s) appended to the query.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeSite(site) {
|
|
22
|
+
const raw = String(site ?? "").trim();
|
|
23
|
+
if (raw === "") return "";
|
|
24
|
+
if (/(^|\s)site:/.test(raw) || /^(?:-|\+|OR\b)/i.test(raw)) return raw;
|
|
25
|
+
return raw
|
|
26
|
+
.split(/[\s,]+/)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((host) => `site:${host}`)
|
|
29
|
+
.join(" OR ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the final query string.
|
|
34
|
+
*
|
|
35
|
+
* @param {{query: string, site?: string, lang?: string, after?: string, before?: string}} args - tool arguments.
|
|
36
|
+
* @returns {string} query with operators appended.
|
|
37
|
+
* @throws {Error} when the query is blank or too long.
|
|
38
|
+
*/
|
|
39
|
+
export function buildQuery(args) {
|
|
40
|
+
const base = String(args.query ?? "").trim();
|
|
41
|
+
if (base === "") throw new Error("deepseek-search: `query` 不能为空");
|
|
42
|
+
const parts = [base];
|
|
43
|
+
const site = normalizeSite(args.site);
|
|
44
|
+
if (site !== "") parts.push(site);
|
|
45
|
+
if (args.lang) parts.push(`lang:${String(args.lang).trim()}`);
|
|
46
|
+
if (args.after) parts.push(`after:${String(args.after).trim()}`);
|
|
47
|
+
if (args.before) parts.push(`before:${String(args.before).trim()}`);
|
|
48
|
+
const query = parts.join(" ").trim();
|
|
49
|
+
if ([...query].length > MAX_QUERY_CHARS) {
|
|
50
|
+
throw new Error(`deepseek-search: query 超长(${[...query].length} > ${MAX_QUERY_CHARS} 字符),请精简后重试`);
|
|
51
|
+
}
|
|
52
|
+
return query;
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deepseek_fetch` — one page as Markdown.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-search/tools
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { clip } from "../format.js";
|
|
8
|
+
import { guarded, requireText } from "./shared.js";
|
|
9
|
+
|
|
10
|
+
export function createFetchTool(ctx) {
|
|
11
|
+
const { client, config } = ctx;
|
|
12
|
+
return {
|
|
13
|
+
name: "deepseek_fetch",
|
|
14
|
+
description:
|
|
15
|
+
"Fetch one web page and get its main content as Markdown (title plus body), extracted by DeepSeek. Use it to read the full text behind a search result, verify a version number / date / price / API field, or quote a page exactly — a search result's summary is a second-hand digest. Cost: one billed request per successful fetch (empty or blocked pages are not billed). JSON API endpoints, PDFs without text, and login-gated pages usually fail with a clear error; those need a browser instead.",
|
|
16
|
+
parameters: {
|
|
17
|
+
url: { type: "string", required: true, description: "Absolute http(s) URL, no embedded username/password." },
|
|
18
|
+
maxChars: { type: "integer", description: "Body ceiling in characters (default from config, usually 20000). The reply says whether it was truncated." },
|
|
19
|
+
},
|
|
20
|
+
outputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: {
|
|
24
|
+
url: { type: "string", required: true },
|
|
25
|
+
title: { type: "string", required: true },
|
|
26
|
+
content: { type: "string", required: true },
|
|
27
|
+
chars: { type: "integer", required: true },
|
|
28
|
+
truncated: { type: "boolean", required: true },
|
|
29
|
+
billed: { type: "boolean", required: true },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
call: (args) => ({ card: "generic", title: `deepseek_fetch ${args.url}`, kind: "search" }),
|
|
33
|
+
render: (_args, value) => {
|
|
34
|
+
const header = `# ${value.title || "(无标题)"}\n${value.url}\n`;
|
|
35
|
+
const footer = value.truncated ? `\n\n…(正文 ${value.chars} 字符处截断,可调大 maxChars)` : "";
|
|
36
|
+
return [{ type: "text", text: value.content ? header + "\n" + value.content + footer : `${header}\n(没有可提取的正文,本次不计费)` }];
|
|
37
|
+
},
|
|
38
|
+
execute: (args) =>
|
|
39
|
+
guarded(async () => {
|
|
40
|
+
const url = requireText(args.url, "url");
|
|
41
|
+
if (!/^https?:\/\//i.test(url)) throw new Error("deepseek-search: `url` 必须是 http:// 或 https:// 开头的绝对地址");
|
|
42
|
+
const limit = Number.isFinite(Number(args.maxChars)) && Number(args.maxChars) > 0
|
|
43
|
+
? Math.trunc(Number(args.maxChars))
|
|
44
|
+
: config.fetchMaxChars;
|
|
45
|
+
const page = await client.fetchPage(url);
|
|
46
|
+
const body = clip(page.content, limit);
|
|
47
|
+
return {
|
|
48
|
+
url,
|
|
49
|
+
title: page.title,
|
|
50
|
+
content: body.text,
|
|
51
|
+
chars: page.content.length,
|
|
52
|
+
truncated: body.truncated,
|
|
53
|
+
billed: page.content.length > 0,
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deepseek_research` — search once, then fetch the top pages, in one call.
|
|
3
|
+
*
|
|
4
|
+
* Saves the model a round trip when it needs a conclusion rather than links:
|
|
5
|
+
* one billed search plus one billed fetch per fetched page.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-search/tools
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { normalizeResult, resultBlock } from "../format.js";
|
|
11
|
+
import { buildQuery } from "../query.js";
|
|
12
|
+
import { clampInt, guarded, requireText } from "./shared.js";
|
|
13
|
+
|
|
14
|
+
/** Space between fetches, keeping well inside the account-wide 5 QPS. */
|
|
15
|
+
const FETCH_GAP_MS = 220;
|
|
16
|
+
|
|
17
|
+
export function createResearchTool(ctx) {
|
|
18
|
+
const { client, config } = ctx;
|
|
19
|
+
return {
|
|
20
|
+
name: "deepseek_research",
|
|
21
|
+
description:
|
|
22
|
+
"Search and read in one call: run the query, then fetch the top N pages and return their opening text alongside the summaries. Use it when the answer needs page content, not just links. Cost: one billed search + one billed fetch per fetched page, so keep `top` small (1-2 is usually enough; `top: 0` skips fetching entirely). Prefer `deepseek_search` when you only need candidate URLs, and `deepseek_fetch` when you already know which page to read.",
|
|
23
|
+
parameters: {
|
|
24
|
+
query: { type: "string", required: true, description: "Search terms, 1-128 characters; operators allowed." },
|
|
25
|
+
count: { type: "integer", description: "Results to list, 1-50 (default from config, usually 5)." },
|
|
26
|
+
top: { type: "integer", description: "How many top results to fetch full text for, 0-5 (default 2). Each fetched page is billed separately." },
|
|
27
|
+
excerptChars: { type: "integer", description: "Characters of body kept per fetched page (default 3000)." },
|
|
28
|
+
site: { type: "string", description: "Restrict to sites, e.g. `github.com` or `github.com huggingface.co`." },
|
|
29
|
+
lang: { type: "string", description: "Restrict to languages, comma separated." },
|
|
30
|
+
after: { type: "string", description: "Only pages published after this date." },
|
|
31
|
+
before: { type: "string", description: "Only pages published before this date." },
|
|
32
|
+
},
|
|
33
|
+
outputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
properties: {
|
|
37
|
+
query: { type: "string", required: true },
|
|
38
|
+
total: { type: "integer", required: true },
|
|
39
|
+
fetched: { type: "integer", required: true },
|
|
40
|
+
billedRequests: { type: "integer", required: true },
|
|
41
|
+
results: {
|
|
42
|
+
type: "array",
|
|
43
|
+
required: true,
|
|
44
|
+
items: {
|
|
45
|
+
type: "object",
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
rank: { type: "integer", required: true },
|
|
49
|
+
title: { type: "string", required: true },
|
|
50
|
+
url: { type: "string", required: true },
|
|
51
|
+
snippet: { type: "string", required: true },
|
|
52
|
+
summary: { type: "string", required: true },
|
|
53
|
+
content: { type: "string", required: true },
|
|
54
|
+
siteName: { type: "string", required: true },
|
|
55
|
+
date: { type: "string", required: true },
|
|
56
|
+
authority: { type: "integer", required: true },
|
|
57
|
+
rerankScore: { type: "number", required: true },
|
|
58
|
+
good: { type: "boolean", required: true },
|
|
59
|
+
excerpt: { type: "string", required: true },
|
|
60
|
+
fetchError: { type: "string", required: true },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
call: (args) => ({ card: "generic", title: `deepseek_research ${args.query}`, kind: "search" }),
|
|
67
|
+
render: (_args, value) => {
|
|
68
|
+
if (value.results.length === 0) {
|
|
69
|
+
return [{ type: "text", text: `query: ${value.query}\n没有匹配结果(本次请求不计费)。` }];
|
|
70
|
+
}
|
|
71
|
+
const blocks = value.results
|
|
72
|
+
.map((result) => {
|
|
73
|
+
const base = resultBlock(result);
|
|
74
|
+
if (result.excerpt) return `${base}\n ── 正文节选 ──\n${indent(result.excerpt)}`;
|
|
75
|
+
if (result.fetchError) return `${base}\n ⚠ 抓取失败:${result.fetchError}`;
|
|
76
|
+
return base;
|
|
77
|
+
})
|
|
78
|
+
.join("\n\n");
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: [
|
|
83
|
+
`query: ${value.query} 命中 ${value.total} 条,抓取 ${value.fetched} 页正文(计费 ${value.billedRequests} 次请求)`,
|
|
84
|
+
"",
|
|
85
|
+
blocks,
|
|
86
|
+
"",
|
|
87
|
+
"引用请带原始 URL;正文是节选,长文用 deepseek_fetch 取全文。",
|
|
88
|
+
].join("\n"),
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
},
|
|
92
|
+
execute: (args) =>
|
|
93
|
+
guarded(async () => {
|
|
94
|
+
const query = buildQuery({
|
|
95
|
+
query: requireText(args.query, "query"),
|
|
96
|
+
site: args.site,
|
|
97
|
+
lang: args.lang,
|
|
98
|
+
after: args.after,
|
|
99
|
+
before: args.before,
|
|
100
|
+
});
|
|
101
|
+
const count = clampInt(args.count, config.defaultCount, 1, 50);
|
|
102
|
+
const top = clampInt(args.top, 2, 0, 5);
|
|
103
|
+
const excerptChars = clampInt(args.excerptChars, 3_000, 200, config.maxContentChars);
|
|
104
|
+
|
|
105
|
+
const raw = await client.search({ query, count });
|
|
106
|
+
const results = raw.map((item, index) => ({
|
|
107
|
+
...normalizeResult(item, index + 1, { contentLimit: 0 }),
|
|
108
|
+
excerpt: "",
|
|
109
|
+
fetchError: "",
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
let fetched = 0;
|
|
113
|
+
for (const result of results.slice(0, top)) {
|
|
114
|
+
if (fetched > 0) await sleep(FETCH_GAP_MS);
|
|
115
|
+
try {
|
|
116
|
+
const page = await client.fetchPage(result.url);
|
|
117
|
+
result.excerpt = page.content.slice(0, excerptChars);
|
|
118
|
+
fetched += 1;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
result.fetchError = String(error?.message ?? error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
query,
|
|
126
|
+
total: results.length,
|
|
127
|
+
fetched,
|
|
128
|
+
billedRequests: results.length === 0 ? 0 : 1 + fetched,
|
|
129
|
+
results,
|
|
130
|
+
};
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Indent an excerpt for display.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} text - body excerpt.
|
|
139
|
+
* @returns {string} indented, blank-line-collapsed text.
|
|
140
|
+
*/
|
|
141
|
+
function indent(text) {
|
|
142
|
+
return text
|
|
143
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
144
|
+
.split("\n")
|
|
145
|
+
.map((line) => ` ${line}`)
|
|
146
|
+
.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {number} ms - delay.
|
|
151
|
+
* @returns {Promise<void>} resolves after the delay.
|
|
152
|
+
*/
|
|
153
|
+
function sleep(ms) {
|
|
154
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
155
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deepseek_search` — web search with structured, reranked results.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-search/tools
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { normalizeResult, renderSearchReport } from "../format.js";
|
|
8
|
+
import { buildQuery } from "../query.js";
|
|
9
|
+
import { clampInt, guarded, requireText } from "./shared.js";
|
|
10
|
+
|
|
11
|
+
export function createSearchTool(ctx) {
|
|
12
|
+
const { client, config } = ctx;
|
|
13
|
+
return {
|
|
14
|
+
name: "deepseek_search",
|
|
15
|
+
description:
|
|
16
|
+
"Search the web through DeepSeek's first-party search engine and get structured, reranked results (title, url, snippet, model-ready summary, optional page content, publish date, authority, rerank score). Supports the operators site:, -site:, -, -\"phrase\", \"exact\", lang:, after:, before: — pass them inside `query` or use the dedicated arguments. Cost: one billed request per successful call (empty results are not billed), so ask for every result you need in one call instead of calling repeatedly. Result count does not change the price. Use `deepseek_fetch` when you need a full page rather than a summary.",
|
|
17
|
+
parameters: {
|
|
18
|
+
query: { type: "string", required: true, description: "Search terms, 1-128 characters. Operators are allowed, e.g. `DeepSeek -site:csdn.net after:2025`." },
|
|
19
|
+
count: { type: "integer", description: "How many results to return, 1-50 (default from config, usually 5)." },
|
|
20
|
+
site: { type: "string", description: "Restrict to sites: `github.com` or `github.com huggingface.co` (becomes `site:a OR site:b`)." },
|
|
21
|
+
lang: { type: "string", description: "Restrict to languages, comma separated, e.g. `zh,en`." },
|
|
22
|
+
after: { type: "string", description: "Only pages published after this date, `2025` or `2025-01-01`." },
|
|
23
|
+
before: { type: "string", description: "Only pages published before this date, `2026` or `2026-06-01`." },
|
|
24
|
+
oriQuestion: { type: "string", description: "The user's original question verbatim; helps the engine infer intent on noisy queries." },
|
|
25
|
+
needContent: { type: "boolean", description: "Also return page body for each result (bigger payload, same price). Prefer `deepseek_fetch` for one specific page." },
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
properties: {
|
|
31
|
+
query: { type: "string", required: true },
|
|
32
|
+
total: { type: "integer", required: true },
|
|
33
|
+
billed: { type: "boolean", required: true },
|
|
34
|
+
results: {
|
|
35
|
+
type: "array",
|
|
36
|
+
required: true,
|
|
37
|
+
items: {
|
|
38
|
+
type: "object",
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
properties: {
|
|
41
|
+
rank: { type: "integer", required: true },
|
|
42
|
+
title: { type: "string", required: true },
|
|
43
|
+
url: { type: "string", required: true },
|
|
44
|
+
snippet: { type: "string", required: true },
|
|
45
|
+
summary: { type: "string", required: true },
|
|
46
|
+
content: { type: "string", required: true },
|
|
47
|
+
siteName: { type: "string", required: true },
|
|
48
|
+
date: { type: "string", required: true },
|
|
49
|
+
authority: { type: "integer", required: true },
|
|
50
|
+
rerankScore: { type: "number", required: true },
|
|
51
|
+
good: { type: "boolean", required: true },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
call: (args) => ({ card: "generic", title: `deepseek_search ${args.query}`, kind: "search" }),
|
|
58
|
+
render: (_args, value) => [{ type: "text", text: renderSearchReport(value) }],
|
|
59
|
+
execute: (args) =>
|
|
60
|
+
guarded(async () => {
|
|
61
|
+
const query = buildQuery({
|
|
62
|
+
query: requireText(args.query, "query"),
|
|
63
|
+
site: args.site,
|
|
64
|
+
lang: args.lang,
|
|
65
|
+
after: args.after,
|
|
66
|
+
before: args.before,
|
|
67
|
+
});
|
|
68
|
+
const count = clampInt(args.count, config.defaultCount, 1, 50);
|
|
69
|
+
const oriQuestion = args.oriQuestion ? String(args.oriQuestion).trim() : "";
|
|
70
|
+
const needContent = args.needContent === true;
|
|
71
|
+
const raw = await client.search({ query, count, oriQuestion, needContent });
|
|
72
|
+
const results = raw.map((item, index) =>
|
|
73
|
+
normalizeResult(item, index + 1, { contentLimit: needContent ? config.maxContentChars : 0 }),
|
|
74
|
+
);
|
|
75
|
+
return { query, total: results.length, billed: results.length > 0, results };
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers every tool file shares: argument clamping, error surfacing, and the
|
|
3
|
+
* "this argument must be a non-blank string" guard.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-search/tools
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a numeric argument into range.
|
|
10
|
+
*
|
|
11
|
+
* @param {unknown} value - candidate value.
|
|
12
|
+
* @param {number} fallback - default when absent or non-numeric.
|
|
13
|
+
* @param {number} min - lower bound.
|
|
14
|
+
* @param {number} max - upper bound.
|
|
15
|
+
* @returns {number} clamped integer.
|
|
16
|
+
*/
|
|
17
|
+
export function clampInt(value, fallback, min, max) {
|
|
18
|
+
const numeric = Number(value);
|
|
19
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
20
|
+
return Math.min(Math.max(Math.trunc(numeric), min), max);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Require a non-blank string argument.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} value - candidate value.
|
|
27
|
+
* @param {string} field - argument name, for the error message.
|
|
28
|
+
* @returns {string} trimmed value.
|
|
29
|
+
* @throws {Error} when the value is missing or blank.
|
|
30
|
+
*/
|
|
31
|
+
export function requireText(value, field) {
|
|
32
|
+
const text = String(value ?? "").trim();
|
|
33
|
+
if (text === "") throw new Error(`deepseek-search: \`${field}\` 不能为空`);
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a tool body so client failures carry their actionable hint.
|
|
39
|
+
*
|
|
40
|
+
* @param {() => Promise<any>} body - tool body.
|
|
41
|
+
* @returns {Promise<any>} body result.
|
|
42
|
+
*/
|
|
43
|
+
export async function guarded(body) {
|
|
44
|
+
try {
|
|
45
|
+
return await body();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const hint = /** @type {{hint?: string}} */ (error)?.hint;
|
|
48
|
+
if (hint) throw new Error(`${error.message} — ${hint}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry: one tool per file, in the order the model should see them.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-search/tools
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { clampInt, guarded, requireText } from "./tools/shared.js";
|
|
8
|
+
import { createSearchTool } from "./tools/search.js";
|
|
9
|
+
import { createFetchTool } from "./tools/fetch.js";
|
|
10
|
+
import { createResearchTool } from "./tools/research.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build every tool spec for one plugin instance.
|
|
14
|
+
*
|
|
15
|
+
* @param {{client: import("./client.js").SearchClient, config: Record<string, any>}} deps - plugin deps.
|
|
16
|
+
* @returns {Array<Record<string, any>>} tool specs in presentation order.
|
|
17
|
+
*/
|
|
18
|
+
export function createToolSpecs({ client, config }) {
|
|
19
|
+
const ctx = { client, config, clampInt, guarded, requireText };
|
|
20
|
+
return [createSearchTool(ctx), createFetchTool(ctx), createResearchTool(ctx)];
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vfvrpq/dsh-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DeepSeek first-party Search/Fetch API for DeepSeek Harness: three tools (deepseek_search / deepseek_fetch / deepseek_research) that return structured, reranked web results with search operators, page-to-Markdown fetching, and cost-aware defaults.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "439436269-ctrl",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/439436269-ctrl/dsh-search.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/439436269-ctrl/dsh-search#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/439436269-ctrl/dsh-search/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"dsh",
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"deepseek",
|
|
25
|
+
"search",
|
|
26
|
+
"fetch",
|
|
27
|
+
"web-search",
|
|
28
|
+
"rag"
|
|
29
|
+
],
|
|
30
|
+
"main": "lib/index.js",
|
|
31
|
+
"types": "lib/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./lib/index.d.ts",
|
|
35
|
+
"default": "./lib/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"lib/**/*.js",
|
|
41
|
+
"lib/**/*.d.ts",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"cordis.patch.yml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
+
"@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1",
|
|
53
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
},
|
|
59
|
+
"compatibility": {
|
|
60
|
+
"dshReleases": {
|
|
61
|
+
"0.2.0-rc.2": "compatible",
|
|
62
|
+
"0.2.1-rc.2": "compatible",
|
|
63
|
+
"0.2.2-rc.1": "compatible"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "node --test test/*.test.js",
|
|
69
|
+
"selftest": "node scripts/live-selftest.js"
|
|
70
|
+
}
|
|
71
|
+
}
|