deepccc 0.1.4 → 0.1.6
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/README.md +23 -22
- package/dist/file-tools.js +28 -0
- package/dist/web-tools.d.ts +82 -0
- package/dist/web-tools.js +246 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,23 +73,6 @@ $env:DEEPCCC_MODEL="deepseek-v4-pro"
|
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
## 隐私替换
|
|
77
|
-
|
|
78
|
-
`deepccc` 支持在展示层(终端输出 / JSONL 流式输出)把敏感信息替换为掩码值,防止用户名、路径等隐私内容出现在终端或管道输出里。创建 `~/.deepccc/privacy.json` 即可启用:
|
|
79
|
-
|
|
80
|
-
```json
|
|
81
|
-
{
|
|
82
|
-
"enabled": true,
|
|
83
|
-
"rules": {
|
|
84
|
-
"weizhangjian": "wzj"
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
- `rules` 的键按字面量替换(`split`/`join`,非正则),可配置多条规则;不配置 `enabled`/`rules` 字段时,扁平写法 `{ "weizhangjian": "wzj" }` 也兼容(等效 `enabled: true`)。
|
|
90
|
-
- 替换作用于模型回复文本、工具调用参数与结果、错误消息中的字符串字段;持久化上下文仍保存原文,替换仅影响展示层。
|
|
91
|
-
- 文件变更后自动热加载,无需重启进程。
|
|
92
|
-
|
|
93
76
|
## 命令行交互
|
|
94
77
|
|
|
95
78
|
在当前目录启动一个交互式 Agent:
|
|
@@ -251,13 +234,29 @@ ChatCCC 是一个把 Claude Code / Codex / Cursor / CCC Agent 聚合到飞书/
|
|
|
251
234
|
|
|
252
235
|
这些内容会放在固定系统提示词之后,作为项目指导使用。
|
|
253
236
|
|
|
254
|
-
##
|
|
237
|
+
## Skills 自动加载
|
|
238
|
+
|
|
239
|
+
`deepccc` 会并行扫描本机 **Claude / Codex / Cursor / DeepCCC** 四套生态的目录式 skill(`<name>/SKILL.md`,含 `name` + `description` frontmatter),把索引注入系统提示词;模型在任务匹配时先用 `read_file` 读取 `SKILL.md` 全文再执行。
|
|
240
|
+
|
|
241
|
+
自动加载的目录(按优先级从低到高排列,扫描时后者覆盖前者):
|
|
242
|
+
|
|
243
|
+
| 目录 | 来源 | 级别 |
|
|
244
|
+
| --- | --- | --- |
|
|
245
|
+
| `~/.claude/skills` | claude | 用户级 |
|
|
246
|
+
| `<cwd>/.claude/skills` | claude | 项目级 |
|
|
247
|
+
| `~/.cursor/skills` | cursor | 用户级 |
|
|
248
|
+
| `<cwd>/.cursor/skills` | cursor | 项目级 |
|
|
249
|
+
| `~/.codex/skills` | codex | 用户级 |
|
|
250
|
+
| `~/.agents/skills` | codex | 用户级(标准全局目录) |
|
|
251
|
+
| `<cwd>/.codex/skills` | codex | 项目级 |
|
|
252
|
+
| `~/.deepccc/skills` | deepccc | 用户级 |
|
|
253
|
+
| `<cwd>/.deepccc/skills` | deepccc | 项目级 |
|
|
254
|
+
|
|
255
|
+
**同名去重优先级(高 → 低):`deepccc` > `codex` > `cursor` > `claude`**;同一来源内:**项目级(project)> 用户级(global)**。扫描时低优先级先入索引、高优先级同名覆盖,天然实现优先级。
|
|
255
256
|
|
|
256
|
-
|
|
257
|
+
扫描带 mtime 热加载缓存:SKILL.md 内容变化自动重读,新技能目录每次扫描立即被发现——因此"创建技能 → 下一次对话自动生效",无需重启。
|
|
257
258
|
|
|
258
|
-
|
|
259
|
-
- `~/.agents/skills`
|
|
260
|
-
- `<cwd>/.codex/skills`(项目级,优先级最高)
|
|
259
|
+
需要新建技能时,创建为 Codex 结构:`~/.deepccc/skills/<name>/SKILL.md`(默认,全局)或 `<cwd>/.deepccc/skills/<name>/SKILL.md`(`--scope project`,仅当用户明确要求项目级时)。
|
|
261
260
|
|
|
262
261
|
## 内置工具
|
|
263
262
|
|
|
@@ -269,6 +268,8 @@ ChatCCC 是一个把 Claude Code / Codex / Cursor / CCC Agent 聚合到飞书/
|
|
|
269
268
|
- 编辑、创建、删除、移动文件
|
|
270
269
|
- 应用 unified diff patch
|
|
271
270
|
- 运行非交互式 shell 命令,并返回 stdout、stderr、exitCode 和超时状态
|
|
271
|
+
- 联网搜索(`websearch`:DuckDuckGo,免 API key,返回标题 + URL + 摘要)
|
|
272
|
+
- 抓取网页并转纯文本(`webfetch`:仅 http/https,自动去 HTML 标签、控制长度与超时)
|
|
272
273
|
|
|
273
274
|
命令返回非零退出码时不会直接被当成工具异常;模型可以读取结构化结果,继续判断下一步。
|
|
274
275
|
|
package/dist/file-tools.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createInterface } from "node:readline";
|
|
|
8
8
|
import { jsonSchema, tool } from "ai";
|
|
9
9
|
import { isDangerousCommand } from "./permissions.js";
|
|
10
10
|
import { killProcessTree } from "./proc-tree-kill.js";
|
|
11
|
+
import { webFetchForTool, webSearchForTool, } from "./web-tools.js";
|
|
11
12
|
const MAX_READ_BYTES = 1024 * 1024;
|
|
12
13
|
const MAX_LIST_ENTRIES = 200;
|
|
13
14
|
const MAX_SEARCH_RESULTS = 100;
|
|
@@ -1138,5 +1139,32 @@ export function createBuiltinFileTools(cwd, options = {}) {
|
|
|
1138
1139
|
return applyPatchForTool(cwd, input);
|
|
1139
1140
|
},
|
|
1140
1141
|
}),
|
|
1142
|
+
// 联网工具:只读外部网络操作,不触碰本地文件系统,无需权限询问
|
|
1143
|
+
websearch: tool({
|
|
1144
|
+
description: "Search the web (DuckDuckGo, no API key) and return matching titles, URLs, and snippets. Use when you need current or external information not available locally, e.g. latest docs, news, or package versions.",
|
|
1145
|
+
inputSchema: jsonSchema({
|
|
1146
|
+
type: "object",
|
|
1147
|
+
additionalProperties: false,
|
|
1148
|
+
properties: {
|
|
1149
|
+
query: { type: "string", description: "Search query." },
|
|
1150
|
+
maxResults: { type: "number", description: "Optional result count, default 5, capped at 10." },
|
|
1151
|
+
},
|
|
1152
|
+
required: ["query"],
|
|
1153
|
+
}),
|
|
1154
|
+
execute: (input, options) => webSearchForTool(input, { abortSignal: options.abortSignal }),
|
|
1155
|
+
}),
|
|
1156
|
+
webfetch: tool({
|
|
1157
|
+
description: "Fetch a URL and return its readable text content (HTML stripped, truncated). Use for documentation pages, articles, or API docs. Only http/https URLs are allowed.",
|
|
1158
|
+
inputSchema: jsonSchema({
|
|
1159
|
+
type: "object",
|
|
1160
|
+
additionalProperties: false,
|
|
1161
|
+
properties: {
|
|
1162
|
+
url: { type: "string", description: "http/https URL to fetch." },
|
|
1163
|
+
maxChars: { type: "number", description: "Optional text length cap, default 10000, capped at 100000." },
|
|
1164
|
+
},
|
|
1165
|
+
required: ["url"],
|
|
1166
|
+
}),
|
|
1167
|
+
execute: (input, options) => webFetchForTool(input, { abortSignal: options.abortSignal }),
|
|
1168
|
+
}),
|
|
1141
1169
|
};
|
|
1142
1170
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-tools.ts — websearch / webfetch 内置工具(agent 端联网,业界主流做法)
|
|
3
|
+
*
|
|
4
|
+
* 与 Claude Code 的 WebSearch/WebFetch、Codex 的 web search 相同形态:
|
|
5
|
+
* 模型通过 function calling 调用,agent 进程执行 HTTP 请求,结果回填上下文。
|
|
6
|
+
* 两者都是只读外部网络操作,不触碰本地文件系统,无需权限询问。
|
|
7
|
+
*
|
|
8
|
+
* - websearch:DuckDuckGo HTML 端点(免 API key),返回标题 + URL + 摘要
|
|
9
|
+
* - webfetch:HTTP GET + HTML 转纯文本,控制大小与超时
|
|
10
|
+
*
|
|
11
|
+
* 零新依赖:使用 Node 20 内置 fetch / AbortSignal / Buffer。
|
|
12
|
+
* 解析逻辑拆成纯函数导出,便于单测;fetch 可通过 options 注入 mock。
|
|
13
|
+
*/
|
|
14
|
+
export interface WebSearchInput {
|
|
15
|
+
query: string;
|
|
16
|
+
/** 返回结果条数上限,默认 5,上限 10 */
|
|
17
|
+
maxResults?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface WebSearchResult {
|
|
20
|
+
title: string;
|
|
21
|
+
url: string;
|
|
22
|
+
snippet: string;
|
|
23
|
+
}
|
|
24
|
+
export interface WebSearchOutput {
|
|
25
|
+
query: string;
|
|
26
|
+
results: WebSearchResult[];
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
}
|
|
30
|
+
export interface WebFetchInput {
|
|
31
|
+
url: string;
|
|
32
|
+
/** 返回纯文本字符数上限,默认 10000,上限 100000 */
|
|
33
|
+
maxChars?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface WebFetchOutput {
|
|
36
|
+
url: string;
|
|
37
|
+
contentType: string;
|
|
38
|
+
title: string;
|
|
39
|
+
text: string;
|
|
40
|
+
chars: number;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
}
|
|
44
|
+
export declare const WEB_SEARCH_TIMEOUT_MS = 15000;
|
|
45
|
+
export declare const WEB_FETCH_TIMEOUT_MS = 20000;
|
|
46
|
+
export declare const WEB_SEARCH_DEFAULT_RESULTS = 5;
|
|
47
|
+
export declare const WEB_SEARCH_MAX_RESULTS = 10;
|
|
48
|
+
export declare const WEB_FETCH_DEFAULT_CHARS = 10000;
|
|
49
|
+
export declare const WEB_FETCH_MAX_CHARS = 100000;
|
|
50
|
+
/** webfetch 读取响应体的大小上限(超出即截断),防止把整个大文件拉进上下文 */
|
|
51
|
+
export declare const WEB_FETCH_MAX_BYTES: number;
|
|
52
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
53
|
+
export interface WebToolOptions {
|
|
54
|
+
abortSignal?: AbortSignal;
|
|
55
|
+
/** 测试注入用,默认全局 fetch */
|
|
56
|
+
fetchImpl?: FetchLike;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
/** 提取 <title> 文本(用于 webfetch 结果),无则返回空字符串 */
|
|
60
|
+
export declare function extractHtmlTitle(html: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* HTML 转纯文本:
|
|
63
|
+
* 去掉 script/style/noscript/svg/注释 → 块级元素补换行 → 去标签 → 解实体 → 压缩空白。
|
|
64
|
+
*/
|
|
65
|
+
export declare function htmlToPlainText(html: string): string;
|
|
66
|
+
/** 解码 DuckDuckGo 结果链接:/l/?uddg=<encoded> 还原为目标 URL */
|
|
67
|
+
export declare function decodeDdgHref(href: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* 解析 DuckDuckGo HTML 搜索结果页(html.duckduckgo.com/html)。
|
|
70
|
+
* 提取 result__a(标题 + href)与 result__snippet(摘要)。
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseDuckDuckGoHtml(html: string): WebSearchResult[];
|
|
73
|
+
/**
|
|
74
|
+
* websearch:DuckDuckGo HTML 搜索(免 API key),返回标题 + URL + 摘要。
|
|
75
|
+
* 网络失败抛错(成为 tool-error),搜索无结果返回空数组(不是错误)。
|
|
76
|
+
*/
|
|
77
|
+
export declare function webSearchForTool(input: WebSearchInput, options?: WebToolOptions): Promise<WebSearchOutput>;
|
|
78
|
+
/**
|
|
79
|
+
* webfetch:HTTP GET + HTML 转纯文本。
|
|
80
|
+
* 只允许 http/https(防 file:// 等本地协议),非 2xx 抛错。
|
|
81
|
+
*/
|
|
82
|
+
export declare function webFetchForTool(input: WebFetchInput, options?: WebToolOptions): Promise<WebFetchOutput>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-tools.ts — websearch / webfetch 内置工具(agent 端联网,业界主流做法)
|
|
3
|
+
*
|
|
4
|
+
* 与 Claude Code 的 WebSearch/WebFetch、Codex 的 web search 相同形态:
|
|
5
|
+
* 模型通过 function calling 调用,agent 进程执行 HTTP 请求,结果回填上下文。
|
|
6
|
+
* 两者都是只读外部网络操作,不触碰本地文件系统,无需权限询问。
|
|
7
|
+
*
|
|
8
|
+
* - websearch:DuckDuckGo HTML 端点(免 API key),返回标题 + URL + 摘要
|
|
9
|
+
* - webfetch:HTTP GET + HTML 转纯文本,控制大小与超时
|
|
10
|
+
*
|
|
11
|
+
* 零新依赖:使用 Node 20 内置 fetch / AbortSignal / Buffer。
|
|
12
|
+
* 解析逻辑拆成纯函数导出,便于单测;fetch 可通过 options 注入 mock。
|
|
13
|
+
*/
|
|
14
|
+
export const WEB_SEARCH_TIMEOUT_MS = 15_000;
|
|
15
|
+
export const WEB_FETCH_TIMEOUT_MS = 20_000;
|
|
16
|
+
export const WEB_SEARCH_DEFAULT_RESULTS = 5;
|
|
17
|
+
export const WEB_SEARCH_MAX_RESULTS = 10;
|
|
18
|
+
export const WEB_FETCH_DEFAULT_CHARS = 10_000;
|
|
19
|
+
export const WEB_FETCH_MAX_CHARS = 100_000;
|
|
20
|
+
/** webfetch 读取响应体的大小上限(超出即截断),防止把整个大文件拉进上下文 */
|
|
21
|
+
export const WEB_FETCH_MAX_BYTES = 512 * 1024;
|
|
22
|
+
const SEARCH_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
23
|
+
function normalizeMaxResults(value) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return WEB_SEARCH_DEFAULT_RESULTS;
|
|
26
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
27
|
+
throw new Error("maxResults must be a positive integer when provided");
|
|
28
|
+
}
|
|
29
|
+
return Math.min(value, WEB_SEARCH_MAX_RESULTS);
|
|
30
|
+
}
|
|
31
|
+
function normalizeMaxChars(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return WEB_FETCH_DEFAULT_CHARS;
|
|
34
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
35
|
+
throw new Error("maxChars must be a positive integer when provided");
|
|
36
|
+
}
|
|
37
|
+
return Math.min(value, WEB_FETCH_MAX_CHARS);
|
|
38
|
+
}
|
|
39
|
+
function buildSignal(timeoutMs, abortSignal) {
|
|
40
|
+
const signals = [AbortSignal.timeout(timeoutMs)];
|
|
41
|
+
if (abortSignal)
|
|
42
|
+
signals.push(abortSignal);
|
|
43
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
44
|
+
}
|
|
45
|
+
function decodeEntities(s) {
|
|
46
|
+
return s
|
|
47
|
+
.replace(/</gi, "<")
|
|
48
|
+
.replace(/>/gi, ">")
|
|
49
|
+
.replace(/"/gi, '"')
|
|
50
|
+
.replace(/�?39;/gi, "'")
|
|
51
|
+
.replace(/ /gi, " ")
|
|
52
|
+
.replace(/&#(\d+);/g, (_, n) => {
|
|
53
|
+
try {
|
|
54
|
+
return String.fromCodePoint(Number(n));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => {
|
|
61
|
+
try {
|
|
62
|
+
return String.fromCodePoint(parseInt(h, 16));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
.replace(/&/gi, "&");
|
|
69
|
+
}
|
|
70
|
+
function stripTags(s) {
|
|
71
|
+
return decodeEntities(s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
72
|
+
}
|
|
73
|
+
/** 提取 <title> 文本(用于 webfetch 结果),无则返回空字符串 */
|
|
74
|
+
export function extractHtmlTitle(html) {
|
|
75
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
76
|
+
return m ? stripTags(m[1]) : "";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* HTML 转纯文本:
|
|
80
|
+
* 去掉 script/style/noscript/svg/注释 → 块级元素补换行 → 去标签 → 解实体 → 压缩空白。
|
|
81
|
+
*/
|
|
82
|
+
export function htmlToPlainText(html) {
|
|
83
|
+
let text = html
|
|
84
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
|
85
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
86
|
+
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ")
|
|
87
|
+
.replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, " ")
|
|
88
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
89
|
+
.replace(/<\/(p|div|li|tr|h[1-6]|pre|blockquote|section|article|table|ul|ol|header|footer|nav|dl|dd|dt)>/gi, "\n")
|
|
90
|
+
.replace(/<(br|hr)\s*\/?>/gi, "\n")
|
|
91
|
+
.replace(/<[^>]+>/g, "");
|
|
92
|
+
text = decodeEntities(text);
|
|
93
|
+
return text
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
96
|
+
.filter((line, i, arr) => line !== "" || (i > 0 && arr[i - 1] !== ""))
|
|
97
|
+
.join("\n")
|
|
98
|
+
.trim();
|
|
99
|
+
}
|
|
100
|
+
/** 解码 DuckDuckGo 结果链接:/l/?uddg=<encoded> 还原为目标 URL */
|
|
101
|
+
export function decodeDdgHref(href) {
|
|
102
|
+
let h = href.trim();
|
|
103
|
+
if (h.startsWith("//"))
|
|
104
|
+
h = "https:" + h;
|
|
105
|
+
try {
|
|
106
|
+
const u = new URL(h);
|
|
107
|
+
if (u.hostname === "duckduckgo.com" && u.pathname.startsWith("/l/")) {
|
|
108
|
+
const uddg = u.searchParams.get("uddg");
|
|
109
|
+
if (uddg)
|
|
110
|
+
return decodeURIComponent(uddg);
|
|
111
|
+
}
|
|
112
|
+
return u.toString();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return h;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 解析 DuckDuckGo HTML 搜索结果页(html.duckduckgo.com/html)。
|
|
120
|
+
* 提取 result__a(标题 + href)与 result__snippet(摘要)。
|
|
121
|
+
*/
|
|
122
|
+
export function parseDuckDuckGoHtml(html) {
|
|
123
|
+
const results = [];
|
|
124
|
+
const titleRe = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
125
|
+
const snippetRe = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
126
|
+
const titleMatches = [...html.matchAll(titleRe)];
|
|
127
|
+
const snippetMatches = [...html.matchAll(snippetRe)];
|
|
128
|
+
titleMatches.forEach((m, i) => {
|
|
129
|
+
const title = stripTags(m[2]);
|
|
130
|
+
const url = decodeDdgHref(m[1]);
|
|
131
|
+
const snippet = snippetMatches[i] ? stripTags(snippetMatches[i][1]) : "";
|
|
132
|
+
if (!title && !url)
|
|
133
|
+
return;
|
|
134
|
+
results.push({ title, url, snippet });
|
|
135
|
+
});
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
/** 读取响应体并限制字节数(超出截断),避免整页大文件进入上下文 */
|
|
139
|
+
async function readBodyWithLimit(res, maxBytes) {
|
|
140
|
+
if (!res.body) {
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
return { text, truncated: Buffer.byteLength(text, "utf8") > maxBytes };
|
|
143
|
+
}
|
|
144
|
+
const reader = res.body.getReader();
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let total = 0;
|
|
147
|
+
let truncated = false;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done)
|
|
151
|
+
break;
|
|
152
|
+
if (!value)
|
|
153
|
+
continue;
|
|
154
|
+
if (total + value.byteLength > maxBytes) {
|
|
155
|
+
const remaining = maxBytes - total;
|
|
156
|
+
if (remaining > 0)
|
|
157
|
+
chunks.push(value.subarray(0, remaining));
|
|
158
|
+
truncated = true;
|
|
159
|
+
await reader.cancel().catch(() => { });
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
chunks.push(value);
|
|
163
|
+
total += value.byteLength;
|
|
164
|
+
}
|
|
165
|
+
return { text: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8"), truncated };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* websearch:DuckDuckGo HTML 搜索(免 API key),返回标题 + URL + 摘要。
|
|
169
|
+
* 网络失败抛错(成为 tool-error),搜索无结果返回空数组(不是错误)。
|
|
170
|
+
*/
|
|
171
|
+
export async function webSearchForTool(input, options = {}) {
|
|
172
|
+
const query = input.query?.trim();
|
|
173
|
+
if (!query)
|
|
174
|
+
throw new Error("query is required");
|
|
175
|
+
const maxResults = normalizeMaxResults(input.maxResults);
|
|
176
|
+
const startedAt = Date.now();
|
|
177
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
178
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
179
|
+
const res = await fetchImpl(url, {
|
|
180
|
+
headers: {
|
|
181
|
+
"User-Agent": SEARCH_UA,
|
|
182
|
+
Accept: "text/html,application/xhtml+xml",
|
|
183
|
+
},
|
|
184
|
+
redirect: "follow",
|
|
185
|
+
signal: buildSignal(options.timeoutMs ?? WEB_SEARCH_TIMEOUT_MS, options.abortSignal),
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
throw new Error(`web search failed: HTTP ${res.status} ${res.statusText}`);
|
|
189
|
+
}
|
|
190
|
+
const { text } = await readBodyWithLimit(res, WEB_FETCH_MAX_BYTES);
|
|
191
|
+
const all = parseDuckDuckGoHtml(text);
|
|
192
|
+
const results = all.slice(0, maxResults);
|
|
193
|
+
return {
|
|
194
|
+
query,
|
|
195
|
+
results,
|
|
196
|
+
truncated: all.length > results.length,
|
|
197
|
+
durationMs: Date.now() - startedAt,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* webfetch:HTTP GET + HTML 转纯文本。
|
|
202
|
+
* 只允许 http/https(防 file:// 等本地协议),非 2xx 抛错。
|
|
203
|
+
*/
|
|
204
|
+
export async function webFetchForTool(input, options = {}) {
|
|
205
|
+
const raw = input.url?.trim();
|
|
206
|
+
if (!raw)
|
|
207
|
+
throw new Error("url is required");
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = new URL(raw);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new Error(`invalid URL: ${raw}`);
|
|
214
|
+
}
|
|
215
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
216
|
+
throw new Error(`unsupported protocol: ${parsed.protocol} (only http/https allowed)`);
|
|
217
|
+
}
|
|
218
|
+
const maxChars = normalizeMaxChars(input.maxChars);
|
|
219
|
+
const startedAt = Date.now();
|
|
220
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
221
|
+
const res = await fetchImpl(parsed.toString(), {
|
|
222
|
+
headers: {
|
|
223
|
+
"User-Agent": SEARCH_UA,
|
|
224
|
+
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
|
|
225
|
+
},
|
|
226
|
+
redirect: "follow",
|
|
227
|
+
signal: buildSignal(options.timeoutMs ?? WEB_FETCH_TIMEOUT_MS, options.abortSignal),
|
|
228
|
+
});
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} for ${parsed.toString()}`);
|
|
231
|
+
}
|
|
232
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
233
|
+
const { text: body, truncated: bodyTruncated } = await readBodyWithLimit(res, WEB_FETCH_MAX_BYTES);
|
|
234
|
+
const title = extractHtmlTitle(body);
|
|
235
|
+
const plain = htmlToPlainText(body);
|
|
236
|
+
const text = plain.length > maxChars ? plain.slice(0, maxChars) : plain;
|
|
237
|
+
return {
|
|
238
|
+
url: parsed.toString(),
|
|
239
|
+
contentType,
|
|
240
|
+
title,
|
|
241
|
+
text,
|
|
242
|
+
chars: text.length,
|
|
243
|
+
truncated: bodyTruncated || plain.length > maxChars,
|
|
244
|
+
durationMs: Date.now() - startedAt,
|
|
245
|
+
};
|
|
246
|
+
}
|