@240xu/dsh-websearch 2.3.0 → 2.7.2

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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * dsh-websearch — 系统性搜索提示词(纯函数,零依赖)。
3
+ *
4
+ * 依据权威资料(2024-2026 官方文档与一手论文)实现三项必做:
5
+ * 1. 查询整形(query shaping):后端扇出前对口语化长查询做确定性归一,
6
+ * 并导出给宿主/代理层使用的查询构造指引。
7
+ * 依据:Tavily Best Practices(agent 风格 ≤1500 字符查询、子查询分解)
8
+ * https://docs.tavily.com/documentation/best-practices/best-practices-search
9
+ * Exa Search Best Practices(语义丰富、描述性查询)
10
+ * https://exa.ai/docs/search-best-practices
11
+ * 2. 结果呈现头(result-presentation header):模型看到的结果块以「来源
12
+ * 甄别 + 引用要求」开头,要求每条事实注明来源链接、优先官方/一手来源、
13
+ * 时效敏感主题优先较新结果。
14
+ * 依据:Anthropic Web Search Tool(结果块置于问题之前、引用来源)
15
+ * https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
16
+ * OpenAI Prompt Engineering(分解 + 持久性指令)
17
+ * https://platform.openai.com/docs/guides/prompt-engineering
18
+ * 3. 多面查询分解指引(导出常量,供宿主/代理层按需使用;不对本插件内
19
+ * 的单次请求盲目多查询——盲目多查询可能劣化,见 arXiv:2404.01037)。
20
+ * 依据:RAG-Fusion / 多查询变体 https://github.com/Raudaschl/rag-fusion
21
+ * Step-Back Prompting https://arxiv.org/abs/2310.06117
22
+ * HyDE https://arxiv.org/abs/2212.10496
23
+ * @module dsh-websearch/prompting
24
+ */
25
+
26
+ /** 查询硬上限(Tavily:agent 查询应简短聚焦;>1500 字符视为跑题长文)。 */
27
+ export const QUERY_MAX_CHARS = 1500;
28
+
29
+ /** 去口语化前缀(中英常见寒暄/请求词,确定性剥离,不改写语义)。 */
30
+ const FILLER_PREFIX_RE =
31
+ /^(?:please|pls|kindly|could you(?: please)?|can you(?: please)?|help me(?: to)?|search for|search|look up|google|find me|find|i(?:'d| would)? like to know|tell me|what is|who is|when is|where is|"|「|『)\s*/i;
32
+ const FILLER_PREFIX_RE_ZH =
33
+ /^(?:请|麻烦|帮我?|帮忙|帮我查一?下|查一?下|查询|搜索一下?|搜一下?|查一下?|帮我找|找一下?|我想知道|请问)/;
34
+
35
+ /**
36
+ * 确定性查询整形:剥离口语前缀(循环多次以处理叠加寒暄)、压缩空白、
37
+ * 截断到 QUERY_MAX_CHARS。绝不改写实体/版本号/日期。
38
+ * @param {string} query
39
+ * @returns {string}
40
+ */
41
+ export function shapeQuery(query) {
42
+ if (typeof query !== "string") return "";
43
+ let q = query.replace(/\s+/g, " ").trim();
44
+ // 中文寒暄与英文寒暄交替剥离,直到稳定。
45
+ for (let i = 0; i < 6; i++) {
46
+ const before = q;
47
+ q = q.replace(FILLER_PREFIX_RE, "").trim();
48
+ q = q.replace(FILLER_PREFIX_RE_ZH, "").trim();
49
+ if (q === before) break;
50
+ }
51
+ if (q.length > QUERY_MAX_CHARS) q = q.slice(0, QUERY_MAX_CHARS).trim();
52
+ return q;
53
+ }
54
+
55
+ /**
56
+ * 结果呈现头(英文)。置于模型可见结果块最前(Anthropic:结果先于问题
57
+ * 显著提升引用召回)。要求:逐条标注 [n]、优先官方/一手来源、时效敏感
58
+ * 主题优先较新结果、每条事实给出来源链接、与问题无关的条目忽略。
59
+ */
60
+ export const RESULT_HEADER_EN =
61
+ "Web search results (merged from multiple engines, deduplicated by URL). " +
62
+ "Each entry: [n] title — URL. Prefer official / primary sources; prefer newer " +
63
+ "results for fast-moving topics; ignore entries whose snippet does not actually " +
64
+ "answer the question. Cite the URL for every fact you take from a result.";
65
+
66
+ /** 结果呈现头(中文)。 */
67
+ export const RESULT_HEADER_ZH =
68
+ "网页搜索结果(多引擎合并、已按 URL 去重)。每条格式:[n] 标题 — 链接。" +
69
+ "优先采用官方/一手来源;时效敏感主题优先较新结果;摘要与问题无关的条目直接忽略。" +
70
+ "引用结果中的任何事实时必须注明来源链接。";
71
+
72
+ /**
73
+ * 查询构造指引(导出给宿主/代理层注入 agent 系统提示用;本插件不发送)。
74
+ * EN 面向 agent,ZH 为同义说明。要点:一条聚焦查询、保留专名/版本/日期、
75
+ * 多面请求按维度拆成多条子查询、首轮结果不佳时先写 2-3 句假想答案再以其
76
+ * 关键短语重搜(HyDE)、持续检索直到每个维度都有答案。
77
+ */
78
+ export const QUERY_GUIDANCE_EN =
79
+ "When searching the web: issue ONE concise, agent-style query (keep named " +
80
+ "entities, version numbers and dates; drop conversational filler). If the " +
81
+ "request has 2+ independent facets, issue separate focused queries per facet " +
82
+ "and merge findings. If the first round returns poor results, write a 2-3 " +
83
+ "sentence hypothetical ideal answer and re-query with its key phrases. " +
84
+ "Keep searching until every facet is answered or demonstrably unavailable.";
85
+
86
+ /** 查询构造指引(中文版)。 */
87
+ export const QUERY_GUIDANCE_ZH =
88
+ "联网检索时:一次发出一条简洁的 agent 风格查询(保留专名/版本号/日期,去掉口语寒暄)。" +
89
+ "若请求含 2 个以上独立维度,按维度分别发起聚焦查询并汇总结论。" +
90
+ "首轮结果不佳时,先写 2-3 句假想的理想答案,再用其中的关键短语重新检索。" +
91
+ "持续检索直到每个维度都有答案或确认不可得。";
92
+
93
+ /**
94
+ * 按语言取结果呈现头。
95
+ * @param {"zh"|"en"|string} [lang]
96
+ */
97
+ export function resultHeader(lang) {
98
+ return String(lang || "").toLowerCase().startsWith("zh") ? RESULT_HEADER_ZH : RESULT_HEADER_EN;
99
+ }
100
+
101
+ /**
102
+ * 遥测行自标注(零成本修复):模型可能把后端失败遥测误读为结果来源;
103
+ * 显式声明「仅诊断,不是来源」。随结果块一并呈现。
104
+ */
105
+ export const TELEMETRY_TAG = " (diagnostic only, not a source)";
package/lib/provider.js CHANGED
@@ -22,9 +22,44 @@ import {
22
22
  } from "./util/log.js";
23
23
  import { dedupeSources, rerankSources } from "./util/rank.js";
24
24
  import { normalizeFilters } from "./util/filters.js";
25
+ import { shapeQuery, resultHeader, TELEMETRY_TAG } from "./prompting.js";
26
+ import { cacheKeyFor } from "./cache.js";
27
+ import { foldBreaker, isCooled, cooledAnnotation } from "./breaker.js";
25
28
 
26
29
  const PROVIDER_ID = "unified";
27
30
 
31
+ // Module-level per-backend health telemetry (id -> entry), updated on every
32
+ // backend completion during a real search. Purely passive: nothing writes here
33
+ // outside search(), so the read-only /api/unified-search/health route can
34
+ // report the LAST known reachability without firing probes or burning quota.
35
+ // Entry shape: { ok: boolean, at: ms, ms: duration, count?: number,
36
+ // error?: string (already truncated to 80), lastOkAt: ms|null }
37
+ const latestBackendHealth = new Map();
38
+
39
+ /** Read-only access for the health route (never mutate the returned map). */
40
+ export function getLatestBackendHealth() {
41
+ return latestBackendHealth;
42
+ }
43
+
44
+ function recordBackendHealth(id, entry, breakerCfg) {
45
+ const prev = latestBackendHealth.get(id);
46
+ const breaker = foldBreaker(prev, entry.ok === true, entry.at, breakerCfg ?? undefined);
47
+ latestBackendHealth.set(id, {
48
+ ...entry,
49
+ lastOkAt: entry.ok === true ? entry.at : (prev && prev.lastOkAt) || null,
50
+ failCount: breaker.failCount,
51
+ cooledUntil: breaker.cooledUntil,
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Read-only access to one backend's breaker state (failCount / cooledUntil).
57
+ */
58
+ export function getBackendBreaker(id) {
59
+ const e = latestBackendHealth.get(id);
60
+ return e ? { failCount: e.failCount ?? 0, cooledUntil: e.cooledUntil ?? 0 } : { failCount: 0, cooledUntil: 0 };
61
+ }
62
+
28
63
  /**
29
64
  * Simple concurrency limiter using a semaphore pattern.
30
65
  * No external dependency needed.
@@ -52,6 +87,47 @@ function createLimiter(concurrency) {
52
87
  };
53
88
  }
54
89
 
90
+ /**
91
+ * PHASE 1 (collect): fold settled fan-out results into the raw merge inputs.
92
+ * Sources keep fan-out priority order; dedupe/rank over the FULL set happens
93
+ * later (a mid-merge maxResults cap would silently drop later backends'
94
+ * entries before cross-backend fill-in and ranking could see them).
95
+ * URL scheme whitelist is enforced here — javascript:/data: payloads from
96
+ * compromised or poisoned backends never reach model/GUI consumers.
97
+ */
98
+ function collectFanoutResults(results, eligible, signal) {
99
+ const collected = [];
100
+ let mergedContent;
101
+ let hardAborted = false;
102
+ let hardError = null;
103
+ const failures = [];
104
+
105
+ let backendIndex = 0;
106
+ for (const settled of results) {
107
+ const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
108
+ backendIndex++;
109
+ if (settled.status === "fulfilled") {
110
+ if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
111
+ mergedContent = settled.value.content;
112
+ }
113
+ for (const src of settled.value?.sources ?? []) {
114
+ if (src && typeof src.url === "string" && /^https?:\/\//i.test(src.url)) collected.push(src);
115
+ }
116
+ } else {
117
+ const err = settled.reason;
118
+ if (err instanceof WebError && err.code === "WEB_ABORTED") {
119
+ // Per-backend abort -> soft unless the caller signal fired.
120
+ if (signal?.aborted === true) hardAborted = true;
121
+ else failures.push({ id: failedId, err });
122
+ } else {
123
+ failures.push({ id: failedId, err });
124
+ if (hardError === null) hardError = err;
125
+ }
126
+ }
127
+ }
128
+ return { collected, mergedContent, hardAborted, hardError, failures };
129
+ }
130
+
55
131
  /**
56
132
  * Build the UnifiedSearchProvider.
57
133
  *
@@ -60,7 +136,7 @@ function createLimiter(concurrency) {
60
136
  * @param {Function} deps.resolveOptions () => resolved options snapshot
61
137
  * @param {object} deps.backends keyed backend registry
62
138
  */
63
- export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
139
+ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends, cache, history }) {
64
140
  return {
65
141
  id: PROVIDER_ID,
66
142
 
@@ -87,19 +163,89 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
87
163
  async search(request, signal) {
88
164
  throwIfSearchAborted(signal);
89
165
  const opts = resolveOptions();
90
- const maxResults = request.maxResults ?? opts.numResults ?? 8;
166
+ const filters = normalizeFilters({
167
+ ...(opts.filters ?? {}),
168
+ ...(request?.filters ?? {}),
169
+ });
170
+ let effReq = { ...request, maxResults: Math.max(1, Number(request?.maxResults) || opts.numResults || 8) };
171
+ // 系统性查询整形(Tavily/Exa best practices):扇出前对口语化查询做
172
+ // 确定性归一 —— 剥离寒暄前缀、压缩空白、截断到 1500 字符;
173
+ // 专名/版本号/日期原样保留,不改写语义。
174
+ effReq.query = shapeQuery(effReq.query);
175
+ if (Object.keys(filters).length > 0) effReq = { ...effReq, filters };
176
+ const maxResults = effReq.maxResults;
91
177
  const concurrency = opts.concurrency ?? 6;
92
178
  const backendTimeoutMs = opts.backendTimeoutMs ?? 30000;
93
179
 
94
- // Pick enabled & available backends.
95
- const eligible = [];
180
+ // v2.7.0 disk cache: keyed on (shaped query + filters + maxResults).
181
+ // A hit short-circuits the fan-out entirely; sources are byte-identical
182
+ // to the first search and content carries a "cache hit, age Ns" note.
183
+ // Cache failures never break the search (cache API never throws).
184
+ const cacheEnabled = opts.cacheEnabled === true && cache;
185
+ let cacheKey = null;
186
+ if (cacheEnabled) {
187
+ cacheKey = cacheKeyFor({ query: effReq.query, filters, maxResults, backends: opts.enabledBackends });
188
+ const hit = cache.get(cacheKey);
189
+ if (hit && Array.isArray(hit.value?.sources) && hit.value.sources.length > 0) {
190
+ // Defense in depth: the fresh path drops non-http(s) schemes before
191
+ // merge; a tampered cache file must not bypass that whitelist.
192
+ const safeSources = hit.value.sources.filter(
193
+ (src) => src && typeof src.url === "string" && /^https?:\/\//i.test(src.url),
194
+ );
195
+ if (safeSources.length === 0) {
196
+ // whole entry poisoned or filtered empty -> treat as a miss
197
+ cacheKey = null;
198
+ } else {
199
+ let hitContent = typeof hit.value.content === "string" ? hit.value.content : undefined;
200
+ const cacheNote = `[websearch cache] cache hit, age ${hit.age}s`;
201
+ hitContent = hitContent === undefined ? cacheNote : hitContent + "\n\n" + cacheNote;
202
+ history?.record({
203
+ query: request?.query,
204
+ time: Date.now(),
205
+ resultCount: hit.value.sources.length,
206
+ backendsOk: 0,
207
+ backendsTotal: 0,
208
+ });
209
+ return {
210
+ sources: safeSources,
211
+ ...(hitContent !== undefined ? { content: hitContent } : {}),
212
+ truncated: hit.value.truncated === true,
213
+ };
214
+ }
215
+ }
216
+ }
217
+
218
+ // Pick enabled & available backends. With the breaker enabled, a
219
+ // backend inside its 60s cooldown window is skipped (annotated in
220
+ // telemetry as "id ⏸cooled"). Fail-open: if EVERY candidate is cooled,
221
+ // run them all anyway — the breaker must never make search worse than
222
+ // no breaker.
223
+ const breakerCfg = opts.breakerEnabled === false ? null : {
224
+ threshold: opts.breakerThreshold,
225
+ cooldownMs: opts.breakerCooldownMs,
226
+ };
227
+ const nowMs = Date.now();
228
+ const candidates = [];
96
229
  for (const id of opts.enabledBackends) {
97
230
  const be = backends[id];
98
231
  if (!be) continue;
99
232
  const beOpts = opts.backends?.[id] ?? {};
100
233
  const avail = be.available(beOpts);
101
234
  if (!avail) continue;
102
- eligible.push({ id, backend: be, beOpts });
235
+ candidates.push({ id, backend: be, beOpts });
236
+ }
237
+ const cooled = new Map();
238
+ let eligible = candidates;
239
+ if (breakerCfg) {
240
+ const active = candidates.filter((c) => {
241
+ if (isCooled(latestBackendHealth.get(c.id), nowMs)) {
242
+ cooled.set(c.id, latestBackendHealth.get(c.id));
243
+ return false;
244
+ }
245
+ return true;
246
+ });
247
+ if (active.length > 0) eligible = active;
248
+ else cooled.clear(); // all cooled -> fail-open
103
249
  }
104
250
 
105
251
  if (eligible.length === 0) {
@@ -112,16 +258,10 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
112
258
  // Settings-level default filters, overridden by per-request filters
113
259
  // (normalizeFilters drops unknown/invalid values so typos degrade to
114
260
  // "no filter" instead of breaking the search).
115
- const filters = normalizeFilters({
116
- ...(opts.filters ?? {}),
117
- ...(request?.filters ?? {}),
118
- });
119
- const effectiveRequest =
120
- Object.keys(filters).length > 0 ? { ...request, filters } : request;
121
261
 
122
262
  const telemetry = [];
123
263
  // Concurrency limiter
124
- const limit = createLimiter(opts.concurrency ?? 6);
264
+ const limit = createLimiter(concurrency);
125
265
 
126
266
  // Run one backend under the per-backend timeout. The internal timer's
127
267
  // controller is merged into the caller's signal; when the INTERNAL timer
@@ -130,20 +270,35 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
130
270
  // failures instead of being silently demoted like user cancellations.
131
271
  const runBackend = ({ id, backend, beOpts, hooks }) => {
132
272
  const startedAtMs = Date.now();
273
+ // Trust boundary: baseURL is operator-configurable and API keys are sent to it.
274
+ // Enforce http(s); plain http only for private hosts (self-hosted SearXNG).
275
+ // Reject embedded credentials. Failure is a per-backend soft error.
276
+ if (beOpts.baseURL !== undefined) {
277
+ let bu = null;
278
+ try { bu = new URL(beOpts.baseURL); } catch { bu = null; }
279
+ const privHost = bu ? /^(localhost|127(\.\d{1,3}){3}|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){3}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){3})$/.test(bu.hostname) : false;
280
+ const schemeOk = bu && (bu.protocol === "https:" || (bu.protocol === "http:" && privHost));
281
+ if (!schemeOk || bu.username || bu.password) {
282
+ throw new WebError(`backend "${id}" has an invalid baseURL: https required (plain http only for private hosts), credentials-in-URL rejected`, "WEB_PROVIDER_ERROR");
283
+ }
284
+ }
133
285
  const controller = new AbortController();
134
286
  const mergedSignal = signal
135
287
  ? AbortSignal.any([signal, controller.signal])
136
288
  : controller.signal;
137
289
  const timeoutId = setTimeout(() => controller.abort(), backendTimeoutMs);
138
290
  return backend
139
- .search(effectiveRequest, mergedSignal, beOpts, hooks)
291
+ .search(effReq, mergedSignal, beOpts, hooks)
140
292
  .then(
141
293
  (value) => {
142
294
  telemetry.push({ id, ok: true, ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 });
295
+ recordBackendHealth(id, { ok: true, at: Date.now(), ms: Date.now() - startedAtMs, count: value?.sources?.length ?? 0 }, breakerCfg);
143
296
  return value;
144
297
  },
145
298
  (err) => {
146
- telemetry.push({ id, ok: false, ms: Date.now() - startedAtMs, error: String(err instanceof Error ? err.message : err).slice(0, 80) });
299
+ const msg = String(err instanceof Error ? err.message : err).slice(0, 80);
300
+ telemetry.push({ id, ok: false, ms: Date.now() - startedAtMs, error: msg });
301
+ recordBackendHealth(id, { ok: false, at: Date.now(), ms: Date.now() - startedAtMs, error: msg }, breakerCfg);
147
302
  throw err;
148
303
  },
149
304
  )
@@ -172,45 +327,18 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
172
327
 
173
328
  throwIfSearchAborted(signal);
174
329
 
175
- // Collect every fulfilled backend's sources in fan-out priority order,
176
- // then dedupe/rank ONCE over the full set. (The previous loop stopped at
177
- // maxResults mid-merge, which silently dropped later backends' entries
178
- // before cross-backend fill-in and ranking could see them.)
179
- const collected = [];
180
- let mergedContent;
181
- let hardAborted = false;
182
- let hardError = null;
183
- const failures = [];
184
-
185
- let backendIndex = 0;
186
- for (const settled of results) {
187
- const failedId = eligible[backendIndex] ? eligible[backendIndex].id : "unknown";
188
- backendIndex++;
189
- if (settled.status === "fulfilled") {
190
- if (mergedContent === undefined && typeof settled.value?.content === "string" && settled.value.content.length > 0) {
191
- mergedContent = settled.value.content;
192
- }
193
- for (const s of settled.value?.sources ?? []) collected.push(s);
194
- } else {
195
- // settled.status === "rejected"
196
- const err = settled.reason;
197
- if (err instanceof WebError && err.code === "WEB_ABORTED") {
198
- // Per-backend abort → soft unless caller signal fired.
199
- if (signal?.aborted === true) hardAborted = true;
200
- else failures.push({ id: failedId, err });
201
- } else {
202
- failures.push({ id: failedId, err });
203
- if (hardError === null) hardError = err;
204
- }
205
- }
206
- }
330
+ // PHASE 1 collect: fold the settled fan-out into raw sources (fan-out
331
+ // priority order), first backend content, and per-backend failures.
332
+ const { collected, mergedContent, hardAborted, hardError, failures } =
333
+ collectFanoutResults(results, eligible, signal);
207
334
 
208
335
  // Caller-cancellation check (hard abort fires before we yield results).
209
336
  if (signal?.aborted === true || hardAborted) {
210
337
  throw searchAborted(signal);
211
338
  }
212
339
 
213
- // Dedup: "url" preserves the historical URL-key behavior; "url+title"
340
+ // PHASE 2 dedupe / PHASE 3 rerank: "url" preserves the historical
341
+ // URL-key behavior; "url+title"
214
342
  // additionally collapses same-story syndicated copies across backends.
215
343
  const strategy = opts.dedupStrategy === "url+title" ? "url+title" : "url";
216
344
  const deduped = dedupeSources(collected, strategy);
@@ -223,11 +351,37 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
223
351
  // If we got ANY sources, return them even if some backends failed.
224
352
  if (ordered.length > 0) {
225
353
  const sources = ordered.slice(0, maxResults);
354
+ // 结果呈现头(Anthropic web-search / OpenAI grounding 官方指引):
355
+ // 置于结果块最前,要求逐条引用来源 URL、优先官方/一手与较新来源;
356
+ // 语言按查询文本自动选择(含 CJK 用中文头)。可用
357
+ // opts.resultHeader === false 关闭。
226
358
  let outContent = mergedContent;
227
- if (opts.resultTelemetry !== false && telemetry.length > 0) {
228
- const line = "[websearch backends] " + telemetry
229
- .map((t) => t.id + (t.ok ? " ✓" : " ✗") + t.ms + "ms" + (t.ok ? "/" + t.count : ""))
230
- .join(" · ");
359
+ if (opts.resultHeader !== false && (sources.length > 0 || outContent !== undefined)) {
360
+ const header = resultHeader(typeof request?.query === "string" && /[\u4e00-\u9fff]/.test(request.query) ? "zh" : "en");
361
+ outContent = outContent === undefined ? header : header + "\n\n" + outContent;
362
+ }
363
+ // Store the pre-telemetry core (sources + headered content) so a cache
364
+ // hit reproduces the exact sources and the model-facing header.
365
+ if (cacheKey && cache) {
366
+ cache.set(cacheKey, { sources, content: outContent, truncated: ordered.length > maxResults });
367
+ }
368
+ history?.record({
369
+ query: request?.query,
370
+ time: Date.now(),
371
+ resultCount: sources.length,
372
+ backendsOk: telemetry.filter((t) => t.ok).length,
373
+ backendsTotal: telemetry.length,
374
+ });
375
+ if (opts.resultTelemetry !== false && (telemetry.length > 0 || cooled.size > 0)) {
376
+ const cooledParts = [];
377
+ for (const [cid, centry] of cooled) {
378
+ const ann = cooledAnnotation(centry, Date.now()) || "⏸cooled";
379
+ cooledParts.push(cid + " " + ann);
380
+ }
381
+ const parts = telemetry
382
+ .map((t) => t.id + (t.ok ? " ✓" : " ✗") + t.ms + "ms" + (t.ok ? "/" + t.count : (t.error ? " (" + String(t.error).slice(0, 40) + ")" : "")))
383
+ .concat(cooledParts);
384
+ const line = "[websearch backends]" + TELEMETRY_TAG + " " + parts.join(" · ");
231
385
  outContent = outContent === undefined ? line : outContent + "\n\n" + line;
232
386
  }
233
387
  return {
@@ -244,7 +398,7 @@ export function createUnifiedSearchProvider({ ctx, resolveOptions, backends }) {
244
398
  .join("; ");
245
399
  const allKeyMissing =
246
400
  failures.length > 0 &&
247
- failures.every((f) => f.err instanceof WebError && f.err.code === "WEB_PROVIDER_UNAVAILABLE");
401
+ failures.every((f) => f.err instanceof WebError && (f.err.code === "WEB_PROVIDER_UNAVAILABLE" || f.err.code === "WEB_PROVIDER_CREDENTIAL_MISSING"));
248
402
  const hint = allKeyMissing
249
403
  ? " (every enabled backend is missing its API key - add keys in Settings > Web Search, or enable a keyless backend)"
250
404
  : "";
package/lib/util/log.js CHANGED
@@ -56,7 +56,7 @@ export function recordBackendOutcome(ctx, backendId, outcome) {
56
56
  const logger = ctx && ctx.logger;
57
57
  if (!logger) return;
58
58
  const status = outcome && typeof outcome.status === "string" ? outcome.status : "";
59
- const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {});
59
+ const line = "[websearch:" + backendId + "] outcome " + JSON.stringify(outcome ?? {}).slice(0, 500);
60
60
  if (status === "error" && typeof logger.warn === "function") logger.warn(line);
61
61
  else if (typeof logger.debug === "function") logger.debug(line);
62
62
  } catch {
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
9
 
10
- const UA = "dsh-websearch/2.1";
10
+ const UA = "dsh-websearch/2.3";
11
11
  const PROTOCOL_VERSION = "2025-06-18";
12
12
  const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
13
13
 
@@ -58,7 +58,7 @@ async function ensureSession(url, signal) {
58
58
  }
59
59
 
60
60
  if (!initResponse.ok) {
61
- const text = await initResponse.text().catch(() => "");
61
+ const text = await initResponse.text().catch(() => "").then((tx) => tx.slice(0, 300));
62
62
  throw new WebError(`MCP initialize HTTP ${initResponse.status}: ${text}`, "WEB_PROVIDER_ERROR");
63
63
  }
64
64
 
@@ -75,7 +75,7 @@ async function ensureSession(url, signal) {
75
75
  };
76
76
 
77
77
  try {
78
- await fetch(url, {
78
+ const notifyResponse = await fetch(url, {
79
79
  method: "POST",
80
80
  headers: {
81
81
  "Content-Type": "application/json",
@@ -86,6 +86,8 @@ async function ensureSession(url, signal) {
86
86
  signal,
87
87
  redirect: "error",
88
88
  });
89
+ // Drain the discarded body so undici returns the socket to the pool.
90
+ notifyResponse.body?.cancel?.().catch(() => {});
89
91
  } catch (error) {
90
92
  // Best-effort: Exa requires this, Parallel tolerates absence
91
93
  if (signal?.aborted) throw error;
@@ -104,17 +106,16 @@ async function ensureSession(url, signal) {
104
106
  function parseEnvelope(text, expectedId) {
105
107
  // Try SSE format first: "event: message\ndata: {...}\n\n"
106
108
  if (text.includes("data:")) {
107
- const lines = text.trim().split("\n");
108
- let dataLine = null;
109
- for (const line of lines) {
110
- if (line.startsWith("data:")) {
111
- dataLine = line.slice(5).trim();
112
- break;
113
- }
114
- }
115
- if (dataLine) {
109
+ // SSE spec: one JSON payload may span multiple data: lines - join them.
110
+ const joined = text
111
+ .trim()
112
+ .split("\n")
113
+ .filter((l) => l.startsWith("data:"))
114
+ .map((l) => l.slice(5).trim())
115
+ .join("");
116
+ if (joined) {
116
117
  try {
117
- return JSON.parse(dataLine);
118
+ return JSON.parse(joined);
118
119
  } catch {}
119
120
  }
120
121
  }
@@ -205,7 +206,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
205
206
  }
206
207
 
207
208
  if (!response.ok) {
208
- const text = await response.text().catch(() => "");
209
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
209
210
  throw new WebError(`MCP tools/call HTTP ${response.status}: ${text}`, "WEB_PROVIDER_ERROR");
210
211
  }
211
212
 
package/lib/util/rank.js CHANGED
@@ -97,7 +97,7 @@ function scoreSource(source, queryTokens) {
97
97
  let score = 0;
98
98
  const titleTokens = tokenize(source.title ?? "");
99
99
  const snippetTokens = tokenize(source.snippet ?? "");
100
- const urlTokens = tokenize(String(source.url ?? "").replace(/[^a-z0-9]+/g, " "));
100
+ const urlTokens = tokenize(String(source.url ?? ""));
101
101
  for (const q of queryTokens) {
102
102
  if (titleTokens.has(q)) score += 3;
103
103
  if (snippetTokens.has(q)) score += 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.3.0",
4
- "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends \u2014 Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
3
+ "version": "2.7.2",
4
+ "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool. v2.7.0: disk result cache, per-backend circuit breaker, append-only search history API. v2.7.1 review fixes: cache key includes enabledBackends, scheme whitelist on cache-hit path, serialized history writes, trust fence on /api/websearch/* routes, cooldownMs=0 disables breaker window, user-language settings labels.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {