chatccc 0.2.227 → 0.2.229
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/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +329 -350
- package/src/__tests__/builtin-file-tools.test.ts +240 -275
- package/src/__tests__/builtin-skills.test.ts +42 -10
- package/src/__tests__/builtin-web-tools.test.ts +220 -0
- package/src/__tests__/restart.test.ts +132 -0
- package/src/builtin/context.ts +333 -323
- package/src/builtin/file-tools.ts +54 -2
- package/src/builtin/index.ts +12 -5
- package/src/builtin/skills.ts +17 -2
- package/src/builtin/web-tools.ts +313 -0
- package/src/index.ts +315 -310
- package/src/orchestrator.ts +2489 -2388
|
@@ -3,13 +3,22 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { createReadStream } from "node:fs";
|
|
4
4
|
import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import {
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
8
|
import { createInterface } from "node:readline";
|
|
8
9
|
|
|
9
10
|
import { jsonSchema, tool, type ToolSet } from "ai";
|
|
10
11
|
|
|
11
12
|
import { isDangerousCommand, type PermissionGate, type PermissionRequest } from "./permissions.js";
|
|
12
13
|
import { killProcessTree } from "./proc-tree-kill.js";
|
|
14
|
+
import {
|
|
15
|
+
webFetchForTool,
|
|
16
|
+
webSearchForTool,
|
|
17
|
+
type WebFetchInput,
|
|
18
|
+
type WebFetchOutput,
|
|
19
|
+
type WebSearchInput,
|
|
20
|
+
type WebSearchOutput,
|
|
21
|
+
} from "./web-tools.js";
|
|
13
22
|
|
|
14
23
|
const MAX_READ_BYTES = 1024 * 1024;
|
|
15
24
|
const MAX_LIST_ENTRIES = 200;
|
|
@@ -180,10 +189,24 @@ export interface ApplyPatchOutput {
|
|
|
180
189
|
changedFiles: ApplyPatchFileChange[];
|
|
181
190
|
}
|
|
182
191
|
|
|
192
|
+
/**
|
|
193
|
+
* 把 `~` / `~/x`(含反斜杠 `~\\x`)展开为用户主目录绝对路径。
|
|
194
|
+
* `~user/x` 形式不展开(Node 无内置支持),保持原样交给后续解析。
|
|
195
|
+
* 供路径工具共用:技能索引里 `~/...` 路径可直接传给 read_file 等工具。
|
|
196
|
+
*/
|
|
197
|
+
export function expandHomePath(value: string): string {
|
|
198
|
+
if (value === "~") return homedir();
|
|
199
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
200
|
+
return join(homedir(), value.slice(2));
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
183
205
|
function resolveToolPath(cwd: string, value: string | undefined): string {
|
|
184
206
|
const raw = value?.trim();
|
|
185
207
|
if (!raw) return resolve(cwd);
|
|
186
|
-
|
|
208
|
+
const expanded = expandHomePath(raw);
|
|
209
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
function toPositiveInt(value: number | undefined): number | undefined {
|
|
@@ -1403,5 +1426,34 @@ export function createBuiltinFileTools(
|
|
|
1403
1426
|
return applyPatchForTool(cwd, input);
|
|
1404
1427
|
},
|
|
1405
1428
|
}),
|
|
1429
|
+
// 联网工具:只读外部网络操作,不触碰本地文件系统,无需权限询问
|
|
1430
|
+
websearch: tool<WebSearchInput, WebSearchOutput>({
|
|
1431
|
+
description:
|
|
1432
|
+
"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.",
|
|
1433
|
+
inputSchema: jsonSchema<WebSearchInput>({
|
|
1434
|
+
type: "object",
|
|
1435
|
+
additionalProperties: false,
|
|
1436
|
+
properties: {
|
|
1437
|
+
query: { type: "string", description: "Search query." },
|
|
1438
|
+
maxResults: { type: "number", description: "Optional result count, default 5, capped at 10." },
|
|
1439
|
+
},
|
|
1440
|
+
required: ["query"],
|
|
1441
|
+
}),
|
|
1442
|
+
execute: (input, options) => webSearchForTool(input, { abortSignal: options.abortSignal }),
|
|
1443
|
+
}),
|
|
1444
|
+
webfetch: tool<WebFetchInput, WebFetchOutput>({
|
|
1445
|
+
description:
|
|
1446
|
+
"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.",
|
|
1447
|
+
inputSchema: jsonSchema<WebFetchInput>({
|
|
1448
|
+
type: "object",
|
|
1449
|
+
additionalProperties: false,
|
|
1450
|
+
properties: {
|
|
1451
|
+
url: { type: "string", description: "http/https URL to fetch." },
|
|
1452
|
+
maxChars: { type: "number", description: "Optional text length cap, default 10000, capped at 100000." },
|
|
1453
|
+
},
|
|
1454
|
+
required: ["url"],
|
|
1455
|
+
}),
|
|
1456
|
+
execute: (input, options) => webFetchForTool(input, { abortSignal: options.abortSignal }),
|
|
1457
|
+
}),
|
|
1406
1458
|
};
|
|
1407
1459
|
}
|
package/src/builtin/index.ts
CHANGED
|
@@ -230,21 +230,26 @@ export class ChatSession {
|
|
|
230
230
|
);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* 组装系统提示词。顺序遵循“稳定性优先”原则(缓存命中友好):
|
|
235
|
+
* 固定规则 → 项目指令 → runtime 上下文 → 用户补充 → 技能索引(最后)。
|
|
236
|
+
* 技能索引是最易变的部分(热加载,任何 SKILL.md 变化都会改前缀),
|
|
237
|
+
* 放最后可以让前面的稳定内容尽量命中缓存,只丢尾段。
|
|
238
|
+
*/
|
|
234
239
|
private buildSystemPrompt(skills: BuiltinSkill[]): string {
|
|
235
240
|
const systemContent = [SYSTEM_PROMPT];
|
|
236
241
|
const projectInstructions = readProjectInstructionFiles(this.cwd);
|
|
237
242
|
if (projectInstructions) {
|
|
238
243
|
systemContent.push("", projectInstructions);
|
|
239
244
|
}
|
|
245
|
+
systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
|
|
246
|
+
if (this.customSystemPrompt) {
|
|
247
|
+
systemContent.push("", this.customSystemPrompt);
|
|
248
|
+
}
|
|
240
249
|
const skillsPrompt = buildSkillsIndexPrompt(skills);
|
|
241
250
|
if (skillsPrompt) {
|
|
242
251
|
systemContent.push("", skillsPrompt);
|
|
243
252
|
}
|
|
244
|
-
if (this.customSystemPrompt) {
|
|
245
|
-
systemContent.push("", this.customSystemPrompt);
|
|
246
|
-
}
|
|
247
|
-
systemContent.push("", buildRuntimeWorkspacePrompt(this.cwd));
|
|
248
253
|
return systemContent.join("\n");
|
|
249
254
|
}
|
|
250
255
|
|
|
@@ -405,6 +410,8 @@ export class ChatSession {
|
|
|
405
410
|
system: SUMMARY_SYSTEM_PROMPT,
|
|
406
411
|
messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
|
|
407
412
|
abortSignal: signal,
|
|
413
|
+
// 温度 0:相同输入尽量产出相同摘要,避免压缩后上下文前缀随机漂移破坏缓存
|
|
414
|
+
temperature: 0,
|
|
408
415
|
});
|
|
409
416
|
|
|
410
417
|
if (!result.text.trim()) return 0;
|
package/src/builtin/skills.ts
CHANGED
|
@@ -17,7 +17,6 @@ import { readdirSync } from "node:fs";
|
|
|
17
17
|
import { readFile, stat } from "node:fs/promises";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
|
|
21
20
|
export type SkillSource = "deepccc" | "codex" | "cursor" | "claude";
|
|
22
21
|
export type SkillScope = "global" | "project";
|
|
23
22
|
|
|
@@ -140,11 +139,27 @@ export function buildDefaultSkillDirs(cwd: string): SkillDirSpec[] {
|
|
|
140
139
|
];
|
|
141
140
|
}
|
|
142
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 把技能绝对路径转成 prompt 展示形式:主目录下的路径缩写为 `~/...`(跨机器、
|
|
144
|
+
* 跨用户稳定,避免用户名/盘符差异导致 system 前缀变化破坏缓存命中);
|
|
145
|
+
* 分隔符统一为 `/`。主目录外的路径保持绝对路径(仅统一分隔符)。
|
|
146
|
+
*/
|
|
147
|
+
export function normalizeSkillPathForPrompt(skillPath: string): string {
|
|
148
|
+
const home = homedir().replace(/[\\/]+$/, "").replace(/\\/g, "/");
|
|
149
|
+
const normalized = skillPath.replace(/\\/g, "/");
|
|
150
|
+
if (normalized === home) return "~";
|
|
151
|
+
if (normalized.startsWith(home + "/")) {
|
|
152
|
+
return "~" + normalized.slice(home.length);
|
|
153
|
+
}
|
|
154
|
+
return normalized;
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
/**
|
|
144
158
|
* 生成 skill 索引提示词(注入 system prompt)。
|
|
145
159
|
* 索引只含 name + description + 来源 + 路径,并指示模型在任务匹配时
|
|
146
160
|
* 先用 read_file 读取 SKILL.md 全文再执行;同时声明"创建技能"约定
|
|
147
161
|
* (模型在对话中应把新技能创建到 ~/.deepccc/skills 或项目 .deepccc/skills)。
|
|
162
|
+
* 路径经 normalizeSkillPathForPrompt 缩写,保持跨机器前缀稳定。
|
|
148
163
|
*/
|
|
149
164
|
export function buildSkillsIndexPrompt(skills: BuiltinSkill[]): string {
|
|
150
165
|
if (skills.length === 0) return "";
|
|
@@ -155,7 +170,7 @@ export function buildSkillsIndexPrompt(skills: BuiltinSkill[]): string {
|
|
|
155
170
|
"On name conflicts: DeepCCC > Codex > Cursor > Claude wins; within one source, project scope wins over global.",
|
|
156
171
|
"When a user request matches a skill's description, first read its full SKILL.md with read_file, then follow the instructions in it exactly.",
|
|
157
172
|
"",
|
|
158
|
-
...skills.map((s) => `- **${s.name}** [${s.source}:${s.scope}] (\`${s.skillPath}\`): ${s.description || "(no description)"}`),
|
|
173
|
+
...skills.map((s) => `- **${s.name}** [${s.source}:${s.scope}] (\`${normalizeSkillPathForPrompt(s.skillPath)}\`): ${s.description || "(no description)"}`),
|
|
159
174
|
"",
|
|
160
175
|
"## Creating Skills",
|
|
161
176
|
"When the user asks to create a skill, create it as a Codex-style directory skill at",
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
|
|
15
|
+
export interface WebSearchInput {
|
|
16
|
+
query: string;
|
|
17
|
+
/** 返回结果条数上限,默认 5,上限 10 */
|
|
18
|
+
maxResults?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WebSearchResult {
|
|
22
|
+
title: string;
|
|
23
|
+
url: string;
|
|
24
|
+
snippet: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebSearchOutput {
|
|
28
|
+
query: string;
|
|
29
|
+
results: WebSearchResult[];
|
|
30
|
+
truncated: boolean;
|
|
31
|
+
durationMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebFetchInput {
|
|
35
|
+
url: string;
|
|
36
|
+
/** 返回纯文本字符数上限,默认 10000,上限 100000 */
|
|
37
|
+
maxChars?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebFetchOutput {
|
|
41
|
+
url: string;
|
|
42
|
+
contentType: string;
|
|
43
|
+
title: string;
|
|
44
|
+
text: string;
|
|
45
|
+
chars: number;
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
durationMs: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const WEB_SEARCH_TIMEOUT_MS = 15_000;
|
|
51
|
+
export const WEB_FETCH_TIMEOUT_MS = 20_000;
|
|
52
|
+
export const WEB_SEARCH_DEFAULT_RESULTS = 5;
|
|
53
|
+
export const WEB_SEARCH_MAX_RESULTS = 10;
|
|
54
|
+
export const WEB_FETCH_DEFAULT_CHARS = 10_000;
|
|
55
|
+
export const WEB_FETCH_MAX_CHARS = 100_000;
|
|
56
|
+
/** webfetch 读取响应体的大小上限(超出即截断),防止把整个大文件拉进上下文 */
|
|
57
|
+
export const WEB_FETCH_MAX_BYTES = 512 * 1024;
|
|
58
|
+
|
|
59
|
+
const SEARCH_UA =
|
|
60
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
61
|
+
|
|
62
|
+
export type FetchLike = (
|
|
63
|
+
input: string | URL | Request,
|
|
64
|
+
init?: RequestInit,
|
|
65
|
+
) => Promise<Response>;
|
|
66
|
+
|
|
67
|
+
export interface WebToolOptions {
|
|
68
|
+
abortSignal?: AbortSignal;
|
|
69
|
+
/** 测试注入用,默认全局 fetch */
|
|
70
|
+
fetchImpl?: FetchLike;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeMaxResults(value: number | undefined): number {
|
|
75
|
+
if (value === undefined) return WEB_SEARCH_DEFAULT_RESULTS;
|
|
76
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
77
|
+
throw new Error("maxResults must be a positive integer when provided");
|
|
78
|
+
}
|
|
79
|
+
return Math.min(value, WEB_SEARCH_MAX_RESULTS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeMaxChars(value: number | undefined): number {
|
|
83
|
+
if (value === undefined) return WEB_FETCH_DEFAULT_CHARS;
|
|
84
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
85
|
+
throw new Error("maxChars must be a positive integer when provided");
|
|
86
|
+
}
|
|
87
|
+
return Math.min(value, WEB_FETCH_MAX_CHARS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildSignal(timeoutMs: number, abortSignal?: AbortSignal): AbortSignal {
|
|
91
|
+
const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
|
|
92
|
+
if (abortSignal) signals.push(abortSignal);
|
|
93
|
+
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function decodeEntities(s: string): string {
|
|
97
|
+
return s
|
|
98
|
+
.replace(/</gi, "<")
|
|
99
|
+
.replace(/>/gi, ">")
|
|
100
|
+
.replace(/"/gi, '"')
|
|
101
|
+
.replace(/�?39;/gi, "'")
|
|
102
|
+
.replace(/ /gi, " ")
|
|
103
|
+
.replace(/&#(\d+);/g, (_, n: string) => {
|
|
104
|
+
try {
|
|
105
|
+
return String.fromCodePoint(Number(n));
|
|
106
|
+
} catch {
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h: string) => {
|
|
111
|
+
try {
|
|
112
|
+
return String.fromCodePoint(parseInt(h, 16));
|
|
113
|
+
} catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
.replace(/&/gi, "&");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stripTags(s: string): string {
|
|
121
|
+
return decodeEntities(s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 提取 <title> 文本(用于 webfetch 结果),无则返回空字符串 */
|
|
125
|
+
export function extractHtmlTitle(html: string): string {
|
|
126
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
127
|
+
return m ? stripTags(m[1]) : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* HTML 转纯文本:
|
|
132
|
+
* 去掉 script/style/noscript/svg/注释 → 块级元素补换行 → 去标签 → 解实体 → 压缩空白。
|
|
133
|
+
*/
|
|
134
|
+
export function htmlToPlainText(html: string): string {
|
|
135
|
+
let text = html
|
|
136
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
|
|
137
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
138
|
+
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, " ")
|
|
139
|
+
.replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, " ")
|
|
140
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
141
|
+
.replace(/<\/(p|div|li|tr|h[1-6]|pre|blockquote|section|article|table|ul|ol|header|footer|nav|dl|dd|dt)>/gi, "\n")
|
|
142
|
+
.replace(/<(br|hr)\s*\/?>/gi, "\n")
|
|
143
|
+
.replace(/<[^>]+>/g, "");
|
|
144
|
+
|
|
145
|
+
text = decodeEntities(text);
|
|
146
|
+
|
|
147
|
+
return text
|
|
148
|
+
.split("\n")
|
|
149
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
150
|
+
.filter((line, i, arr) => line !== "" || (i > 0 && arr[i - 1] !== ""))
|
|
151
|
+
.join("\n")
|
|
152
|
+
.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 解码 DuckDuckGo 结果链接:/l/?uddg=<encoded> 还原为目标 URL */
|
|
156
|
+
export function decodeDdgHref(href: string): string {
|
|
157
|
+
let h = href.trim();
|
|
158
|
+
if (h.startsWith("//")) h = "https:" + h;
|
|
159
|
+
try {
|
|
160
|
+
const u = new URL(h);
|
|
161
|
+
if (u.hostname === "duckduckgo.com" && u.pathname.startsWith("/l/")) {
|
|
162
|
+
const uddg = u.searchParams.get("uddg");
|
|
163
|
+
if (uddg) return decodeURIComponent(uddg);
|
|
164
|
+
}
|
|
165
|
+
return u.toString();
|
|
166
|
+
} catch {
|
|
167
|
+
return h;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 解析 DuckDuckGo HTML 搜索结果页(html.duckduckgo.com/html)。
|
|
173
|
+
* 提取 result__a(标题 + href)与 result__snippet(摘要)。
|
|
174
|
+
*/
|
|
175
|
+
export function parseDuckDuckGoHtml(html: string): WebSearchResult[] {
|
|
176
|
+
const results: WebSearchResult[] = [];
|
|
177
|
+
const titleRe = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
178
|
+
const snippetRe = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
179
|
+
|
|
180
|
+
const titleMatches = [...html.matchAll(titleRe)];
|
|
181
|
+
const snippetMatches = [...html.matchAll(snippetRe)];
|
|
182
|
+
|
|
183
|
+
titleMatches.forEach((m, i) => {
|
|
184
|
+
const title = stripTags(m[2]);
|
|
185
|
+
const url = decodeDdgHref(m[1]);
|
|
186
|
+
const snippet = snippetMatches[i] ? stripTags(snippetMatches[i][1]) : "";
|
|
187
|
+
if (!title && !url) return;
|
|
188
|
+
results.push({ title, url, snippet });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 读取响应体并限制字节数(超出截断),避免整页大文件进入上下文 */
|
|
195
|
+
async function readBodyWithLimit(res: Response, maxBytes: number): Promise<{ text: string; truncated: boolean }> {
|
|
196
|
+
if (!res.body) {
|
|
197
|
+
const text = await res.text();
|
|
198
|
+
return { text, truncated: Buffer.byteLength(text, "utf8") > maxBytes };
|
|
199
|
+
}
|
|
200
|
+
const reader = res.body.getReader();
|
|
201
|
+
const chunks: Uint8Array[] = [];
|
|
202
|
+
let total = 0;
|
|
203
|
+
let truncated = false;
|
|
204
|
+
for (;;) {
|
|
205
|
+
const { done, value } = await reader.read();
|
|
206
|
+
if (done) break;
|
|
207
|
+
if (!value) continue;
|
|
208
|
+
if (total + value.byteLength > maxBytes) {
|
|
209
|
+
const remaining = maxBytes - total;
|
|
210
|
+
if (remaining > 0) chunks.push(value.subarray(0, remaining));
|
|
211
|
+
truncated = true;
|
|
212
|
+
await reader.cancel().catch(() => {});
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
chunks.push(value);
|
|
216
|
+
total += value.byteLength;
|
|
217
|
+
}
|
|
218
|
+
return { text: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8"), truncated };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* websearch:DuckDuckGo HTML 搜索(免 API key),返回标题 + URL + 摘要。
|
|
223
|
+
* 网络失败抛错(成为 tool-error),搜索无结果返回空数组(不是错误)。
|
|
224
|
+
*/
|
|
225
|
+
export async function webSearchForTool(
|
|
226
|
+
input: WebSearchInput,
|
|
227
|
+
options: WebToolOptions = {},
|
|
228
|
+
): Promise<WebSearchOutput> {
|
|
229
|
+
const query = input.query?.trim();
|
|
230
|
+
if (!query) throw new Error("query is required");
|
|
231
|
+
|
|
232
|
+
const maxResults = normalizeMaxResults(input.maxResults);
|
|
233
|
+
const startedAt = Date.now();
|
|
234
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
235
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
236
|
+
|
|
237
|
+
const res = await fetchImpl(url, {
|
|
238
|
+
headers: {
|
|
239
|
+
"User-Agent": SEARCH_UA,
|
|
240
|
+
Accept: "text/html,application/xhtml+xml",
|
|
241
|
+
},
|
|
242
|
+
redirect: "follow",
|
|
243
|
+
signal: buildSignal(options.timeoutMs ?? WEB_SEARCH_TIMEOUT_MS, options.abortSignal),
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
throw new Error(`web search failed: HTTP ${res.status} ${res.statusText}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { text } = await readBodyWithLimit(res, WEB_FETCH_MAX_BYTES);
|
|
250
|
+
const all = parseDuckDuckGoHtml(text);
|
|
251
|
+
const results = all.slice(0, maxResults);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
query,
|
|
255
|
+
results,
|
|
256
|
+
truncated: all.length > results.length,
|
|
257
|
+
durationMs: Date.now() - startedAt,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* webfetch:HTTP GET + HTML 转纯文本。
|
|
263
|
+
* 只允许 http/https(防 file:// 等本地协议),非 2xx 抛错。
|
|
264
|
+
*/
|
|
265
|
+
export async function webFetchForTool(
|
|
266
|
+
input: WebFetchInput,
|
|
267
|
+
options: WebToolOptions = {},
|
|
268
|
+
): Promise<WebFetchOutput> {
|
|
269
|
+
const raw = input.url?.trim();
|
|
270
|
+
if (!raw) throw new Error("url is required");
|
|
271
|
+
|
|
272
|
+
let parsed: URL;
|
|
273
|
+
try {
|
|
274
|
+
parsed = new URL(raw);
|
|
275
|
+
} catch {
|
|
276
|
+
throw new Error(`invalid URL: ${raw}`);
|
|
277
|
+
}
|
|
278
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
279
|
+
throw new Error(`unsupported protocol: ${parsed.protocol} (only http/https allowed)`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const maxChars = normalizeMaxChars(input.maxChars);
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
285
|
+
|
|
286
|
+
const res = await fetchImpl(parsed.toString(), {
|
|
287
|
+
headers: {
|
|
288
|
+
"User-Agent": SEARCH_UA,
|
|
289
|
+
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
|
|
290
|
+
},
|
|
291
|
+
redirect: "follow",
|
|
292
|
+
signal: buildSignal(options.timeoutMs ?? WEB_FETCH_TIMEOUT_MS, options.abortSignal),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} for ${parsed.toString()}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
299
|
+
const { text: body, truncated: bodyTruncated } = await readBodyWithLimit(res, WEB_FETCH_MAX_BYTES);
|
|
300
|
+
const title = extractHtmlTitle(body);
|
|
301
|
+
const plain = htmlToPlainText(body);
|
|
302
|
+
const text = plain.length > maxChars ? plain.slice(0, maxChars) : plain;
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
url: parsed.toString(),
|
|
306
|
+
contentType,
|
|
307
|
+
title,
|
|
308
|
+
text,
|
|
309
|
+
chars: text.length,
|
|
310
|
+
truncated: bodyTruncated || plain.length > maxChars,
|
|
311
|
+
durationMs: Date.now() - startedAt,
|
|
312
|
+
};
|
|
313
|
+
}
|