@epoch-agent/plugin-web 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @epoch-agent/plugin-web
2
2
 
3
3
  网页抓取与搜索插件。**两个工具**:`web_fetch`(总是有)和 `web_search`
4
- (**配了搜索后端才注册**)。
4
+ (**有可用后端才注册** —— 缺省状态下就有,见下)。
5
5
 
6
6
  - ✅ **做**:HTTP GET → HTML→Markdown/Text/HTML、SSRF 校验、逐跳重定向校验、大小限制;
7
- web 搜索(tavily / brave / searxng 三个后端)
7
+ web 搜索(native / tavily / brave / searxng / keyless 五个后端)
8
8
  - ❌ **不做**:不执行 JS、不渲染页面、不带浏览器;**不抓搜索引擎的 HTML 结果页**
9
- (违反 ToS、随时会碎、要处理验证码 —— 没 key 就诚实地不提供这个工具)
9
+ (违反 ToS、随时会碎、要处理验证码 —— 要搜就走后端,不走爬虫)
10
10
  - **依赖**:只有 node 内置模块。[protocol](../../protocol) 是 peer,
11
11
  HTML 转换是手写的(无第三方库)
12
12
 
@@ -25,18 +25,33 @@
25
25
  **只返回标题 / URL / 摘要,不返回正文。** 一次搜索 5 条结果全抓正文轻松几万 token,
26
26
  而其中多半是模型看一眼摘要就丢掉的。分工是:搜 → 模型挑 → `web_fetch` 抓那一条。
27
27
 
28
- ### 三个后端,按凭据自动挑
28
+ ### 五个后端,按凭据自动挑
29
29
 
30
- 三家(`tavily` / `brave` / `searxng`)各要什么凭据见
30
+ 五档(`native` / `tavily` / `brave` / `searxng` / `keyless`)各要什么凭据见
31
31
  [docs/TOOLS.md](../../../docs/TOOLS.md#web_search-要一个搜索后端)。
32
32
 
33
- 不配 `search.provider` 时按那张表的顺序探测,第一个有凭据的赢。**显式配了哪家就只认哪家**
33
+ 不配 `search.provider` 时按那张表的顺序探测,第一个可用的赢。**显式配了哪家就只认哪家**
34
34
  —— 缺 key 时报诊断而不是悄悄换一家:换一家意味着查询发去了另一家公司,
35
35
  那是用户没同意的事。
36
36
 
37
- 一个后端都没有时 `web_search` **不注册**,启动诊断给 `skipped` 并列出三家各缺什么。
37
+ 一个后端都没有时 `web_search` **不注册**,启动诊断给 `skipped` 并逐档列出缺什么。
38
38
  注册一个一调就报错的工具比不注册更糟:模型看见工具表里有它就会反复试,每次烧一轮。
39
39
 
40
+ #### 两档不用办账号的(2026-09-15)
41
+
42
+ 上面那句「没有后端就不注册」原来的实际效果是**大多数嵌入宿主的用户都没有搜索** ——
43
+ 三家老后端全要办账号,而宿主多半不会去配。这一档漏配是静默的:模型不知道自己本该
44
+ 有搜索,就改用浏览器去抓搜索引擎结果页(正是本包第一节说「不做」的那件事)。
45
+
46
+ - **`native`**(`search/native.ts`)借 `provider.apiKey`,让当前 LLM provider 自己的
47
+ 服务端搜索去干活。首版认 `deepseek` / `anthropic`(共用 Anthropic Messages 协议)。
48
+ 它的结果项**没有明文摘要**(正文是 `encrypted_content`),信息走
49
+ `SearchOutcome.summary`。
50
+ - **`keyless`**(`search/keyless.ts`)打 Exa / Parallel 的匿名 MCP 端点,
51
+ 带游标轮转摊平两家免费额度。**查询词会发给第三方**,所以它排在最后、且能整档关掉。
52
+
53
+ 两个开关缺省开:`search.native: false` / `search.keyless: false`。
54
+
40
55
  > SearXNG **默认没开** JSON 输出,实例的 `settings.yml` 里要有
41
56
  > `search: { formats: [html, json] }`。没开时它返回 403,我们把这句话写进了错误里 ——
42
57
  > 否则用户会以为是自己地址写错了。
@@ -90,7 +105,10 @@ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true }
90
105
  | `tools/web-search.ts` | 搜索工具:参数钳制 + 会话内缓存 + 失败不炸轮 |
91
106
  | `search/provider.ts` | 后端接口 + 共用的 HTTP / 错误分类 |
92
107
  | `search/registry.ts` | 按配置 + 凭据挑后端(挑不到就不注册) |
93
- | `search/{tavily,brave,searxng}.ts` | 三个后端各自的请求 / 响应翻译 |
108
+ | `search/{tavily,brave,searxng}.ts` | 三个要办账号的后端各自的请求 / 响应翻译 |
109
+ | `search/native.ts` | 借当前 LLM provider 的服务端搜索 |
110
+ | `search/keyless.ts` | Exa / Parallel 免费端点环 + 游标轮转 |
111
+ | `search/mcp.ts` | MCP `tools/call`(纯 JSON 和 SSE 两种回法) |
94
112
  | `utils/url-safety.ts` | 三层 SSRF 校验 |
95
113
  | `utils/html-to-markdown.ts` | 手写 HTML→Markdown / →Text |
96
114
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SearchProviderType, EpochConfig, ToolContext, ToolResult, EpochTool, EpochPlugin } from '@epoch-agent/protocol';
1
+ import { ProviderType, SearchProviderType, EpochConfig, ToolContext, ToolResult, EpochTool, EpochPlugin } from '@epoch-agent/protocol';
2
2
 
3
3
  /**
4
4
  * 搜索后端的接口(方案 37)。
@@ -50,6 +50,26 @@ interface SearchOutcome {
50
50
  * 于是会反复重试同一个查询。
51
51
  */
52
52
  ignored?: string[];
53
+ /**
54
+ * 后端自己合成的一段答案,**排在结果列表前面**给模型看。
55
+ *
56
+ * ## 为什么要有这一格(2026-09-15)
57
+ *
58
+ * `native` 那一档(搜索发生在 LLM provider 的服务端)返回的结果项里
59
+ * **没有明文摘要**:Anthropic 系的 `web_search_tool_result` 给的是
60
+ * `{title, url, encrypted_content, page_age}`,正文那一格是**加密的**,
61
+ * 只有发它的那一方解得开。
62
+ *
63
+ * 而本文件开头写着 snippet 的职责是「让模型判断这条值不值得抓正文」——
64
+ * 没有这一格,native 档交给模型的就是一串裸标题加 URL,它只能盲抓。
65
+ * 服务端合成的那段话正好补上这个缺口,而且它是**读过正文之后**写的,
66
+ * 比任何一家的 snippet 都准。
67
+ *
68
+ * ⚠️ **不是「摘要的替代品」**:有 snippet 的后端(tavily / brave / searxng /
69
+ * keyless)照常填 snippet、不填这一格。两者同时存在时都给模型,
70
+ * 它们回答的是不同的问题(这一格是「答案大概是什么」,snippet 是「这条讲什么」)。
71
+ */
72
+ summary?: string;
53
73
  }
54
74
  interface SearchProvider {
55
75
  readonly name: string;
@@ -73,11 +93,11 @@ declare class SearchError extends Error {
73
93
  }
74
94
 
75
95
  /**
76
- * 按配置 + 凭据挑一个搜索后端(方案 37)。
96
+ * 按配置 + 凭据挑一个搜索后端(方案 37;2026-09-15 加 native / keyless 两档)。
77
97
  *
78
98
  * ```
79
99
  * 显式配了 provider → 只认那一家。缺 key 就报出来,**不静默回落到别家**
80
- * 没配 → 按顺序试 tavily → brave → searxng,第一个能用的赢
100
+ * 没配 → 按 AUTO_ORDER 顺序试,第一个能用的赢
81
101
  * 一个都不能用 → 返回 null,工具**不注册**
82
102
  * ```
83
103
  *
@@ -87,13 +107,32 @@ declare class SearchError extends Error {
87
107
  * 悄悄换成 tavily 意味着他的查询发去了另一家公司 —— 那是他没同意的事。
88
108
  * 报出来让他自己选,是唯一诚实的做法。
89
109
  *
90
- * ## 「一个都没有」为什么不注册工具,而不是注册一个会报错的
110
+ * ## 「一个都没有」这一档 2026-09-15 之后几乎不会出现了
91
111
  *
92
- * 注册一个一调就失败的工具,模型会反复试(它看到工具表里有 `web_search`,
93
- * 就会认为搜索是可行的),每次都烧一轮。不注册的话它从一开始就知道没这个能力。
94
- * 诊断走 `skipped` 而不是 `failed` —— 没配搜索不是故障,是没开这个功能。
112
+ * 原话是「注册一个一调就失败的工具比不注册更糟」,那条判据本身没变。
113
+ * 变的是**现在几乎总有一家能用**:`native` 跟着已经配好的 LLM provider 走、
114
+ * `keyless` 连 key 都不要。于是 `null` 这一支退化成「两档都被显式关掉、
115
+ * 且三家带 key 的也都没配」这种明确的用户选择。
116
+ *
117
+ * 这么改的理由是实测到的一次事故(nebular 2026-09-15):宿主从没配过 `search`,
118
+ * 于是它的**每一个**用户都没有 `web_search`,而这件事对模型是静默的 ——
119
+ * 它不知道自己本该有搜索,就改用浏览器去抓搜索引擎结果页,
120
+ * 把 SEO 广告和 AI 生成的模板文当成行业数据写进了交付物。
95
121
  */
96
122
 
123
+ /**
124
+ * `native` 那一档要的东西 —— 它借的是**当前 LLM provider** 的凭据和端点,
125
+ * 而那几样不在 `EpochConfig['search']` 里。
126
+ *
127
+ * 整段缺席时 `native` 会如实报「不知道当前是哪个 provider」然后落到下一档,
128
+ * 不是报错 —— 这让老的调用点(以及只测搜索配置的用例)不用全改一遍。
129
+ */
130
+ interface NativeContext {
131
+ providerType?: ProviderType;
132
+ apiKey?: string;
133
+ baseUrl?: string;
134
+ model?: string;
135
+ }
97
136
  /** 一次后端选择的结果 */
98
137
  type SearchSelection = {
99
138
  provider: SearchProvider;
@@ -104,15 +143,17 @@ type SearchSelection = {
104
143
  provider: null;
105
144
  detail: string;
106
145
  };
107
- declare function createSearchProvider(type: SearchProviderType, config: EpochConfig['search']): SearchProvider;
146
+ declare function createSearchProvider(type: SearchProviderType, config: EpochConfig['search'], native?: NativeContext): SearchProvider;
108
147
  /**
109
148
  * 挑一个能用的后端。
110
149
  *
111
150
  * @param config `EpochConfig.search`。整段缺席 = 用户没开这个功能,
112
151
  * 但**仍然会按凭据自动探测** —— 环境里已经有 `TAVILY_API_KEY` 的人
113
152
  * 不该被要求再写一行配置才能用上。
153
+ * @param native 当前 LLM provider 的类型 / 凭据 / 端点。不给的话 `native`
154
+ * 那一档自动不可用,选择会落到后面几档 —— 老调用点不传也不会崩。
114
155
  */
115
- declare function selectSearchProvider(config: EpochConfig['search']): SearchSelection;
156
+ declare function selectSearchProvider(config: EpochConfig['search'], native?: NativeContext): SearchSelection;
116
157
 
117
158
  /**
118
159
  * SearXNG —— **自托管、无 key** 的那一档(方案 37)。
@@ -166,6 +207,92 @@ interface BraveOptions {
166
207
  }
167
208
  declare function createBraveProvider(opts?: BraveOptions): SearchProvider;
168
209
 
210
+ /**
211
+ * native —— 让**当前这个 LLM provider 自己**去搜(2026-09-15)。
212
+ *
213
+ * codex 和 gemini-cli 走的都是这条路:前者把 `{type:"web_search"}` 交给 OpenAI 的
214
+ * Responses API,后者读 Gemini grounding 的 `groundingMetadata`。共同点是
215
+ * **搜索发生在模型那一侧**,于是不需要第二个 API key、也不需要第二个账号。
216
+ *
217
+ * ## 为什么排在自动挑选的第一位
218
+ *
219
+ * 1. **不要新凭据** —— 用的就是已经配好的那把 provider key。
220
+ * 2. **隐私暴露面≈0** —— 查询词发给的是本来就持有整段对话的那一方。
221
+ * 相比之下 tavily / brave / keyless 都是把用户的问题交给一个新的第三方。
222
+ * 3. **有付费关系就有 SLA** —— 不看免费档的脸色。
223
+ *
224
+ * ⚠️ 代价是**一次搜索 = 一个完整的模型轮次**(实测 deepseek-flash:
225
+ * 5776 in / 583 out token),比纯检索端点慢也贵。要换回检索型后端就显式写
226
+ * `search.provider: tavily`;整档关掉是 `search.native: false`。
227
+ *
228
+ * ## 为什么结果里没有摘要,得靠 `summary`
229
+ *
230
+ * Anthropic 系的 `web_search_tool_result` 每一条是
231
+ * `{title, url, encrypted_content, page_age}` —— 正文那一格**是加密的**,
232
+ * 只有发它的那一方解得开。所以这一档填的是 `SearchOutcome.summary`
233
+ * (服务端读完正文之后合成的那段话),判据全文在 `provider.ts` 那一格上。
234
+ *
235
+ * ## 覆盖面
236
+ *
237
+ * 首版只做 **deepseek + anthropic** —— 两家共用 Anthropic Messages 协议,
238
+ * 一份实现就够。`openai`(Responses API)和 `google`(grounding)的传输层
239
+ * 完全不同,是**另外两份实现**,不在这一版里;能力表留着扩展位,
240
+ * 表里没有的 provider 会如实报原因然后落到下一档,不是报错。
241
+ */
242
+
243
+ interface NativeOptions {
244
+ /** 当前 LLM provider 的类型。不给 = 这一档不可用 */
245
+ providerType?: ProviderType;
246
+ /** 已解析好的 provider key(`config.provider.apiKey`) */
247
+ apiKey?: string;
248
+ /** 用户自定义的 baseUrl;不给就用能力表里的默认值 */
249
+ baseUrl?: string;
250
+ /** 用哪个模型去搜(`config.model`) */
251
+ model?: string;
252
+ /** `search.native`。显式 `false` 才关 */
253
+ enabled?: boolean;
254
+ }
255
+ declare function createNativeProvider(opts?: NativeOptions): SearchProvider;
256
+
257
+ /**
258
+ * keyless —— 免费公开端点组成的环,**零 key、零服务器**(2026-09-15)。
259
+ *
260
+ * 这一档存在的理由是本仓库原来的一条判据反过来咬了自己:`web_search` 没后端就
261
+ * 不注册,而三家已有后端**全都要凭据**,于是「什么都没配」的用户拿到的不是
262
+ * 「一个可能限流的搜索」,是**根本没有搜索**。实测后果(nebular 2026-09-15):
263
+ * 模型改去用浏览器抓百度结果页,把 SEO 广告和 AI 模板文当成行业数据写进了交付物。
264
+ *
265
+ * ## 为什么可以不要 key
266
+ *
267
+ * Exa 和 Parallel 都对外开着**匿名的 MCP 端点**。这不是钻空子:opencode 和
268
+ * hermes-agent 两个互不相干的项目(一个 TS 一个 Python)各自独立收敛到了
269
+ * 同一组 URL,说明这是厂商有意留的获客入口。给了 key 就是付费档,不给就是免费档,
270
+ * 同一个端点。
271
+ *
272
+ * ## 为什么要轮转,而不是「排个序、第一家优先」
273
+ *
274
+ * 免费档是**按 IP 限流**的。固定先打一家等于把那一家的额度打爆之后才用另一家,
275
+ * 而限流恢复是有冷却的 —— 结果是两家的额度都没吃满,用户却已经在看「搜索不可用」。
276
+ * 模块级游标每次调用挪一格,把负载摊平(照 hermes 的 `_ring_cursor`)。
277
+ *
278
+ * ## 隐私(照抄 hermes 的分寸,不是可选项)
279
+ *
280
+ * - **不发任何用户标识**。
281
+ * - Parallel 的 `session_id` 是**每进程随机**的一个 hex,它只用于那边的限流关联,
282
+ * 不落盘、不跨进程复用。
283
+ * - Parallel 还收一个可选的 `model_name` 分析字段 —— **故意不传**。
284
+ *
285
+ * 即便如此,查询词本身仍然会发给第三方。要整档关掉:`search.keyless: false`。
286
+ */
287
+
288
+ /** 给用例用:把游标拨回 0,否则用例之间会互相影响 */
289
+ declare function resetKeylessCursor(): void;
290
+ interface KeylessOptions {
291
+ /** `search.keyless`。显式 `false` 才关 —— 缺省是开着的 */
292
+ enabled?: boolean;
293
+ }
294
+ declare function createKeylessProvider(opts?: KeylessOptions): SearchProvider;
295
+
169
296
  /**
170
297
  * web_search —— 搜一串结果回来,**不抓正文**(方案 37)。
171
298
  *
@@ -226,4 +353,4 @@ declare function createWebSearchTool(provider: SearchProvider, opts?: {
226
353
  defaultMaxResults?: number;
227
354
  }): EpochTool;
228
355
 
229
- export { SearchError, type SearchOptions, type SearchOutcome, type SearchProvider, type SearchResult, type SearchSelection, clearSearchCache, createBraveProvider, createSearchProvider, createSearxngProvider, createTavilyProvider, createWebSearchExec, createWebSearchTool, selectSearchProvider, webPlugin };
356
+ export { type NativeContext, SearchError, type SearchOptions, type SearchOutcome, type SearchProvider, type SearchResult, type SearchSelection, clearSearchCache, createBraveProvider, createKeylessProvider, createNativeProvider, createSearchProvider, createSearxngProvider, createTavilyProvider, createWebSearchExec, createWebSearchTool, resetKeylessCursor, selectSearchProvider, webPlugin };
package/dist/index.js CHANGED
@@ -11,8 +11,8 @@ function htmlToMarkdown(html) {
11
11
  );
12
12
  cleaned = cleaned.replace(/<br\s*\/?>/gi, "\n");
13
13
  cleaned = cleaned.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, url, text) => {
14
- const t5 = stripTags(text).trim();
15
- return t5 ? `[${t5}](${url})` : url;
14
+ const t8 = stripTags(text).trim();
15
+ return t8 ? `[${t8}](${url})` : url;
16
16
  });
17
17
  cleaned = cleaned.replace(
18
18
  /<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi,
@@ -338,18 +338,20 @@ function clearSearchCache() {
338
338
  function cacheKey(sessionId, query, opts) {
339
339
  return `${sessionId}\0${query.trim().toLowerCase()}\0${JSON.stringify(opts)}`;
340
340
  }
341
- function format(results, ignored) {
341
+ function format(results, ignored, summary) {
342
342
  const lines = results.map((r, i) => {
343
343
  const date = r.publishedAt ? ` \xB7 ${r.publishedAt}` : "";
344
344
  const snippet = r.snippet.length > MAX_SNIPPET ? `${r.snippet.slice(0, MAX_SNIPPET)}\u2026` : r.snippet;
345
- return `${i + 1}. ${r.title}${date}
345
+ return snippet ? `${i + 1}. ${r.title}${date}
346
346
  ${r.url}
347
- ${snippet}`;
347
+ ${snippet}` : `${i + 1}. ${r.title}${date}
348
+ ${r.url}`;
348
349
  });
349
350
  const head = `${results.length} \u6761\u7ED3\u679C\uFF08\u53EA\u6709\u6458\u8981\uFF0C\u8981\u6B63\u6587\u8BF7\u5BF9\u5177\u4F53 URL \u8C03 web_fetch\uFF09`;
351
+ const lead = summary ? ["", `\u540E\u7AEF\u7EFC\u8FF0\uFF08\u636E\u4E0B\u5217\u6765\u6E90\u5408\u6210\uFF0C\u5F15\u7528\u65F6\u8BF7\u843D\u5230\u5177\u4F53 URL\uFF09\uFF1A`, summary] : [];
350
352
  const tail = ignored.length > 0 ? [`
351
353
  \u6CE8\u610F\uFF1A${ignored.join("\uFF1B")}`] : [];
352
- return [head, "", ...lines, ...tail].join("\n");
354
+ return [head, ...lead, "", ...lines, ...tail].join("\n");
353
355
  }
354
356
  function stringArray(value) {
355
357
  if (!Array.isArray(value)) return void 0;
@@ -385,7 +387,7 @@ function createWebSearchExec(deps) {
385
387
  if (cached) {
386
388
  return {
387
389
  success: true,
388
- output: format(cached, ["\u672C\u6B21\u7ED3\u679C\u6765\u81EA\u672C\u4F1A\u8BDD\u7F13\u5B58\uFF0C\u672A\u91CD\u65B0\u8054\u7F51"]),
390
+ output: format(cached.results, ["\u672C\u6B21\u7ED3\u679C\u6765\u81EA\u672C\u4F1A\u8BDD\u7F13\u5B58\uFF0C\u672A\u91CD\u65B0\u8054\u7F51"], cached.summary),
389
391
  duration: Date.now() - started
390
392
  };
391
393
  }
@@ -393,7 +395,10 @@ function createWebSearchExec(deps) {
393
395
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
394
396
  try {
395
397
  const outcome = await deps.provider.search(query, { ...options, signal: controller.signal });
396
- cache.set(key, outcome.results);
398
+ cache.set(key, {
399
+ results: outcome.results,
400
+ ...outcome.summary ? { summary: outcome.summary } : {}
401
+ });
397
402
  if (outcome.results.length === 0) {
398
403
  return {
399
404
  success: true,
@@ -403,7 +408,7 @@ function createWebSearchExec(deps) {
403
408
  }
404
409
  return {
405
410
  success: true,
406
- output: format(outcome.results, outcome.ignored ?? []),
411
+ output: format(outcome.results, outcome.ignored ?? [], outcome.summary),
407
412
  duration: Date.now() - started
408
413
  };
409
414
  } catch (err) {
@@ -424,7 +429,7 @@ function createWebSearchExec(deps) {
424
429
  };
425
430
  }
426
431
  // src/search/registry.ts
427
- import { t as t4 } from "@epoch-agent/infra";
432
+ import { t as t7 } from "@epoch-agent/infra";
428
433
  // src/search/brave.ts
429
434
  import { t } from "@epoch-agent/infra";
430
435
  var ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
@@ -489,22 +494,336 @@ function toFreshness(days) {
489
494
  if (days <= 366) return "py";
490
495
  return void 0;
491
496
  }
492
- // src/search/searxng.ts
497
+ // src/search/keyless.ts
498
+ import { randomUUID } from "crypto";
499
+ import { t as t3 } from "@epoch-agent/infra";
500
+ // src/search/mcp.ts
493
501
  import { t as t2 } from "@epoch-agent/infra";
502
+ function pickText(payload) {
503
+ const trimmed = payload.trim();
504
+ if (!trimmed.startsWith("{")) return void 0;
505
+ let data;
506
+ try {
507
+ data = JSON.parse(trimmed);
508
+ } catch {
509
+ return void 0;
510
+ }
511
+ if (data.error && typeof data.error.message === "string") {
512
+ throw new SearchError(data.error.message);
513
+ }
514
+ const content = data.result?.content;
515
+ if (!Array.isArray(content)) return void 0;
516
+ for (const item of content) {
517
+ if (typeof item !== "object" || item === null) continue;
518
+ const text = item.text;
519
+ if (typeof text === "string" && text.length > 0) {
520
+ if (data.result?.isError === true) throw new SearchError(text);
521
+ return text;
522
+ }
523
+ }
524
+ return void 0;
525
+ }
526
+ function parseMcpResponse(body) {
527
+ const direct = pickText(body);
528
+ if (direct !== void 0) return direct;
529
+ for (const line of body.split("\n")) {
530
+ if (!line.startsWith("data: ")) continue;
531
+ const text = pickText(line.slice(6));
532
+ if (text !== void 0) return text;
533
+ }
534
+ return void 0;
535
+ }
536
+ async function mcpCall(init) {
537
+ let response;
538
+ try {
539
+ response = await fetch(init.url, {
540
+ method: "POST",
541
+ headers: {
542
+ "Content-Type": "application/json",
543
+ Accept: "application/json, text/event-stream",
544
+ ...init.headers
545
+ },
546
+ body: JSON.stringify({
547
+ jsonrpc: "2.0",
548
+ id: 1,
549
+ method: "tools/call",
550
+ params: { name: init.tool, arguments: init.args }
551
+ }),
552
+ ...init.signal ? { signal: init.signal } : {}
553
+ });
554
+ } catch (err) {
555
+ if (err instanceof Error && err.name === "AbortError") {
556
+ throw new SearchError(t2("web_search_setup.err_timeout", { who: init.who }));
557
+ }
558
+ throw new SearchError(
559
+ t2("web_search_setup.err_unreachable", {
560
+ who: init.who,
561
+ detail: err instanceof Error ? err.message : String(err)
562
+ })
563
+ );
564
+ }
565
+ if (!response.ok) {
566
+ const hint = response.status === 429 ? t2("web_search_setup.err_rate_limited_hint") : "";
567
+ throw new SearchError(
568
+ t2("web_search_setup.err_http", { who: init.who, status: response.status, hint }),
569
+ response.status
570
+ );
571
+ }
572
+ const text = parseMcpResponse(await response.text());
573
+ if (text === void 0)
574
+ throw new SearchError(t2("web_search_setup.err_no_content", { who: init.who }));
575
+ return text;
576
+ }
577
+ // src/search/keyless.ts
578
+ var EXA_URL = "https://mcp.exa.ai/mcp";
579
+ var PARALLEL_URL = "https://search.parallel.ai/mcp";
580
+ var RATE_LIMIT_ID = randomUUID().replace(/-/gu, "");
581
+ var RING = ["exa", "parallel"];
582
+ var cursor = 0;
583
+ function resetKeylessCursor() {
584
+ cursor = 0;
585
+ }
586
+ function ringOrder() {
587
+ const start = cursor % RING.length;
588
+ cursor = (cursor + 1) % RING.length;
589
+ return [...RING.slice(start), ...RING.slice(0, start)];
590
+ }
591
+ function parseExa(text, max) {
592
+ const out = [];
593
+ let current = null;
594
+ let inHighlights = false;
595
+ const flush = () => {
596
+ if (current && current.url) {
597
+ out.push({
598
+ title: current.title || current.url,
599
+ url: current.url,
600
+ snippet: current.lines.join(" ").trim(),
601
+ ...current.date && current.date !== "N/A" ? { publishedAt: current.date } : {}
602
+ });
603
+ }
604
+ current = null;
605
+ inHighlights = false;
606
+ };
607
+ for (const line of text.split("\n")) {
608
+ if (line.startsWith("Title: ")) {
609
+ flush();
610
+ current = { title: line.slice(7).trim(), url: "", date: "", lines: [] };
611
+ } else if (!current) {
612
+ continue;
613
+ } else if (line.startsWith("URL: ")) {
614
+ current.url = line.slice(5).trim();
615
+ } else if (line.startsWith("Published: ")) {
616
+ current.date = line.slice(11).trim();
617
+ } else if (line.startsWith("Author: ")) {
618
+ } else if (line.startsWith("Highlights:")) {
619
+ inHighlights = true;
620
+ } else if (inHighlights) {
621
+ const piece = line.trim();
622
+ if (piece && piece !== "...") current.lines.push(piece);
623
+ }
624
+ }
625
+ flush();
626
+ return out.slice(0, max);
627
+ }
628
+ function parseParallel(text, max) {
629
+ let data;
630
+ try {
631
+ data = JSON.parse(text);
632
+ } catch {
633
+ throw new SearchError(t3("web_search_setup.err_bad_json", { who: "parallel" }));
634
+ }
635
+ if (!Array.isArray(data.results)) return [];
636
+ const out = [];
637
+ for (const item of data.results) {
638
+ if (typeof item !== "object" || item === null) continue;
639
+ const row = item;
640
+ if (typeof row.url !== "string" || !row.url) continue;
641
+ const excerpts = Array.isArray(row.excerpts) ? row.excerpts.filter((e) => typeof e === "string") : [];
642
+ out.push({
643
+ title: typeof row.title === "string" && row.title ? row.title : row.url,
644
+ url: row.url,
645
+ snippet: excerpts.join(" ").trim(),
646
+ ...typeof row.publish_date === "string" && row.publish_date ? { publishedAt: row.publish_date } : {}
647
+ });
648
+ if (out.length >= max) break;
649
+ }
650
+ return out;
651
+ }
652
+ async function searchOne(vendor, query, options) {
653
+ if (vendor === "exa") {
654
+ const text2 = await mcpCall({
655
+ url: EXA_URL,
656
+ tool: "web_search_exa",
657
+ args: {
658
+ query,
659
+ type: "auto",
660
+ numResults: options.maxResults,
661
+ livecrawl: "fallback"
662
+ },
663
+ who: "exa",
664
+ ...options.signal ? { signal: options.signal } : {}
665
+ });
666
+ return parseExa(text2, options.maxResults);
667
+ }
668
+ const text = await mcpCall({
669
+ url: PARALLEL_URL,
670
+ tool: "web_search",
671
+ args: {
672
+ objective: query,
673
+ search_queries: [query],
674
+ session_id: RATE_LIMIT_ID
675
+ },
676
+ who: "parallel",
677
+ ...options.signal ? { signal: options.signal } : {}
678
+ });
679
+ return parseParallel(text, options.maxResults);
680
+ }
681
+ function createKeylessProvider(opts = {}) {
682
+ return {
683
+ name: "keyless",
684
+ unavailableReason() {
685
+ return opts.enabled === false ? t3("web_search_setup.keyless_disabled") : null;
686
+ },
687
+ async search(query, options) {
688
+ const failures = [];
689
+ for (const vendor of ringOrder()) {
690
+ try {
691
+ const results = await searchOne(vendor, query, options);
692
+ return {
693
+ results,
694
+ ...ignoredOf(options).length > 0 ? { ignored: ignoredOf(options) } : {}
695
+ };
696
+ } catch (err) {
697
+ if (options.signal?.aborted) throw err;
698
+ failures.push(err instanceof Error ? err.message : String(err));
699
+ }
700
+ }
701
+ throw new SearchError(
702
+ t3("web_search_setup.keyless_all_failed", {
703
+ tried: failures.join(t3("web_search_setup.tried_sep"))
704
+ })
705
+ );
706
+ }
707
+ };
708
+ }
709
+ function ignoredOf(options) {
710
+ const ignored = [];
711
+ if (options.recencyDays !== void 0)
712
+ ignored.push(t3("web_search_setup.ignored_recency_keyless"));
713
+ if (options.domains?.length) ignored.push(t3("web_search_setup.ignored_domains_keyless"));
714
+ if (options.excludeDomains?.length) ignored.push(t3("web_search_setup.ignored_exclude_keyless"));
715
+ return ignored;
716
+ }
717
+ // src/search/native.ts
718
+ import { t as t4 } from "@epoch-agent/infra";
719
+ var ANTHROPIC_STYLE = {
720
+ deepseek: { defaultBaseUrl: "https://api.deepseek.com", path: "/anthropic/v1/messages" },
721
+ anthropic: { defaultBaseUrl: "https://api.anthropic.com", path: "/v1/messages" }
722
+ };
723
+ var TOOL_TYPE = "web_search_20250305";
724
+ var MAX_TOKENS = 1500;
725
+ function buildPrompt(query, opts) {
726
+ const sep = t4("web_search_setup.prompt_domain_sep");
727
+ const lines = [t4("web_search_setup.prompt_search", { query })];
728
+ if (opts.domains?.length)
729
+ lines.push(t4("web_search_setup.prompt_only_domains", { domains: opts.domains.join(sep) }));
730
+ if (opts.excludeDomains?.length)
731
+ lines.push(
732
+ t4("web_search_setup.prompt_avoid_domains", { domains: opts.excludeDomains.join(sep) })
733
+ );
734
+ lines.push(t4("web_search_setup.prompt_wrap_up"));
735
+ return lines.join("\n");
736
+ }
737
+ function collectResults(blocks, max) {
738
+ const out = [];
739
+ for (const block of blocks) {
740
+ if (block.type !== "web_search_tool_result" || !Array.isArray(block.content)) continue;
741
+ for (const item of block.content) {
742
+ if (typeof item !== "object" || item === null) continue;
743
+ const row = item;
744
+ if (typeof row.url !== "string" || !row.url) continue;
745
+ out.push({
746
+ title: typeof row.title === "string" && row.title ? row.title : row.url,
747
+ snippet: "",
748
+ url: row.url,
749
+ ...typeof row.page_age === "string" && row.page_age ? { publishedAt: row.page_age } : {}
750
+ });
751
+ if (out.length >= max) return out;
752
+ }
753
+ }
754
+ return out;
755
+ }
756
+ function collectSummary(blocks) {
757
+ return blocks.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text.trim()).filter((s) => s.length > 0).join("\n\n");
758
+ }
759
+ function createNativeProvider(opts = {}) {
760
+ const spec = opts.providerType ? ANTHROPIC_STYLE[opts.providerType] : void 0;
761
+ return {
762
+ name: "native",
763
+ unavailableReason() {
764
+ if (opts.enabled === false) return t4("web_search_setup.native_disabled");
765
+ if (!opts.providerType) return t4("web_search_setup.native_no_provider");
766
+ if (!spec) return t4("web_search_setup.native_unsupported", { provider: opts.providerType });
767
+ if (!opts.apiKey?.trim()) {
768
+ return t4("web_search_setup.native_need_key", { provider: opts.providerType });
769
+ }
770
+ if (!opts.model?.trim()) return t4("web_search_setup.native_no_model");
771
+ return null;
772
+ },
773
+ async search(query, options) {
774
+ const base = (opts.baseUrl?.trim() || spec.defaultBaseUrl).replace(/\/+$/u, "");
775
+ const json = await searchFetch(
776
+ `${base}${spec.path}`,
777
+ {
778
+ method: "POST",
779
+ headers: {
780
+ "Content-Type": "application/json",
781
+ "x-api-key": opts.apiKey,
782
+ "anthropic-version": "2023-06-01"
783
+ },
784
+ body: JSON.stringify({
785
+ model: opts.model,
786
+ max_tokens: MAX_TOKENS,
787
+ tools: [{ type: TOOL_TYPE, name: "web_search", max_uses: 3 }],
788
+ messages: [{ role: "user", content: buildPrompt(query, options) }]
789
+ }),
790
+ ...options.signal ? { signal: options.signal } : {}
791
+ },
792
+ "native"
793
+ );
794
+ const blocks = Array.isArray(json.content) ? json.content ?? [] : [];
795
+ const results = collectResults(blocks, options.maxResults);
796
+ if (results.length === 0) {
797
+ throw new SearchError(
798
+ t4("web_search_setup.native_no_search", { provider: opts.providerType })
799
+ );
800
+ }
801
+ const summary = collectSummary(blocks);
802
+ const ignored = options.recencyDays === void 0 ? [] : [t4("web_search_setup.ignored_recency_native")];
803
+ return {
804
+ results,
805
+ ...summary ? { summary } : {},
806
+ ...ignored.length > 0 ? { ignored } : {}
807
+ };
808
+ }
809
+ };
810
+ }
811
+ // src/search/searxng.ts
812
+ import { t as t5 } from "@epoch-agent/infra";
494
813
  function createSearxngProvider(opts) {
495
814
  return {
496
815
  name: "searxng",
497
816
  unavailableReason() {
498
817
  if (!opts.baseUrl?.trim()) {
499
- return t2("web_search_setup.searxng_need_base_url");
818
+ return t5("web_search_setup.searxng_need_base_url");
500
819
  }
501
820
  try {
502
821
  const url = new URL(opts.baseUrl);
503
822
  if (url.protocol !== "http:" && url.protocol !== "https:") {
504
- return t2("web_search_setup.searxng_bad_protocol", { protocol: url.protocol });
823
+ return t5("web_search_setup.searxng_bad_protocol", { protocol: url.protocol });
505
824
  }
506
825
  } catch {
507
- return t2("web_search_setup.searxng_bad_url", { url: opts.baseUrl });
826
+ return t5("web_search_setup.searxng_bad_url", { url: opts.baseUrl });
508
827
  }
509
828
  return null;
510
829
  },
@@ -566,7 +885,7 @@ function timeRange(days) {
566
885
  return void 0;
567
886
  }
568
887
  // src/search/tavily.ts
569
- import { t as t3 } from "@epoch-agent/infra";
888
+ import { t as t6 } from "@epoch-agent/infra";
570
889
  var ENDPOINT2 = "https://api.tavily.com/search";
571
890
  var MAX_RESULTS2 = 20;
572
891
  function createTavilyProvider(opts = {}) {
@@ -574,7 +893,7 @@ function createTavilyProvider(opts = {}) {
574
893
  return {
575
894
  name: "tavily",
576
895
  unavailableReason() {
577
- return key() ? null : t3("web_search_setup.need_tavily_key");
896
+ return key() ? null : t6("web_search_setup.need_tavily_key");
578
897
  },
579
898
  async search(query, options) {
580
899
  const body = {
@@ -617,34 +936,54 @@ function createTavilyProvider(opts = {}) {
617
936
  };
618
937
  }
619
938
  // src/search/registry.ts
620
- var AUTO_ORDER = ["tavily", "brave", "searxng"];
621
- function createSearchProvider(type, config) {
622
- if (type === "searxng") {
623
- return createSearxngProvider(config?.baseUrl ? { baseUrl: config.baseUrl } : {});
939
+ var AUTO_ORDER = [
940
+ "native",
941
+ "tavily",
942
+ "brave",
943
+ "searxng",
944
+ "keyless"
945
+ ];
946
+ function createSearchProvider(type, config, native) {
947
+ switch (type) {
948
+ case "searxng":
949
+ return createSearxngProvider(config?.baseUrl ? { baseUrl: config.baseUrl } : {});
950
+ case "brave":
951
+ return createBraveProvider();
952
+ case "keyless":
953
+ return createKeylessProvider(config?.keyless === false ? { enabled: false } : {});
954
+ case "native":
955
+ return createNativeProvider({
956
+ ...native?.providerType ? { providerType: native.providerType } : {},
957
+ ...native?.apiKey ? { apiKey: native.apiKey } : {},
958
+ ...native?.baseUrl ? { baseUrl: native.baseUrl } : {},
959
+ ...native?.model ? { model: native.model } : {},
960
+ ...config?.native === false ? { enabled: false } : {}
961
+ });
962
+ default:
963
+ return createTavilyProvider();
624
964
  }
625
- return type === "brave" ? createBraveProvider() : createTavilyProvider();
626
965
  }
627
- function selectSearchProvider(config) {
966
+ function selectSearchProvider(config, native) {
628
967
  const explicit = config?.provider;
629
968
  if (explicit) {
630
- const provider = createSearchProvider(explicit, config);
969
+ const provider = createSearchProvider(explicit, config, native);
631
970
  const reason = provider.unavailableReason();
632
971
  return reason ? {
633
972
  provider: null,
634
- detail: t4("web_search_setup.explicit_unavailable", { provider: explicit, reason })
635
- } : { provider, detail: t4("web_search_setup.explicit_ok", { provider: explicit }) };
973
+ detail: t7("web_search_setup.explicit_unavailable", { provider: explicit, reason })
974
+ } : { provider, detail: t7("web_search_setup.explicit_ok", { provider: explicit }) };
636
975
  }
637
976
  const tried = [];
638
977
  for (const type of AUTO_ORDER) {
639
- const provider = createSearchProvider(type, config);
978
+ const provider = createSearchProvider(type, config, native);
640
979
  const reason = provider.unavailableReason();
641
- if (!reason) return { provider, detail: t4("web_search_setup.auto", { provider: type }) };
980
+ if (!reason) return { provider, detail: t7("web_search_setup.auto", { provider: type }) };
642
981
  tried.push(reason);
643
982
  }
644
983
  return {
645
984
  provider: null,
646
- detail: t4("web_search_setup.none", {
647
- tried: tried.join(t4("web_search_setup.tried_sep"))
985
+ detail: t7("web_search_setup.none", {
986
+ tried: tried.join(t7("web_search_setup.tried_sep"))
648
987
  })
649
988
  };
650
989
  }
@@ -702,11 +1041,14 @@ export {
702
1041
  SearchError,
703
1042
  clearSearchCache,
704
1043
  createBraveProvider,
1044
+ createKeylessProvider,
1045
+ createNativeProvider,
705
1046
  createSearchProvider,
706
1047
  createSearxngProvider,
707
1048
  createTavilyProvider,
708
1049
  createWebSearchExec,
709
1050
  createWebSearchTool,
1051
+ resetKeylessCursor,
710
1052
  selectSearchProvider,
711
1053
  webPlugin
712
1054
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epoch-agent/plugin-web",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "Web 抓取插件 — URL 安全校验 + HTML→Markdown + SSRF 防护",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "@epoch-agent/infra": "0.10.1",
26
- "@epoch-agent/protocol": "0.10.1"
25
+ "@epoch-agent/infra": "0.12.0",
26
+ "@epoch-agent/protocol": "0.12.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.0.0",