@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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ DSH([DeepSeek Harness](https://github.com/deepseek-ai/dsh))原生插件:
29
29
  | `anthropic` | `ANTHROPIC_API_KEY` | `https://api.anthropic.com/v1/messages` | Anthropic Messages + `web_search_20250305`, model `claude-sonnet-4-6` | Claude Code 同机制 |
30
30
  | `openai` | `OPENAI_API_KEY` | `https://api.openai.com/v1/responses` | Responses API native `web_search` tool, parse `url_citation` annotations | Codex 同机制 |
31
31
 
32
- **Default enabled set**: `["exa", "parallel", "ddg", "searxng"]` — all keyless. The key-gated backends only join when their `available()` returns true (key present + baseURL reachable).
32
+ > 可用性说明:available() 只做本地配置检查(不联网探测可达性);密钥在每次搜索时按操作解析,缺失时该后端以明确错误码软失败(不阻塞其他后端)。全部缺 Key 时错误会指向设置页。
33
33
 
34
34
  ### SearXNG 实例 | Public instances
35
35
 
@@ -115,6 +115,45 @@ pnpm install
115
115
 
116
116
  环境变量兜底:未在 credentials 配置时回退读同名环境变量;`DSH_UNIFIED_SEARCH_BACKENDS` 可逗号分隔强制指定启用集合。
117
117
 
118
+ ## v2.7.2 补丁 | What's new in v2.7.2
119
+
120
+ - 设置项 label:6 个新设置项在 schema 层挂用户语言 label(含单位尾注,如「缓存有效期(秒)」),宿主渲染不再回退 camelCase 键名;description 同步以人话名称开头(真机渲染行为归视觉待办 V5)。
121
+
122
+ ## v2.7.1 评审修复 | What's new in v2.7.1
123
+
124
+ - **缓存 key 加入 enabledBackends 选择集**(arch-review P1-1):切换后端组合后立即拿到与新配置一致的结果,不再吃旧组合缓存。
125
+ - **缓存命中路径复用 URL scheme 白名单**(纵深防御):被篡改的缓存文件里的 javascript:/data: 条目在命中时同样被过滤。
126
+ - **history 并发写串行化**:record() 经进程内 promise 链串行,杜绝并发搜索完成时的丢失更新;单条写入仍为 tmp+rename 原子替换。
127
+ - **history 路由信任围栏**:两条 /api/websearch/* 路由要求回环 Host(防 DNS rebinding)+ same-site 浏览器上下文(防跨站 text/plain CSRF);无头客户端(curl)放行只读 GET。
128
+ - **熔断冷却时长支持 0**:`breakerCooldownMs=0` 显式关闭冷却(此前静默回退 60s)。
129
+ - **设置文案**:6 个新设置项改用户语言并按【缓存】/【熔断】/【历史】分组,单位(秒/毫秒)写入说明。
130
+ - **互操作**:新增 window 事件 `dsh-websearch:open-settings`(devkit 命令联动);收到时经 devkit toast 提示设置路径(宿主无分区跳转 API)。
131
+
132
+ ## v2.7.0 新增 | What's new in v2.7.0
133
+
134
+ 三大增量功能,全部经设置开关控制、默认为保守值,且不改变既有结果语义(v2.6.0 提示词原样保留):
135
+
136
+ ### 1. 搜索结果磁盘缓存(默认开)
137
+
138
+ - 相同 **(整形后查询 + filters + maxResults + 启用后端集合)** 在 TTL 内直接命中缓存,不打任何后端;`sources` 与首次搜索完全一致。
139
+ - 命中时 `content` 追加一行 `[websearch cache] cache hit, age Ns`。
140
+ - 存储位置:`$DSH_HOME/cache/websearch/results/`,单条目一个 JSON 文件,tmp+rename 原子写,超出上限按最旧淘汰;任何 fs 错误都被吞掉,缓存坏了绝不影响搜索。
141
+ - 设置:`cacheEnabled`(默认 true)、`cacheTtl`(秒,默认 900)。
142
+
143
+ ### 2. 后端熔断器(默认开,保守阈值)
144
+
145
+ - 同一后端**连续失败 3 次**进入 **60s 冷却**;冷却期内 eligible 选择直接跳过该后端,遥测行标注 `id ⏸cooled Ns`。
146
+ - 状态与 `recordBackendHealth` 共用(健康条目新增 `failCount` / `cooledUntil` 字段),任意一次成功立即复位。
147
+ - **Fail-open 兜底**:若所有候选后端都处于冷却期,则全部放行照常扇出——熔断器永远只会让搜索更快,不会让它更差。
148
+ - 设置:`breakerEnabled`(默认 true)、`breakerThreshold`(默认 3)、`breakerCooldownMs`(默认 60000)。
149
+
150
+ ### 3. 搜索历史 API(默认开,只追加 / 只读暴露)
151
+
152
+ - 每次搜索(含缓存命中)追加一条记录,环形上限 50 条,新在前;文件 `$DSH_HOME/cache/websearch/history.json`,写前原子替换。
153
+ - `GET /api/websearch/history` → `{ ok, entries: [{ query, time, resultCount, backendsOk, backendsTotal }] }`(只读,无凭证、无 URL)。
154
+ - `POST /api/websearch/history/clear` → `{ ok, cleared: true }` 显式清空。
155
+ - 设置:`historyEnabled`(默认 true)。
156
+
118
157
  ## Design | 设计
119
158
 
120
159
  - **One provider, no ambiguity**: a single `registeredSearchProvider({id:"unified"})` — the `dsh-web` seam's selection rule picks it unambiguously, and `search()` caps `maxResults` itself.
@@ -125,7 +164,7 @@ pnpm install
125
164
  - **Honest timeouts**: each backend runs under an internal abort controller merged into the caller signal; when only the internal timer fires, the failure is reclassified as WEB_PROVIDER_ERROR ("backend <id> timed out") instead of being masked as a user cancellation.
126
165
  - **Concurrency & Timeout Control**: `concurrency` (default 6) limits simultaneous calls; `backendTimeoutMs` (default 30s) caps each backend.
127
166
  - **Streamable-http MCP, custom handshake**: `lib/util/mcp-client.js` implements `initialize → notifications/initialized → tools/call` with `Mcp-Session-Id` header caching against Exa and Parallel — no dependency on the full MCP SDK.
128
- - **hooks.recordRequest/recordOutcome bridge**: each backend routes its request/outcome to `lib/util/log.js` which records `web/unified-search-backend-request` / `…backend-outcome` events onto the active DSH session via `ctx.get("agents")?.currentInitiator()?.session.append(...)` (mirrors `dsh-web-search-deepseek`).
167
+ - **Host-logger diagnostics**: each backend routes request/outcome through lib/util/log.js to the host logger (ctx.logger), best-effort and never thrown - deliberately NOT session events: the session event vocabulary is a closed set third-party plugins must not extend (an unknown envelope type makes the whole session log unreadable).
129
168
 
130
169
  ## Architecture | 架构
131
170
 
@@ -157,10 +196,19 @@ tests/
157
196
  ## Test | 测试
158
197
 
159
198
  ```bash
160
- node --test tests/parse.test.js tests/provider.test.js
199
+ node --test tests/
161
200
  ```
162
201
 
163
- 33/33 pass.
202
+ v2.7.0 起共 76+ tests pass(`node --test "tests/*.test.js" "test/*.test.mjs"`)。
203
+
204
+ ## 权威依据与取舍 | Authoritative sources & design trade-offs
205
+
206
+ v2.7.0 缓存/熔断/历史三项的依据与刻意取舍(含查证过的反例):
207
+
208
+ 1. **熔断器(Circuit Breaker)**:出处 Michael T. Nygard《Release It!》(Pragmatic Bookshelf, 2007) 与 Microsoft Azure Architecture Center「Circuit Breaker pattern」https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker 。经典状态机是 Closed → Open → Half-Open(半开态放行少量探测请求)。**本插件刻意省略半开态**:冷却到期即视为 Closed(下一次真实搜索就是探测),失败则立即重新开窗——因为这里的"请求"是一次多后端扇出中的单个后端调用,探测请求必然真实发生且失败成本只是一次软降级,无需额外探测计数器;同时保留 fail-open 兜底(全部冷却时放行),比经典模式更保守。
209
+ 2. **缓存失效(TTL-only vs 事件驱动)**:Cloudflare「Retention vs Freshness (TTL)」https://developers.cloudflare.com/cache/concepts/retention-vs-freshness/ 与「Revalidation」https://developers.cloudflare.com/cache/concepts/revalidation/ 。搜索结果没有可靠的原站变更信号可订阅(网页索引持续变化),事件驱动失效无锚点;TTL-only + 把 TTL 做成设置项(默认 900s)+ key 纳入后端组合,是"可接受的最终一致性"。取舍:900s 内索引更新不可见——对搜索场景可接受,且用户可调低。
210
+ 3. **原子写(tmp + renameSync)**:POSIX 上 rename(2) 原子替换(Node 文档:newPath 已存在时覆盖);Windows 上 libuv 将 fs.rename 映射为 MoveFileExW(MOVEFILE_REPLACE_EXISTING)(libuv#283 https://github.com/joyent/libuv/issues/283 ,LWN 讨论 https://lwn.net/Articles/682988/ 指出 MS 文档不承诺该调用的原子性)。**结论:POSIX 双端原子;Windows 实践上等价但官方文档不背书**——因此本插件在写入端额外做了兜底:读端永远校验 JSON 完整性、坏条目按 miss 处理并删除,即使 Windows 上出现半写状态也不会返回坏数据。
211
+ 4. **搜索历史隐私**:Article 29 Working Party 意见书 WP148 https://ec.europa.eu/justice/article-29/documentation/opinion-recommendation/files/2008/wp148_en.pdf 认定查询日志(query + 时间 + 来源标识)属个人数据;EFF 六条自保建议 https://w2.eff.org/Privacy/search/searchtips.pdf 的第一条是"别在搜索词里放 PII"——说明 query 天然可能携带 PII(人名、账号、地址)。本插件的取舍:历史只存 {query, time, resultCount, backendsOk/Total}(无 IP、无 cookie、无结果 URL)、环形 50 条自动滚动即短保留、本机存储不出网、提供 historyEnabled 开关与 POST clear 端点。**未做 query 脱敏**:脱敏会破坏"查看最近搜了什么"的核心用途,且本机单用户场景下该数据本就在 DSH 会话日志里存在;若未来历史跨设备同步,脱敏/加密是上线前置条件。
164
212
 
165
213
  ## License | 许可
166
214
 
@@ -19,7 +19,7 @@ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort
19
19
  import { WebError } from "@deepseek-ai/dsh-web";
20
20
 
21
21
  /** Attribution header; bumped with the package version. */
22
- const UA = "dsh-websearch/2.1";
22
+ const UA = "dsh-websearch/2.3";
23
23
 
24
24
  /**
25
25
  * Factory: create an Anthropic-like Messages backend.
@@ -30,7 +30,6 @@ const UA = "dsh-websearch/2.1";
30
30
  * @param {string} spec.defaultModel
31
31
  * @param {string} spec.defaultApiVersion e.g. "2023-06-01"
32
32
  * @param {string} spec.toolName "web_search_20250305"
33
- * @param {string} [spec.futureToolName] optional newer tool for dual fallback
34
33
  * @returns {object} backend object { id, requiresCredential, available, search }
35
34
  */
36
35
  export function createAnthropicLikeBackend(spec) {
@@ -40,7 +39,6 @@ export function createAnthropicLikeBackend(spec) {
40
39
  defaultModel,
41
40
  defaultApiVersion,
42
41
  toolName,
43
- futureToolName,
44
42
  } = spec;
45
43
 
46
44
  return {
@@ -154,7 +152,7 @@ export function createAnthropicLikeBackend(spec) {
154
152
  }
155
153
 
156
154
  try {
157
- const result = mapAnthropicResponse(await response.json(), id, futureToolName);
155
+ const result = mapAnthropicResponse(await response.json(), id);
158
156
  hooks.recordOutcome?.({ status: "ok", sources: result.sources.length });
159
157
  return { sources: result.sources, content: undefined, truncated: false };
160
158
  } catch (err) {
@@ -174,15 +172,7 @@ export function createAnthropicLikeBackend(spec) {
174
172
  };
175
173
  }
176
174
 
177
- /**
178
- * Optional dual-tool fallback: try the newer tool first; if that tool name is
179
- * rejected by the API (HTTP error mapping is upstream of us), the provider
180
- * retries with the established fallback. Here we just expose both — the
181
- * provider loops through candidates and uses whichever result block appears.
182
- */
183
- export function tryDualToolResponse(response, id, primaryTool, fallbackTool) {
184
- return mapAnthropicResponse(response, id, primaryTool);
185
- }
175
+
186
176
 
187
177
  /**
188
178
  * Walk web_search_tool_result blocks for citeable web_search_result items,
@@ -51,8 +51,10 @@ export const braveBackend = {
51
51
  headers: {
52
52
  "X-Subscription-Token": apiKey,
53
53
  "Accept": "application/json",
54
- "User-Agent": "dsh-websearch/2.1",
54
+ "User-Agent": "dsh-websearch/2.3",
55
55
  },
56
+ redirect: "error",
57
+
56
58
  signal,
57
59
  });
58
60
  } catch (error) {
@@ -61,7 +63,7 @@ export const braveBackend = {
61
63
  }
62
64
 
63
65
  if (!response.ok) {
64
- const text = await response.text().catch(() => "");
66
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
65
67
  hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
66
68
  throw new WebError(`Brave Search API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
67
69
  }
@@ -40,7 +40,7 @@ export const ddgBackend = {
40
40
  headers: {
41
41
  "Content-Type": "application/x-www-form-urlencoded",
42
42
  "Accept": "text/html",
43
- "User-Agent": "dsh-websearch/2.1",
43
+ "User-Agent": "dsh-websearch/2.3",
44
44
  },
45
45
  body,
46
46
  ...(signal !== undefined ? { signal } : {}),
@@ -7,7 +7,7 @@
7
7
  // .../Highlights: .../ ..." separated by "\n\n---\n\n"; we split into source objects.
8
8
  // Exa is keyless — its public MCP endpoint requires no API key.
9
9
 
10
- import { mcpCall, invalidateSession } from "../util/mcp-client.js";
10
+ import { mcpCall } from "../util/mcp-client.js";
11
11
  import { WebError } from "@deepseek-ai/dsh-web";
12
12
 
13
13
  const EXA_ENDPOINT = "https://mcp.exa.ai/mcp";
@@ -41,7 +41,7 @@ export const mojeekBackend = {
41
41
  const headers = {
42
42
  "Accept": "application/json",
43
43
  "Authorization": "Bearer " + apiKey,
44
- "User-Agent": "dsh-websearch/2.1",
44
+ "User-Agent": "dsh-websearch/2.3",
45
45
  };
46
46
 
47
47
  hooks?.recordRequest?.({
@@ -55,6 +55,8 @@ export const mojeekBackend = {
55
55
  response = await fetch(url, {
56
56
  method: "GET",
57
57
  headers,
58
+ redirect: "error",
59
+
58
60
  signal,
59
61
  });
60
62
  } catch (error) {
@@ -63,7 +65,7 @@ export const mojeekBackend = {
63
65
  }
64
66
 
65
67
  if (!response.ok) {
66
- const text = await response.text().catch(() => "");
68
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
67
69
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
68
70
  throw new WebError("Mojeek API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
69
71
  }
@@ -13,7 +13,7 @@
13
13
  import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
14
14
  import { WebError } from "@deepseek-ai/dsh-web";
15
15
 
16
- const UA = "dsh-websearch/2.1";
16
+ const UA = "dsh-websearch/2.3";
17
17
 
18
18
  export const openaiBackend = {
19
19
  id: "openai",
@@ -47,7 +47,7 @@ export const openaiBackend = {
47
47
  );
48
48
  }
49
49
  const baseURL = (options.baseURL || "https://api.openai.com/v1").replace(/\/$/, "");
50
- const model = options.model || "gpt-5";
50
+ const model = options.model || "gpt-4o";
51
51
  const endpoint = `${baseURL}/responses`;
52
52
 
53
53
  const body = {
@@ -13,18 +13,15 @@
13
13
  // JSON-string with { search_id, results: [{ url, title, publish_date, excerpts:[] }] }.
14
14
  // Parallel is keyless — its public MCP endpoint requires no API key.
15
15
 
16
- import { mcpCall, invalidateSession } from "../util/mcp-client.js";
16
+ import { randomUUID } from "node:crypto";
17
+ import { mcpCall } from "../util/mcp-client.js";
17
18
  import { WebError } from "@deepseek-ai/dsh-web";
18
19
 
19
20
  const PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp";
20
21
  const PARALLEL_TOOL = "web_search";
21
22
 
22
- /** Per-process session id — stable across searches so Parallel's free-tier
23
- * rate limiter correlates calls consistently. */
24
- const SESSION_ID =
25
- "dsh-websearch-" +
26
- process.pid?.toString(36) + "-" +
27
- Math.random().toString(36).slice(2, 10);
23
+ /** Per-process session id - stable so Parallel free-tier rate limiting correlates calls. */
24
+ const SESSION_ID = "dsh-websearch-" + randomUUID().slice(0, 8);
28
25
 
29
26
  export const parallelBackend = {
30
27
  id: "parallel",
@@ -60,9 +57,26 @@ export const parallelBackend = {
60
57
  );
61
58
  }
62
59
 
63
- hooks.recordOutcome?.({ status: "ok" });
60
+ if (result?.isError === true) {
61
+
62
+
63
+ const message = String(result.content?.[0]?.text ?? "unknown");
64
+
65
+
66
+ hooks.recordOutcome?.({ status: "error", message });
67
+
68
+
69
+ throw new WebError(`parallel returned an MCP error: ${message}`, "WEB_PROVIDER_ERROR");
70
+
71
+
72
+ }
73
+
74
+
64
75
  const sources = parseParallelResult(result, request.maxResults ?? 8);
65
76
 
77
+
78
+ hooks.recordOutcome?.({ status: "ok", count: sources.length });
79
+
66
80
  if (result?.isError === true) {
67
81
  throw new WebError(
68
82
  `parallel returned an MCP error: ${String(result.content?.[0]?.text ?? "unknown")}`,
@@ -44,7 +44,7 @@ export const searxngBackend = {
44
44
 
45
45
  const headers = {
46
46
  "Accept": "application/json",
47
- "User-Agent": "dsh-websearch/2.1",
47
+ "User-Agent": "dsh-websearch/2.3",
48
48
  };
49
49
 
50
50
  if (apiKey) {
@@ -62,6 +62,8 @@ export const searxngBackend = {
62
62
  response = await fetch(url, {
63
63
  method: "GET",
64
64
  headers,
65
+ redirect: "error",
66
+
65
67
  signal,
66
68
  });
67
69
  } catch (error) {
@@ -71,8 +73,11 @@ export const searxngBackend = {
71
73
  }
72
74
 
73
75
  if (!response.ok) {
74
- const text = await response.text().catch(() => "");
76
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
75
77
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
78
+ if (response.status === 429 || response.status >= 500) {
79
+ throw new WebError("SearXNG API error (HTTP " + response.status + "): " + text + " (instance struggling? point searxngBaseURL at another public instance)", "WEB_PROVIDER_ERROR");
80
+ }
76
81
  throw new WebError("SearXNG API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
77
82
  }
78
83
 
@@ -52,9 +52,11 @@ export const serperBackend = {
52
52
  "X-API-KEY": apiKey,
53
53
  "Content-Type": "application/json",
54
54
  "Accept": "application/json",
55
- "User-Agent": "dsh-websearch/2.1",
55
+ "User-Agent": "dsh-websearch/2.3",
56
56
  },
57
57
  body: JSON.stringify(body),
58
+ redirect: "error",
59
+
58
60
  signal,
59
61
  });
60
62
  } catch (error) {
@@ -63,7 +65,7 @@ export const serperBackend = {
63
65
  }
64
66
 
65
67
  if (!response.ok) {
66
- const text = await response.text().catch(() => "");
68
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
67
69
  hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
68
70
  throw new WebError("Serper API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
69
71
  }
@@ -56,9 +56,11 @@ export const tavilyBackend = {
56
56
  "Authorization": `Bearer ${apiKey}`,
57
57
  "Content-Type": "application/json",
58
58
  "Accept": "application/json",
59
- "User-Agent": "dsh-websearch/2.1",
59
+ "User-Agent": "dsh-websearch/2.3",
60
60
  },
61
61
  body: JSON.stringify(body),
62
+ redirect: "error",
63
+
62
64
  signal,
63
65
  });
64
66
  } catch (error) {
@@ -67,7 +69,7 @@ export const tavilyBackend = {
67
69
  }
68
70
 
69
71
  if (!response.ok) {
70
- const text = await response.text().catch(() => "");
72
+ const text = await response.text().catch(() => "").then((tx) => tx.slice(0, 300));
71
73
  hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
72
74
  throw new WebError(`Tavily API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
73
75
  }
package/lib/breaker.js ADDED
@@ -0,0 +1,65 @@
1
+ // breaker.js — v2.7.0 per-backend circuit breaker (pure functions, zero deps).
2
+ //
3
+ // State lives ON the backend health entry (the same object recordBackendHealth
4
+ // in provider.js maintains), as two extra fields:
5
+ // failCount — consecutive failures (reset to 0 on any success)
6
+ // cooledUntil — epoch ms until which the backend is skipped; 0 = closed
7
+ // Breaker opens when failCount reaches threshold and stays open for
8
+ // cooldownMs. Failure modes are deliberately conservative: unknown state or
9
+ // missing fields never trip; a fully-cooled fan-out falls back to running
10
+ // every backend (fail-open) so the breaker can never make search WORSE than
11
+ // no breaker.
12
+
13
+ /** Default breaker tuning (settings can override; cooldownMs 0 = disabled). */
14
+ export const BREAKER_DEFAULTS = {
15
+ threshold: 3,
16
+ cooldownMs: 60_000,
17
+ };
18
+
19
+ /**
20
+ * Fold one backend outcome into the breaker fields of a health entry.
21
+ * Pure: returns the new fields; caller merges into the entry.
22
+ *
23
+ * @param {object|undefined} prev previous entry (may be undefined)
24
+ * @param {boolean} ok did this call succeed?
25
+ * @param {number} nowMs
26
+ * @param {{threshold?: number, cooldownMs?: number}} cfg
27
+ * @returns {{ failCount: number, cooledUntil: number }}
28
+ */
29
+ export function foldBreaker(prev, ok, nowMs, cfg = {}) {
30
+ const threshold = Math.max(1, Math.floor(Number(cfg.threshold) || BREAKER_DEFAULTS.threshold));
31
+ // cooldownMs === 0 explicitly disables the cooldown window (cooledUntil = now
32
+ // expires instantly). Only null/undefined falls back to the default — an
33
+ // explicit 0 must not silently become 60s (arch-review P2 note).
34
+ const cooldownMs = cfg.cooldownMs != null
35
+ ? Math.max(0, Math.floor(Number(cfg.cooldownMs) || 0))
36
+ : BREAKER_DEFAULTS.cooldownMs;
37
+ if (ok) return { failCount: 0, cooledUntil: 0 };
38
+ const failCount = (Number.isFinite(prev?.failCount) ? prev.failCount : 0) + 1;
39
+ if (failCount >= threshold) {
40
+ return { failCount, cooledUntil: nowMs + cooldownMs };
41
+ }
42
+ // Keep an existing open window if the backend was already open (failure
43
+ // while open extends nothing, but must not silently close it).
44
+ return { failCount, cooledUntil: Number.isFinite(prev?.cooledUntil) ? prev.cooledUntil : 0 };
45
+ }
46
+
47
+ /**
48
+ * Is the backend currently in its cooldown window?
49
+ * @param {object|undefined} entry health entry (failCount/cooledUntil optional)
50
+ * @param {number} nowMs
51
+ */
52
+ export function isCooled(entry, nowMs = Date.now()) {
53
+ const cooledUntil = entry?.cooledUntil;
54
+ return Number.isFinite(cooledUntil) && cooledUntil > nowMs;
55
+ }
56
+
57
+ /**
58
+ * Human telemetry annotation for a cooled backend, e.g. "⏸cooled 42s".
59
+ * Empty string when not cooled.
60
+ */
61
+ export function cooledAnnotation(entry, nowMs = Date.now()) {
62
+ if (!isCooled(entry, nowMs)) return "";
63
+ const restS = Math.max(0, Math.ceil((entry.cooledUntil - nowMs) / 1000));
64
+ return `⏸cooled ${restS}s`;
65
+ }
package/lib/cache.js ADDED
@@ -0,0 +1,151 @@
1
+ // cache.js — v2.7.0 disk cache for unified search results (zero dependencies).
2
+ //
3
+ // Cache key: sha256 over the canonical JSON of {query (already shaped),
4
+ // filters, maxResults} — exactly the dimensions that change a search outcome.
5
+ // Entries live as one JSON file per key under <dir>, written atomically
6
+ // (tmp file + rename) so a crash mid-write never leaves a corrupt cache.
7
+ // Eviction: when the entry count exceeds maxEntries, the oldest-by-mtime
8
+ // files are deleted first. Every fs error is swallowed by the caller-facing
9
+ // API (get/set never throw) — a broken cache must never break a search.
10
+
11
+ import { createHash } from "node:crypto";
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ renameSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ /** Defaults (overridable per instance; settings feed these from index.js). */
25
+ export const CACHE_DEFAULTS = {
26
+ ttlMs: 900_000, // 900s
27
+ maxEntries: 200,
28
+ };
29
+
30
+ /**
31
+ * Compute the deterministic cache key for a search.
32
+ * Includes the enabledBackends selection set (sorted): switching the backend
33
+ * mix must never serve a stale result produced by a different combination
34
+ * (arch-review P1-1). Pure function; exported for tests and host reuse.
35
+ */
36
+ export function cacheKeyFor({ query, filters, maxResults, backends }) {
37
+ const backendSet = Array.isArray(backends) ? [...backends].sort().join(",") : "";
38
+ const canonical = JSON.stringify({
39
+ q: typeof query === "string" ? query : "",
40
+ f: filters ?? {},
41
+ n: Number(maxResults) || 0,
42
+ b: backendSet,
43
+ });
44
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
45
+ }
46
+
47
+ /** Age of a record in whole seconds (bounded >= 0). */
48
+ function ageSeconds(record, nowMs) {
49
+ return Math.max(0, Math.floor((nowMs - (record?.time ?? 0)) / 1000));
50
+ }
51
+
52
+ /**
53
+ * Create a disk-backed result cache.
54
+ *
55
+ * @param {object} opts
56
+ * @param {string} opts.dir cache directory (created lazily)
57
+ * @param {number} [opts.ttlMs] entry TTL in ms (default 900_000)
58
+ * @param {number} [opts.maxEntries] entry cap (default 200)
59
+ * @returns {{ get, set, clear }} get/set/clear never throw
60
+ */
61
+ export function createSearchCache({ dir, ttlMs = CACHE_DEFAULTS.ttlMs, maxEntries = CACHE_DEFAULTS.maxEntries }) {
62
+ // ttlMs may be a number or () => number (settings can change at runtime).
63
+ const ttlOf = typeof ttlMs === "function" ? ttlMs : () => Number(ttlMs) || 0;
64
+ const cap = Math.max(1, Math.floor(Number(maxEntries) || CACHE_DEFAULTS.maxEntries));
65
+
66
+ const fileFor = (key) => path.join(dir, `${key}.json`);
67
+
68
+ function ensureDir() {
69
+ mkdirSync(dir, { recursive: true });
70
+ }
71
+
72
+ /**
73
+ * Atomic write: tmp sibling + rename. Atomic on POSIX (rename(2) replaces
74
+ * atomically) and on Windows via libuv's MoveFileExW(MOVEFILE_REPLACE_
75
+ * EXISTING) — see README "权威依据与取舍" for the Windows caveat.
76
+ */
77
+ function atomicWrite(file, data) {
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ writeFileSync(tmp, data, "utf8");
80
+ renameSync(tmp, file);
81
+ }
82
+
83
+ /** List current entries as [{key, file, mtime}] sorted oldest first. */
84
+ function listEntries() {
85
+ if (!existsSync(dir)) return [];
86
+ const out = [];
87
+ for (const name of readdirSync(dir)) {
88
+ if (!name.endsWith(".json") || name.includes(".tmp")) continue;
89
+ const file = path.join(dir, name);
90
+ try {
91
+ out.push({ key: name.slice(0, -5), file, mtime: statSync(file).mtimeMs });
92
+ } catch {
93
+ // raced delete — skip
94
+ }
95
+ }
96
+ return out.sort((a, b) => a.mtime - b.mtime);
97
+ }
98
+
99
+ function evict() {
100
+ const entries = listEntries();
101
+ for (let i = 0; i < entries.length - cap; i++) {
102
+ try { unlinkSync(entries[i].file); } catch { /* raced */ }
103
+ }
104
+ }
105
+
106
+ return {
107
+ /**
108
+ * Look up a cached result.
109
+ * @returns {{ value: object, age: number } | null} age in seconds; null on miss/expiry
110
+ */
111
+ get(key, nowMs = Date.now()) {
112
+ try {
113
+ const file = fileFor(key);
114
+ if (!existsSync(file)) return null;
115
+ const record = JSON.parse(readFileSync(file, "utf8"));
116
+ if (!record || typeof record !== "object" || !record.value) return null;
117
+ const age = ageSeconds(record, nowMs);
118
+ const ttl = Math.max(0, Number(ttlOf()) || 0);
119
+ if (age * 1000 > ttl) {
120
+ try { unlinkSync(file); } catch { /* raced */ }
121
+ return null;
122
+ }
123
+ return { value: record.value, age };
124
+ } catch {
125
+ return null;
126
+ }
127
+ },
128
+
129
+ /** Store a result (best-effort; fs errors are swallowed). */
130
+ set(key, value, nowMs = Date.now()) {
131
+ try {
132
+ ensureDir();
133
+ atomicWrite(fileFor(key), JSON.stringify({ key, time: nowMs, value }));
134
+ evict();
135
+ } catch {
136
+ // cache write failures are non-fatal by design
137
+ }
138
+ },
139
+
140
+ /** Drop every cached entry (best-effort). */
141
+ clear() {
142
+ try {
143
+ for (const e of listEntries()) {
144
+ try { unlinkSync(e.file); } catch { /* raced */ }
145
+ }
146
+ } catch {
147
+ // nothing to do
148
+ }
149
+ },
150
+ };
151
+ }