dsh-free-search 0.4.30 → 0.4.32

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
@@ -33,7 +33,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
33
33
  - **网页设置页** —— 引擎切换 + API key 配置(UI 中 key 脱敏显示"已配置")+ 中英文切换;DSH 0.1.6-alpha.2+ 入口在左侧「插件」页的组件行配置,旧版在「设置 → 插件 → 可配置」
34
34
  - **弹出式切换命令** —— 聊天框输入 `/free-search-engine`,弹出引擎选择窗口,点选即切换(等效设置页 + 保存)
35
35
  - **引擎测试** —— `free_search_test` 工具让 agent 一键测试所有引擎;设置页也有"测试引擎"按钮(直测当前引擎,不走回退链,付费引擎无 key 会明确报错)
36
- - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable/firecrawl 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`)
36
+ - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable/firecrawl/parallel 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`)
37
37
  - **时间过滤** —— `advanced_search` 工具支持 `timeRange`:固定档、自定义相对值、绝对日期三种形式(详见下方逻辑说明)
38
38
  - **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key
39
39
  - **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.17),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
@@ -58,7 +58,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
58
58
  | `tavily` | Tavily | 免费 | **无 key 也可用**(keyless 匿名),配 key 提升额度 |
59
59
  | `keenable` | Keenable | 免费 | **无 key 也可用**(MCP 匿名),配 key 提升额度 |
60
60
  | `firecrawl` | Firecrawl | 免费 | **无 key 也可用**(官方免 key 匿名额度),配 key 提升限额 |
61
- | `parallel` | Parallel | 付费 | 需 `PARALLEL_API_KEY`(platform.parallel.ai 有免费额度) |
61
+ | `parallel` | Parallel | 免费 | **无 key 也可用**(官方 MCP 匿名额度),配 key 提升额度并支持精确时间过滤 |
62
62
  | `perplexity` | Perplexity | 付费 | 需 `PERPLEXITY_API_KEY` |
63
63
  | `serpbase` | SerpBase | 付费 | 需 `SERPBASE_API_KEY`(serpbase.dev,注册送 100 次免费额度) |
64
64
  | `deepseek-official` | DeepSeek 官方 | 付费 | 需 `DEEPSEEK_API_KEY` |
@@ -121,7 +121,7 @@ dsh web
121
121
  配置页提供:
122
122
 
123
123
  - **Search engine**:下拉框切换引擎,保存即生效
124
- - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek 填写 key(密码框,保存后只显示"已配置")
124
+ - **API keys**:为 Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek 填写 key(密码框,保存后只显示"已配置";Exa / Tavily / Keenable / Firecrawl / Parallel 不填也可免 key 使用)
125
125
  - **推荐**:付费引擎 key 建议写入 harness 凭据中心 `~/.dsh/.credentials.yaml`(如 `DEEPSEEK_API_KEY: sk-...`,与官方 LLM provider 一致,一处管理所有 key)。插件读取优先级:凭据中心 > 设置页 > 环境变量,设置页填的 key 仅作为遗留兼容。
126
126
  - **Test engine**:直测当前引擎可用性(不走回退链,付费引擎无 key 会明确报错)
127
127
  - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
@@ -206,7 +206,7 @@ Search engine test:
206
206
  | Keenable | `published_after` | ✅ 精确 | 相对值原样传(`12h/3d/2mo/1y`),绝对日期原样传 |
207
207
  | Tavily | `time_range` | ⚠️ 近似 | 只认固定档,自定义天数自动映射到最近似档位 |
208
208
  | Firecrawl | `tbs` | ⚠️ 近似 | 固定档映射到 `qdr:d/w/m/y`;绝对日期用 `cdr:1,cd_min:M/D/YYYY`(精确) |
209
- | Parallel | `source_policy.after_date` | ✅ 精确 | 自定义天数转成 ISO 日期(N 天前),绝对日期原样传入 |
209
+ | Parallel | `source_policy.after_date`(有 key 时精确);无 key 走 MCP,无日期参数,改为把窗口写进 objective 作为新鲜度提示(软过滤) | ✅ 精确 / ⚠️ 软过滤 | 自定义天数转成 ISO 日期(N 天前),绝对日期原样传入 |
210
210
  | SearXNG | `time_range` | ⚠️ 近似 | 同上 |
211
211
  | DuckDuckGo / Lite | `df` | ⚠️ 近似 | 同上 |
212
212
  | Bing / AnySearch | — | ❌ 忽略 | 无对应参数 |
@@ -302,7 +302,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
302
302
  - **Web Settings UI** — Engine switching, API key configuration (keys masked as "configured" in the UI), and a Chinese/English toggle; on DSH 0.1.6-alpha.2+ the entry is the component-row config on the sidebar Plugins page, on older versions Settings → Plugins → Configurable
303
303
  - **Popup Switch Command** — Type `/free-search-engine` in the chat: a picker opens with all engines; click one to switch (equivalent to the settings page + save)
304
304
  - **Engine Testing** — `free_search_test` for the agent to check all engines in one call; the settings UI also has a "Test engine" button that tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error)
305
- - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable/firecrawl are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright.
305
+ - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright.
306
306
  - **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
307
307
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
308
308
  - **Version + Update Check** — The settings card shows the current version (v0.4.17), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
@@ -327,13 +327,13 @@ If this plugin has been helpful, a ⭐ on [GitHub](https://github.com/DDDMUC/dsh
327
327
  | `tavily` | Tavily | Free | **Usable without a key** (keyless anonymous); configure a key for higher quota |
328
328
  | `keenable` | Keenable | Free | **Usable without a key** (anonymous MCP); configure a key for higher quota |
329
329
  | `firecrawl` | Firecrawl | Free | **Usable without a key** (official keyless anonymous quota); configure a key for higher limits |
330
- | `parallel` | Parallel | Paid | Requires `PARALLEL_API_KEY` (free tier available at platform.parallel.ai) |
330
+ | `parallel` | Parallel | Free | **Works without a key** (official MCP anonymous quota); a key raises limits and enables precise time filtering |
331
331
  | `perplexity` | Perplexity | Paid | Requires `PERPLEXITY_API_KEY` |
332
332
  | `serpbase` | SerpBase | Paid | Requires `SERPBASE_API_KEY` (serpbase.dev, 100 free queries on signup) |
333
333
  | `deepseek-official` | DeepSeek Official | Paid | Requires `DEEPSEEK_API_KEY` |
334
334
 
335
335
  - **Default engine is `bing`** (free and most stable), ready to use out of the box after installation.
336
- - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — the configured engine first, then other engines (exa/tavily/keenable/firecrawl are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright because of engine issues.
336
+ - **Auto-failover**: any engine failure (rate-limited free engine, or missing/invalid paid key, network error) automatically tries the next engine — the configured engine first, then other engines (exa/tavily/keenable/firecrawl/parallel are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright because of engine issues.
337
337
  - **Official Links in Settings**: Free engines display "Visit Website →", while paid engines display "Get API Key →" (opens in a new tab):
338
338
  - Exa: <https://dashboard.exa.ai/api-keys>
339
339
  - Tavily: <https://app.tavily.com/home>
@@ -390,7 +390,7 @@ After installation, open the config page according to your DSH version:
390
390
  The config page provides:
391
391
 
392
392
  - **Search engine**: Select an engine from the dropdown; changes take effect immediately upon saving.
393
- - **API keys**: Enter keys for Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek (password fields; displayed as "configured" once saved).
393
+ - **API keys**: Enter keys for Exa / Tavily / Keenable / Firecrawl / Parallel / Perplexity / DeepSeek (password fields; displayed as "configured" once saved; Exa / Tavily / Keenable / Firecrawl / Parallel work without a key too).
394
394
  - **Recommended**: store paid-engine keys in the harness credential center `~/.dsh/.credentials.yaml` (e.g. `DEEPSEEK_API_KEY: sk-...`, same as the official LLM providers — one place for all keys). Resolution order: credentials center > settings page > environment variable; the settings-page fields remain for backward compatibility.
395
395
  - **Test engine**: Tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error).
396
396
  - **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits
@@ -475,7 +475,7 @@ Ask the agent for *"news from the last week"*, *"releases this month"*, *"update
475
475
  | Keenable | `published_after` | ✅ precise | relative values (`12h/3d/2mo/1y`) and absolute dates pass through |
476
476
  | Tavily | `time_range` | ⚠️ approximate | only fixed tiers; custom days map to the nearest tier |
477
477
  | Firecrawl | `tbs` | ⚠️ approximate | fixed tiers map to `qdr:d/w/m/y`; absolute dates use `cdr:1,cd_min:M/D/YYYY` (precise) |
478
- | Parallel | `source_policy.after_date` | ✅ precise | custom days become an ISO date (N days ago); absolute dates pass through |
478
+ | Parallel | `source_policy.after_date` with a key (precise); without a key the MCP path has no date parameter, so the window is written into the objective as a freshness hint (soft filter) | ✅ precise / ⚠️ soft | custom days become an ISO date (N days ago); absolute dates pass through |
479
479
  | SearXNG | `time_range` | ⚠️ approximate | same as above |
480
480
  | DuckDuckGo / Lite | `df` | ⚠️ approximate | same as above |
481
481
  | Bing / AnySearch | — | ❌ ignored | no corresponding parameter |
package/lib/client.js CHANGED
@@ -86,7 +86,7 @@ window.__ModuleLoader__.load({
86
86
  const NS = "free-search";
87
87
  const I18N = {
88
88
  zh: {
89
- description: "免费搜索 —— 无需 API key(Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl)",
89
+ description: "免费搜索 —— 无需 API key(Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel)",
90
90
  unsaved: "未保存",
91
91
  searchEngine: "搜索引擎",
92
92
  visit: "访问官网 →",
@@ -97,7 +97,7 @@ window.__ModuleLoader__.load({
97
97
  tavilyPh: (c) => c ? "Tavily API 密钥(已配置)" : "Tavily API 密钥(可选,不填也可免费使用)",
98
98
  keenablePh: (c) => c ? "Keenable API 密钥(已配置)" : "Keenable API 密钥(可选,不填也可免费使用)",
99
99
  firecrawlPh: (c) => c ? "Firecrawl API 密钥(已配置)" : "Firecrawl API 密钥(可选,不填也可免费使用)",
100
- parallelPh: (c) => c ? "Parallel API 密钥(已配置)" : "Parallel API 密钥(必填,platform.parallel.ai)",
100
+ parallelPh: (c) => c ? "Parallel API 密钥(已配置)" : "Parallel API 密钥(可选,不填走 MCP 免 key)",
101
101
  perplexityPh: (c) => c ? "Perplexity API 密钥(已配置)" : "Perplexity API 密钥(pplx-...)",
102
102
  deepseekPh: (c) => c ? "DeepSeek API 密钥(已配置)" : "DeepSeek API 密钥(sk-...)",
103
103
  serpbasePh: (c) => c ? "SerpBase API 密钥(已配置)" : "SerpBase API 密钥(serpbase.dev 获取)",
@@ -153,7 +153,7 @@ window.__ModuleLoader__.load({
153
153
  marketKoKR: "ko-KR —— 韩国",
154
154
  },
155
155
  en: {
156
- description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl)",
156
+ description: "Free web search — no API key needed (Bing / DuckDuckGo / AnySearch / Exa / Tavily / Keenable / Firecrawl / Parallel)",
157
157
  unsaved: "unsaved",
158
158
  searchEngine: "Search engine",
159
159
  visit: "Visit website →",
@@ -164,7 +164,7 @@ window.__ModuleLoader__.load({
164
164
  tavilyPh: (c) => c ? "Tavily API key (configured)" : "Tavily API key (optional, free without)",
165
165
  keenablePh: (c) => c ? "Keenable API key (configured)" : "Keenable API key (optional, free without)",
166
166
  firecrawlPh: (c) => c ? "Firecrawl API key (configured)" : "Firecrawl API key (optional, free without)",
167
- parallelPh: (c) => c ? "Parallel API key (configured)" : "Parallel API key (required, platform.parallel.ai)",
167
+ parallelPh: (c) => c ? "Parallel API key (configured)" : "Parallel API key (optional, free without)",
168
168
  perplexityPh: (c) => c ? "Perplexity API key (configured)" : "Perplexity API key (pplx-...)",
169
169
  deepseekPh: (c) => c ? "DeepSeek API key (configured)" : "DeepSeek API key (sk-...)",
170
170
  serpbasePh: (c) => c ? "SerpBase API key (configured)" : "SerpBase API key (from serpbase.dev)",
@@ -222,7 +222,7 @@ window.__ModuleLoader__.load({
222
222
  };
223
223
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
224
224
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION 保持一致)
225
- const PLUGIN_VERSION = "0.4.29";
225
+ const PLUGIN_VERSION = "0.4.32";
226
226
  const ENGINES = [
227
227
  { id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
228
228
  { id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
@@ -233,7 +233,7 @@ window.__ModuleLoader__.load({
233
233
  { id: "tavily", label: "Tavily", badge: "FREE", link: "https://app.tavily.com/home" },
234
234
  { id: "keenable", label: "Keenable", badge: "FREE", link: "https://keenable.ai/login" },
235
235
  { id: "firecrawl", label: "Firecrawl", badge: "FREE", link: "https://www.firecrawl.dev" },
236
- { id: "parallel", label: "Parallel", badge: "API KEY", link: "https://platform.parallel.ai" },
236
+ { id: "parallel", label: "Parallel", badge: "FREE", link: "https://platform.parallel.ai" },
237
237
  { id: "perplexity", label: "Perplexity", badge: "API KEY", link: "https://www.perplexity.ai/settings/api" },
238
238
  { id: "serpbase", label: "SerpBase · Google", badge: "API KEY", link: "https://serpbase.dev" },
239
239
  { id: "deepseek-official", label: "DeepSeek Official", badge: "API KEY", link: "https://platform.deepseek.com/api_keys" },
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ const BING_URL = "https://www.bing.com/search";
11
11
  const TAVILY_URL = "https://api.tavily.com/search";
12
12
  const FIRECRAWL_URL = "https://api.firecrawl.dev/v2/search";
13
13
  const PARALLEL_URL = "https://api.parallel.ai/v1/search";
14
+ const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp";
14
15
  const KEENABLE_URL = "https://api.keenable.ai/v1/search";
15
16
  const KEENABLE_MCP_URL = "https://api.keenable.ai/mcp";
16
17
  const SERPBASE_URL = "https://api.serpbase.dev/google/search";
@@ -51,7 +52,7 @@ const FREE_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch"];
51
52
  const ALL_ENGINES = ["ddg", "ddg-lite", "bing", "searxng", "anysearch", "exa", "tavily", "keenable", "firecrawl", "parallel", "perplexity", "serpbase", "deepseek-official"];
52
53
 
53
54
  // 当前插件版本(发布时与 package.json 同步)
54
- const PLUGIN_VERSION = "0.4.29";
55
+ const PLUGIN_VERSION = "0.4.32";
55
56
  // 检查更新的 npm registry 元数据地址(dsh-free-search 是 npmjs 上的公开包)
56
57
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/dsh-free-search/latest";
57
58
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/dsh-free-search";
@@ -930,7 +931,7 @@ async function searchFirecrawl(query, maxResults, apiKey, timeRange, signal) {
930
931
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
931
932
  }
932
933
 
933
- // Parallel: 需 PARALLEL_API_KEY(x-api-key),自然语言 objective + search_queries,返回带 excerpts 的结果
934
+ // Parallel: 有 key 走 REST(x-api-key);无 key 走官方 MCP 免 key。自然语言 objective + search_queries,返回带 excerpts 的结果
934
935
  async function searchParallel(query, maxResults, apiKey, timeRange, signal) {
935
936
  if (!apiKey) throw new Error("Parallel search requires PARALLEL_API_KEY");
936
937
  const controller = new AbortController();
@@ -991,6 +992,91 @@ async function searchParallel(query, maxResults, apiKey, timeRange, signal) {
991
992
  return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
992
993
  }
993
994
 
995
+ // Parallel keyless:官方 MCP(search.parallel.ai/mcp)匿名额度,无需 API key。
996
+ // 与 REST 的差异:MCP 的 web_search 工具没有 max_results / after_date 参数
997
+ // (条数由服务端决定),时间过滤退化为 objective 里的新鲜度提示(软过滤)。
998
+ const PARALLEL_MCP_SESSION = (() => {
999
+ let id = "";
1000
+ for (let i = 0; i < 32; i++) id += Math.floor(Math.random() * 16).toString(16);
1001
+ return id;
1002
+ })();
1003
+
1004
+ async function searchParallelMCP(query, maxResults, timeRange, signal) {
1005
+ const controller = new AbortController();
1006
+ const timer = setTimeout(() => controller.abort(), 25000);
1007
+ const onAbort = () => controller.abort();
1008
+ signal?.addEventListener("abort", onAbort);
1009
+ let response;
1010
+ try {
1011
+ const after = timeRange
1012
+ ? timeRange.after ?? (timeRange.days !== undefined ? isoDaysAgo(timeRange.days).slice(0, 10) : undefined)
1013
+ : undefined;
1014
+ const objective = after ? `${query} (prefer results published after ${after})` : query;
1015
+ response = await fetch(PARALLEL_MCP_URL, {
1016
+ method: "POST",
1017
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
1018
+ body: JSON.stringify({
1019
+ jsonrpc: "2.0",
1020
+ id: Date.now(),
1021
+ method: "tools/call",
1022
+ params: {
1023
+ name: "web_search",
1024
+ arguments: {
1025
+ objective,
1026
+ search_queries: [query],
1027
+ session_id: PARALLEL_MCP_SESSION,
1028
+ },
1029
+ },
1030
+ }),
1031
+ signal: controller.signal,
1032
+ });
1033
+ } catch (error) {
1034
+ if (signal?.aborted) throw error;
1035
+ throw new Error(`Parallel MCP request failed: ${error?.message ?? String(error)}`);
1036
+ } finally {
1037
+ clearTimeout(timer);
1038
+ signal?.removeEventListener("abort", onAbort);
1039
+ }
1040
+ if (!response.ok) throw new Error(`Parallel MCP error (HTTP ${response.status})`);
1041
+ const text = await response.text();
1042
+ // 兼容 JSON 与 SSE(event: message\ndata: {...})两种响应形态
1043
+ let json = null;
1044
+ try {
1045
+ json = JSON.parse(text);
1046
+ } catch {
1047
+ for (const line of text.split("\n")) {
1048
+ if (line.startsWith("data: ")) {
1049
+ try {
1050
+ json = JSON.parse(line.slice(6));
1051
+ break;
1052
+ } catch {}
1053
+ }
1054
+ }
1055
+ }
1056
+ if (!json || json.error) throw new Error(`Parallel MCP error: ${json?.error?.message ?? "no data"}`);
1057
+ const blocks = (json.result?.content ?? []).filter((b) => b.type === "text").map((b) => b.text);
1058
+ if (json.result?.isError) throw new Error(`Parallel MCP error: ${blocks.join(" ").slice(0, 200) || "tool call failed"}`);
1059
+ let payload = null;
1060
+ for (const block of blocks) {
1061
+ try {
1062
+ payload = JSON.parse(block);
1063
+ break;
1064
+ } catch {}
1065
+ }
1066
+ const sources = (payload?.results ?? [])
1067
+ .filter((r) => r.url)
1068
+ .map((r) => {
1069
+ const excerpt = (r.excerpts ?? []).find((e) => String(e).trim().length > 0);
1070
+ return {
1071
+ url: r.url,
1072
+ ...(r.title ? { title: String(r.title) } : {}),
1073
+ ...(excerpt ? { snippet: String(excerpt).slice(0, 300) } : {}),
1074
+ ...(r.publish_date ? { publishedAt: String(r.publish_date) } : {}),
1075
+ };
1076
+ });
1077
+ return { sources: uniqueSources(sources, maxResults ?? 10), truncated: false };
1078
+ }
1079
+
994
1080
  // 把任意天数转成 Keenable 的相对时间格式(12h / Nd / Nmo / Ny)
995
1081
  function formatKeenableRelative(days) {
996
1082
  if (days <= 0.5) return "12h";
@@ -1817,15 +1903,13 @@ function apply(ctx, config) {
1817
1903
  const key = await resolveApiKey("FIRECRAWL_API_KEY", "firecrawlApiKey");
1818
1904
  result = await searchFirecrawl(request.query, request.maxResults, key, timeRange, effSignal);
1819
1905
  } else if (engine === "parallel") {
1820
- // parallel:必须配置 PARALLEL_API_KEY(无 key 跳过,同 perplexity)
1906
+ // parallel:有 key 走 REST(支持 after_date 精确过滤),无 key 走 keyless MCP(免费匿名额度)
1821
1907
  const key = await resolveApiKey("PARALLEL_API_KEY", "parallelApiKey");
1822
- if (!key) {
1823
- lastError = new Error("Parallel requires PARALLEL_API_KEY");
1824
- if (engine === preferred) preferredFailure = "PARALLEL_API_KEY is not configured";
1825
- logger.warn(`free-search: engine "${engine}" skipped (no key), trying next engine`);
1826
- continue; // 无 key 跳过
1908
+ if (key) {
1909
+ result = await searchParallel(request.query, request.maxResults, key, timeRange, effSignal);
1910
+ } else {
1911
+ result = await searchParallelMCP(request.query, request.maxResults, timeRange, effSignal);
1827
1912
  }
1828
- result = await searchParallel(request.query, request.maxResults, key, timeRange, effSignal);
1829
1913
  } else if (engine === "perplexity") {
1830
1914
  const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
1831
1915
  if (!key) {
@@ -1999,8 +2083,8 @@ function apply(ctx, config) {
1999
2083
  }
2000
2084
  case "parallel": {
2001
2085
  const key = await resolveApiKey("PARALLEL_API_KEY", "parallelApiKey");
2002
- if (!key) return { ok: false, error: "PARALLEL_API_KEY not configured" };
2003
- return await searchParallel(q, 2, key, tr);
2086
+ if (key) return await searchParallel(q, 2, key, tr);
2087
+ return await searchParallelMCP(q, 2, tr);
2004
2088
  }
2005
2089
  case "perplexity": {
2006
2090
  const key = await resolveApiKey("PERPLEXITY_API_KEY", "perplexityApiKey");
@@ -2089,7 +2173,9 @@ function apply(ctx, config) {
2089
2173
  }
2090
2174
  return `- ${r.engine}: FAIL - ${r.error}`;
2091
2175
  });
2092
- return `Search engine test:\n${lines.join("\n")}`;
2176
+ // 契约要求直接返回 ContentBlock[]:finalizeContent 在 tools/post-execute 之后才执行,
2177
+ // 只返回字符串会让该阶段的消费者(如 dsh-hooks-codex)拿到裸字符串崩溃。
2178
+ return [{ type: "text", text: `Search engine test:\n${lines.join("\n")}` }];
2093
2179
  },
2094
2180
  },
2095
2181
  async execute(args) {
@@ -2113,7 +2199,7 @@ function apply(ctx, config) {
2113
2199
  return { results };
2114
2200
  },
2115
2201
  finalizeContent(exec, result) {
2116
- // 把 render 输出包装成合法的 text block(content 必须是 block 数组)
2202
+ // render 已直接返回 block 数组;这里仅作旧路径兼容兜底(幂等)
2117
2203
  const text = result.content;
2118
2204
  if (typeof text === "string" && text.length > 0) {
2119
2205
  return [{ type: "text", text }];
@@ -2172,7 +2258,7 @@ function apply(ctx, config) {
2172
2258
  },
2173
2259
  render(args, value) {
2174
2260
  const lines = value.sources.map((s, i) => `- [${s.title ?? s.url}](${s.url})${s.snippet ? ` - ${s.snippet.slice(0, 120)}` : ""}`);
2175
- return `Platform search (${value.platform}):\n${lines.join("\n") || "No results found."}`;
2261
+ return [{ type: "text", text: `Platform search (${value.platform}):\n${lines.join("\n") || "No results found."}` }];
2176
2262
  },
2177
2263
  },
2178
2264
  async execute(args) {
@@ -2200,7 +2286,7 @@ function apply(ctx, config) {
2200
2286
  return { platform, sources };
2201
2287
  },
2202
2288
  finalizeContent(exec, result) {
2203
- // Tool-result content must be an array of content blocks, not a raw string.
2289
+ // render 已直接返回 block 数组;这里保留为幂等兜底
2204
2290
  const text = result.content;
2205
2291
  return typeof text === "string" && text.length > 0 ? [{ type: "text", text }] : undefined;
2206
2292
  },
@@ -2263,7 +2349,7 @@ function apply(ctx, config) {
2263
2349
  },
2264
2350
  render(args, value) {
2265
2351
  const lines = value.sources.map((s, i) => `- [${s.title ?? s.url}](${s.url})${s.snippet ? ` - ${s.snippet.slice(0, 120)}` : ""}${s.publishedAt ? ` (${s.publishedAt})` : ""}`);
2266
- return `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${lines.join("\n") || "No results found."}${value.content ? `\n\n${value.content}` : ""}`;
2352
+ return [{ type: "text", text: `Search (${value.provider}${args.timeRange ? `, timeRange=${args.timeRange}` : ""}):\n${lines.join("\n") || "No results found."}${value.content ? `\n\n${value.content}` : ""}` }];
2267
2353
  },
2268
2354
  },
2269
2355
  async execute(args) {
@@ -2293,7 +2379,7 @@ function apply(ctx, config) {
2293
2379
  };
2294
2380
  },
2295
2381
  finalizeContent(exec, result) {
2296
- // Tool-result content must be an array of content blocks, not a raw string.
2382
+ // render 已直接返回 block 数组;这里保留为幂等兜底
2297
2383
  const text = result.content;
2298
2384
  return typeof text === "string" && text.length > 0 ? [{ type: "text", text }] : undefined;
2299
2385
  },
@@ -2334,12 +2420,12 @@ function apply(ctx, config) {
2334
2420
  "- tavily - FREE keyless or TAVILY_API_KEY for higher limits",
2335
2421
  "- keenable - FREE keyless (MCP) or KEENABLE_API_KEY for higher limits",
2336
2422
  "- firecrawl - FREE keyless or FIRECRAWL_API_KEY for higher limits",
2337
- "- parallel - requires PARALLEL_API_KEY",
2423
+ "- parallel - FREE keyless (MCP) or PARALLEL_API_KEY for higher limits",
2338
2424
  "- perplexity - requires PERPLEXITY_API_KEY",
2339
2425
  "- deepseek-official - requires DEEPSEEK_API_KEY",
2340
2426
  "- serpbase - Google organic results via API, requires SERPBASE_API_KEY (100 free queries on signup)",
2341
2427
  "",
2342
- "IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) the configured engine first, (2) then other engines with API keys configured (exa/tavily/keenable/firecrawl work keyless too, so they are tried even without a key), (3) then the remaining free engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used and why the preferred one was skipped. Understand the two note forms: (a) 'Note: X does not support time filtering (timeRange=...), using Y.' means X cannot filter by time so it was skipped BEFORE any attempt (X did NOT fail); (b) 'Note: X unavailable or failed (reason), using Y.' means X was actually tried but failed (missing key / invalid key / 401 / rate limit / network / 0 results). Never tell the user search is unavailable - it always falls back.",
2428
+ "IMPORTANT: If the configured engine fails (missing key, invalid key, 401, rate limit, or network error), web_search automatically tries other engines in this order: (1) the configured engine first, (2) then other engines with API keys configured (exa/tavily/keenable/firecrawl/parallel work keyless too, so they are tried even without a key), (3) then the remaining free engines (Bing, AnySearch, DuckDuckGo, SearXNG). This applies to ALL engines - paid or free. The results include a note showing which engine was actually used and why the preferred one was skipped. Understand the two note forms: (a) 'Note: X does not support time filtering (timeRange=...), using Y.' means X cannot filter by time so it was skipped BEFORE any attempt (X did NOT fail); (b) 'Note: X unavailable or failed (reason), using Y.' means X was actually tried but failed (missing key / invalid key / 401 / rate limit / network / 0 results). Never tell the user search is unavailable - it always falls back.",
2343
2429
  "",
2344
2430
  "Use the free_search_test tool to test which engines actually work right now.",
2345
2431
  "",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-free-search",
3
- "version": "0.4.30",
4
- "description": "Free web search for DeepSeek Harness: 13 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl keyless; Parallel/Perplexity/SerpBase/DeepSeek with key) + time filtering + platform search + web_fetch, with web settings UI.",
3
+ "version": "0.4.32",
4
+ "description": "Free web search for DeepSeek Harness: 13 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Perplexity/SerpBase/DeepSeek with key) + time filtering + platform search + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {