chatccc 0.2.231 → 0.2.233

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.
Files changed (130) hide show
  1. package/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  2. package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  3. package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  4. package/README.md +90 -90
  5. package/agent-prompts/claude_specific.md +45 -45
  6. package/agent-prompts/codex_specific.md +2 -2
  7. package/agent-prompts/cursor_specific.md +13 -13
  8. package/bin/cccagent.mjs +17 -17
  9. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  10. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  11. package/im-skills/feishu-skill/skill.md +3 -3
  12. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  13. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  14. package/im-skills/wechat-file-skill/skill.md +10 -10
  15. package/im-skills/wechat-image-skill/skill.md +10 -10
  16. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  17. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  18. package/im-skills/wechat-video-skill/skill.md +10 -10
  19. package/package.json +1 -1
  20. package/scripts/postinstall-sharp-check.mjs +58 -58
  21. package/src/__tests__/agent-activity.test.ts +86 -86
  22. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  23. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  24. package/src/__tests__/builtin-chat-session.test.ts +449 -393
  25. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  26. package/src/__tests__/builtin-config.test.ts +26 -26
  27. package/src/__tests__/builtin-context.test.ts +319 -202
  28. package/src/__tests__/builtin-file-tools.test.ts +240 -240
  29. package/src/__tests__/builtin-permissions.test.ts +211 -211
  30. package/src/__tests__/builtin-session-search.test.ts +228 -0
  31. package/src/__tests__/builtin-session-select.test.ts +116 -116
  32. package/src/__tests__/builtin-sigint.test.ts +56 -56
  33. package/src/__tests__/builtin-skills.test.ts +284 -284
  34. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  35. package/src/__tests__/card-action-routing.test.ts +18 -18
  36. package/src/__tests__/cardkit.test.ts +60 -60
  37. package/src/__tests__/ccc-adapter.test.ts +170 -170
  38. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  39. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  40. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  41. package/src/__tests__/claude-adapter.test.ts +614 -614
  42. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  43. package/src/__tests__/codex-adapter.test.ts +58 -58
  44. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  45. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  46. package/src/__tests__/crash-logging.test.ts +95 -1
  47. package/src/__tests__/cursor-adapter.test.ts +268 -268
  48. package/src/__tests__/feishu-api.test.ts +60 -60
  49. package/src/__tests__/feishu-avatar.test.ts +164 -164
  50. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  51. package/src/__tests__/feishu-platform.test.ts +22 -22
  52. package/src/__tests__/format-message.test.ts +47 -47
  53. package/src/__tests__/jsonl-stream.test.ts +79 -79
  54. package/src/__tests__/package-files.test.ts +24 -24
  55. package/src/__tests__/privacy.test.ts +198 -198
  56. package/src/__tests__/progress-reducer.test.ts +121 -121
  57. package/src/__tests__/raw-stream-log.test.ts +106 -106
  58. package/src/__tests__/response-stall.test.ts +49 -49
  59. package/src/__tests__/restart.test.ts +55 -10
  60. package/src/__tests__/session.test.ts +199 -199
  61. package/src/__tests__/shared-prefix.test.ts +36 -36
  62. package/src/__tests__/sim-platform.test.ts +16 -16
  63. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  64. package/src/__tests__/stop-session.test.ts +34 -34
  65. package/src/__tests__/stream-state.test.ts +42 -42
  66. package/src/__tests__/terminal-error.test.ts +54 -54
  67. package/src/__tests__/terminal-renderer.test.ts +247 -247
  68. package/src/__tests__/update-command-guard.test.ts +144 -144
  69. package/src/__tests__/web-ui.test.ts +326 -326
  70. package/src/adapters/adapter-interface.ts +33 -33
  71. package/src/adapters/ccc-adapter.ts +141 -141
  72. package/src/adapters/claude-adapter.ts +620 -620
  73. package/src/adapters/claude-session-meta-store.ts +120 -120
  74. package/src/adapters/codex-adapter.ts +426 -426
  75. package/src/adapters/cursor-adapter.ts +681 -681
  76. package/src/adapters/jsonl-stream.ts +157 -157
  77. package/src/adapters/raw-stream-log.ts +124 -124
  78. package/src/adapters/resource-monitor.ts +140 -140
  79. package/src/agent-activity.ts +175 -175
  80. package/src/agent-delegate-task-rpc.ts +153 -153
  81. package/src/agent-delegate-task.ts +91 -91
  82. package/src/agent-reload-config-rpc.ts +34 -34
  83. package/src/agent-stop-stuck.ts +129 -129
  84. package/src/builtin/cli.ts +6 -0
  85. package/src/builtin/config.ts +84 -84
  86. package/src/builtin/context.ts +465 -391
  87. package/src/builtin/file-log.ts +38 -38
  88. package/src/builtin/file-tools.ts +34 -0
  89. package/src/builtin/index.ts +24 -23
  90. package/src/builtin/proc-tree-kill.ts +61 -61
  91. package/src/builtin/progress/cards-helpers.ts +76 -76
  92. package/src/builtin/progress/reducer.ts +113 -113
  93. package/src/builtin/progress/terminal-renderer.ts +294 -294
  94. package/src/builtin/progress/view.ts +77 -77
  95. package/src/builtin/raw-stream-log.ts +124 -124
  96. package/src/builtin/session-search.ts +363 -0
  97. package/src/builtin/session-select.ts +48 -48
  98. package/src/builtin/sigint.ts +50 -50
  99. package/src/builtin/skills.ts +205 -205
  100. package/src/builtin/web-tools.ts +313 -313
  101. package/src/card-action-routing.ts +14 -14
  102. package/src/chatgpt-subscription-rpc.ts +27 -27
  103. package/src/chatgpt-subscription.ts +299 -299
  104. package/src/chrome-devtools-guard.ts +318 -318
  105. package/src/codex-reset-actions.ts +184 -184
  106. package/src/feishu-api.ts +193 -193
  107. package/src/feishu-message-ingress.ts +195 -195
  108. package/src/feishu-platform.ts +20 -20
  109. package/src/format-message.ts +293 -293
  110. package/src/index.ts +6 -1
  111. package/src/litellm-proxy.ts +374 -374
  112. package/src/orchestrator.ts +36 -13
  113. package/src/platform-adapter.ts +6 -6
  114. package/src/privacy.ts +118 -118
  115. package/src/progress/reducer.ts +113 -113
  116. package/src/progress/terminal-renderer.ts +294 -294
  117. package/src/progress/view.ts +77 -77
  118. package/src/response-stall.ts +28 -28
  119. package/src/runtime-reload.ts +34 -34
  120. package/src/session-chat-binding.ts +82 -82
  121. package/src/session-name.ts +8 -8
  122. package/src/session.ts +201 -201
  123. package/src/shared-prefix.ts +29 -29
  124. package/src/shared.ts +44 -0
  125. package/src/sim-platform.ts +20 -20
  126. package/src/startup-lifecycle.ts +250 -250
  127. package/src/stream-state.ts +21 -21
  128. package/src/terminal-error.ts +129 -129
  129. package/src/turn-cards.ts +117 -117
  130. package/src/update-command-guard.ts +165 -165
@@ -1,313 +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(/&lt;/gi, "<")
99
- .replace(/&gt;/gi, ">")
100
- .replace(/&quot;/gi, '"')
101
- .replace(/&#0?39;/gi, "'")
102
- .replace(/&nbsp;/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(/&amp;/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
- }
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(/&lt;/gi, "<")
99
+ .replace(/&gt;/gi, ">")
100
+ .replace(/&quot;/gi, '"')
101
+ .replace(/&#0?39;/gi, "'")
102
+ .replace(/&nbsp;/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(/&amp;/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
+ }
@@ -1,14 +1,14 @@
1
- export type FeishuCommandChatType = "p2p" | "group";
2
-
3
- type SessionRegistryForRouting = Record<string, { chatType?: string }>;
4
-
5
- /**
6
- * Feishu card action callbacks do not include the chat type. Recover it from
7
- * the persisted binding so buttons clicked in a private chat stay in p2p.
8
- */
9
- export function resolveFeishuCardActionChatType(
10
- chatId: string,
11
- registry: SessionRegistryForRouting,
12
- ): FeishuCommandChatType {
13
- return registry[chatId]?.chatType === "p2p" ? "p2p" : "group";
14
- }
1
+ export type FeishuCommandChatType = "p2p" | "group";
2
+
3
+ type SessionRegistryForRouting = Record<string, { chatType?: string }>;
4
+
5
+ /**
6
+ * Feishu card action callbacks do not include the chat type. Recover it from
7
+ * the persisted binding so buttons clicked in a private chat stay in p2p.
8
+ */
9
+ export function resolveFeishuCardActionChatType(
10
+ chatId: string,
11
+ registry: SessionRegistryForRouting,
12
+ ): FeishuCommandChatType {
13
+ return registry[chatId]?.chatType === "p2p" ? "p2p" : "group";
14
+ }