# Changelog

## 0.24.2

Documentation and image patch; no runtime behavior changed.

### Fixed
- **The benchmark chart no longer claims to be v0.15.0.** `docs/images/bench.png`
  was regenerated from the current offline replay and labelled v0.24.x. The
  numbers are unchanged (P@8 0.4375, R@8 0.7917, MRR@8 0.8071) because the
  ranking path did not change; the chart now says so without presenting an old
  release as the current one.
- **Baseline labels now say "current" everywhere.** README, Chinese README and
  `docs/rrf-ranking.md` still explain that the tier-aware replay protocol was
  introduced in 0.15.0, but no longer call the current baseline a "0.15.0
  baseline".
- **The chart has an editable source.** `docs/images/bench.svg` is checked in
  next to the PNG. Both files are shipped in the npm package so the relative
  image link in `docs/rrf-ranking.md` resolves there too.

**Test suite: 690 tests across 56 files** (689 pass, 1 skipped).

## 0.24.1

Documentation and metadata patch; no search or fetch behavior changed.

### Fixed
- **README and docs no longer imply macOS-only proxy discovery.** The English
  and Chinese READMEs, architecture note, and troubleshooting guide now describe
  macOS `scutil` and Windows Internet Settings as the two system-proxy sources,
  with PowerShell checks for environment variables, registry values and port
  reachability.
- **The test commands match the cross-platform script.** Docs use `npm test`
  instead of an unquoted shell glob; CI documentation now names Ubuntu, macOS
  and Windows.
- **The troubleshooting guide no longer says `lib/` is gitignored.** It is
  committed, and the guide now says so.
- **The "no version is hard-coded" claim was removed.** The ecosystem table is
  explicitly a dated snapshot, not a live leaderboard.
- **README links point at files that exist in the npm package.** The package now
  ships `docs/architecture.md`, `docs/engines.md`, `docs/plugin-settings-form.md`
  and `docs/troubleshooting.md`; the benchmark link goes to GitHub instead.
- **Package metadata and Settings copy were refreshed.** The npm description
  names Windows/macOS proxy support; keywords include `windows`, `keyless`,
  `arxiv` and `web-fetch`; the Settings title/description now names eight
  engines and 34 sources instead of Bing/AnySearch.

**Test suite: 690 tests across 56 files** (689 pass, 1 skipped); this patch does
not change runtime code.

## 0.24.0

### Added
- **Windows system-proxy detection.** When no explicit proxy or proxy
  environment variable is set, the plugin reads `ProxyEnable` / `ProxyServer`
  from the current user's Windows Internet Settings (`HKCU\...\Internet
  Settings`) with a fixed, absolute `reg.exe` probe. A bare `host:port` and the
  `http=...;https=...` map are both accepted; SOCKS-only and PAC-only settings
  are left alone rather than guessed. macOS `scutil` behavior is unchanged.
- **Windows CI.** The GitHub Actions matrix now includes `windows-latest`; the
  install, typecheck, full test suite, build and entry-point checks all pass on
  Windows, macOS and Ubuntu (Node 24).

### Fixed
- **Cross-platform test command.** `npm test` now passes a quoted glob to
  Node's test runner instead of relying on shell expansion, which cmd.exe does
  not perform.
- **Windows cache path test.** The cache-path assertion uses the platform
  temporary directory instead of a hard-coded `/tmp` path.

### Documentation
- **Windows install notes.** README, Chinese README and the quickstart document
  the identical PowerShell command, the no-native-addon requirement, automatic
  system-proxy detection, PAC-only fallback and the `%USERPROFILE%` cache path.
- **Security boundary updated.** [SECURITY.md](SECURITY.md) now lists the
  Windows registry probe alongside the macOS `scutil` probe and records its
  fixed argv, timeout and data access.

**Test suite: 690 tests across 56 files** (689 pass, 1 skipped).

## 0.23.0

### Added
- **Explicit arXiv research queries.** `web_search` and `search_many` route a
  query that names arXiv with paper/survey intent to the arXiv Atom API, in
  parallel with the ordinary fan-out. The lookup is capped at 3.5 s, never
  replaces ordinary rows when it fails, and returns stable `arxiv.org/abs/`
  URLs. `arXiv:1706.03762` resolves the paper by id.
- **Boolean arXiv queries.** A survey query becomes a quoted topic phrase plus
  `all:survey`; other multi-word queries become ANDed terms. This fixes the
  image-generation paper that used to rank above actual RAG surveys for
  `retrieval augmented generation survey arXiv`. Explicit field syntax and
  boolean expressions are passed through unchanged.
- **arXiv PDFs are readable.** A `/pdf/<id>` URL is transparently read through
  arXiv's HTML full-text view instead of failing as binary, including versioned
  URLs and old-style archive identifiers. Plain arXiv abstract pages now parse
  correctly when the whole document is wrapped in a container whose class is
  named `footer` (`div.flex-wrap-footer` on the live site).

### Fixed
- **Empty arXiv abstract pages.** The pruning pass no longer drops a wrapper
  that contains a `<main>` or `<article>` merely because its class mentions
  `footer`; site chrome inside the wrapper is still removed by its own tag and
  attributes.

### Documentation
- **Research path documented.** The README and Chinese README explain the arXiv
  routing/PDF behavior and update the test count to 687 across 56 files. The
  public 30-query follow-up now records 30/30 official hits, mean 2263 ms and
  p95 2997 ms; the older 0.22.0 follow-up remains checked in as historical
  evidence.

**Test suite: 687 tests across 56 files** (686 pass, 1 skipped).

## 0.22.0

### Changed
- **Tail-tolerant tier release.** A tier no longer waits for a contributor
  from the leading region. It releases when the result budget is filled and any
  two independent engines have returned rows. Interactive deadlines are 5 s for
  Latin-script queries and 3 s for CJK queries; `wait: 'all'` remains the
  completeness escape hatch.
- **Official release facts start in parallel.** `officialLookup()` now covers
  PostgreSQL, Rust and explicitly npm-scoped package queries. The Rust resolver
  maps the GitHub release timestamp to the canonical
  `blog.rust-lang.org/YYYY/MM/DD/Rust-X.Y.Z/` page, tries the API directly
  before the tunnel, and is capped at 2.5 s. `Rust 1.90 发布说明` now returns the
  official announcement at rank 1.
- **Optional Firecrawl key.** `firecrawlApiKey` / `FIRECRAWL_API_KEY` gives
  users a repeatable Firecrawl fallback. Keyless Firecrawl remains the default
  and its quota behavior is still recorded honestly; a client cannot repair a
  server-side keyless quota.
- **Replayable follow-up measurement.** `bench/compare30.mjs` can run one arm
  with `--only plugin`, enriches the plugin arm with the official lookup, and
  records an optional Firecrawl key. The 30-query follow-up and the five-query
  Chinese latency table are checked in under `bench/compare30/` and
  `docs/latency-and-sources.md`.

### Documentation
- **Exa architecture and papers.** `docs/latency-and-sources.md` explains why a
  hosted semantic index can answer around 1 s, what tail-tolerant release
  borrows from Dean & Barroso, and why this plugin does not need a vector
  database to fix these specific failures. It links Exa's engineering posts,
  The Tail at Scale, HNSW, Matryoshka representation learning and ColBERT.

**Test suite: 680 tests across 55 files** (679 pass, 1 skipped).

## 0.21.1

### Documentation
- **Public evidence links for the 30-query comparison.** The raw three sweeps
  and the query corpus are linked from `docs/comparison.md` through a public
  gist, so the evidence is reachable without publishing the working tree.
- **README quickstart and cross-agent paths.** The package now includes
  `docs/quickstart.md`, `docs/cross-agent.md` and the `web_tools` demo asset.

No runtime behavior changed since 0.21.0. **Test suite: 668 tests across 52 files**
(667 pass, 1 skipped).

## 0.21.0

### Added
- **A dependency-free MCP server for other agents.** The package now ships
  `dsh-web-search-mcp` (`mcp/server.mjs`) with four tools: `search`, `fetch`,
  `research` and `domain_search`. It speaks MCP over stdio, needs no DSH
  context and no API key, and is installable with
  `npx -y @fffangchenghao/dsh-web-search dsh-web-search-mcp`. Claude Code and
  Codex setup snippets are in [docs/cross-agent.md](docs/cross-agent.md).
- **A public 30-query comparison harness.** `bench/compare30.mjs` runs the same
  bilingual corpus against this plugin, keyless Firecrawl and anonymous Exa,
  recording availability, latency, unique domains, official-domain presence and
  every failure. [docs/comparison.md](docs/comparison.md) publishes the method
  and the results, including where this plugin is slower or misses.
- **Quickstart and demo assets.** [docs/quickstart.md](docs/quickstart.md) and
  `docs/images/web-tools-demo.svg` show the one-tool/eight-action surface and
  the two-minute install path.

### Fixed
- **Repeated direct 403s on IP-sensitive sources.** When a direct request is
  blocked and the tunnel succeeds, the engine now remembers the working route
  for ten minutes and tries it first next time, while keeping the alternate
  route as a fallback. A recorded Firecrawl request went from 1,874 ms (403 +
  retry) to 1,017 ms on the second call. Regression tests cover the health
  memory and the transport behavior against a real `CONNECT` proxy.
- **MCP notification handling.** Unknown notifications no longer receive a
  JSON-RPC response, and `notifications/cancelled` is ignored.

**Test suite: 668 tests across 52 files** (667 pass, 1 skipped).

## 0.20.1

### Added
- **A manual update button for deployments without `dsh-autoupdater`.** When
  the Settings page is local and npm reports a newer version, it shows
  **Install update**. The button posts to a loopback-only
  `/api/dsh-web-search/update` bridge, which calls the Host's built-in
  `pluginManager.installBundle` with exactly
  `@fffangchenghao/dsh-web-search@latest`; it never accepts a package name from
  the browser. A `link:`/`file:` checkout is refused rather than replaced, a
  deployment without a plugin manager reports that honestly, and a successful
  install says that a restart is required. This is manual, not a scheduler:
  periodic checks still belong to `dsh-autoupdater`.
- **The npm version comparison is now directional.** A local build newer than
  the registry is no longer labelled "update available"; the button appears
  only when the registry version is actually newer.
- **Update-bridge tests.** 8 new cases cover the fixed package spec, link/file
  refusal, manager absence, `ctx.get` fallback, method/loopback/origin guards,
  malformed bodies and manager failure results.

**Test suite: 664 tests across 50 files** (663 pass, 1 skipped).

## 0.20.0

### Added
- **One `web_tools` action surface.** The eight optional capabilities are now
  one model-facing tool with `action` set to `search_many`, `advanced_search`,
  `domain_search`, `platform_search`, `read_page`, `research`, `cache_search`
  or `engine_test`. This cuts the always-visible optional surface from eight
  names to one while keeping each handler's validation and rendering; the old
  names remain available with `toolSurface: legacy`. The real DSH registry
  accepts the unified schema (see `scripts/verify-tool-registration.mjs`).
- **Auto-update compatibility.** The package is documented and installed as an
  npm bundle so the profile-level `dsh-autoupdater` manages it; `link:`/`file:`
  checkouts are intentionally excluded by that updater. The Settings card shows
  the latest npm version and the running build; the plugin never hot-swaps its
  own loaded code. The desktop profile is now installed from npm (`0.20.0`), so the profile
  updater no longer classifies it as a local checkout.
- **`advanced_search` uses the official Chrome Releases feed for a windowed
  Chrome security/release query.** The feed carries the real post date that a
  search snippet often loses; the result note states how many posts fell inside
  the window and whether any title or summary mentions zero-day. A feed miss
  falls back to the previous engine path.
- **`search_many` routes explicit package-registry queries.** A query that names
  npm, PyPI or crates.io is answered from that existing domain source (one exact
  lookup per extracted package name) and falls back to ordinary web results if
  the registry returns nothing.
- **`platform_search`: community and video sources.** A new model tool queries
  Bilibili, V2EX, Reddit, YouTube, Zhihu and Juejin. It uses each site's keyless
  public endpoint where one exists and falls back to `site:<domain> <query>`
  through the normal search path. Fallback rows are filtered to the platform
  host (and aliases such as `b23.tv`, `youtu.be`, `redd.it`), so a generic page
  cannot become a platform result; one blocked platform does not empty the
  batch. Zhihu has no usable keyless API and is therefore fallback-only.
- **`cache_search`: a second retrieval over `research` bodies.** `research` now
  fetches up to 200,000 characters per page and stores the full body in an
  in-process cache through an optional `onPage` sink. `cache_search` searches
  those bodies (24 pages, 400,000 characters each, 7-day TTL) and returns the
  matching passages with the same URLs, without another network request. The
  first excerpt is query-aware too: instead of only the opening 6,000
  characters, it scores paragraph windows against the query and keeps the best
  windows plus opening context.
- **Official-source query expansion is focused, not generic.** When the first
  result set lacks a known issuer, `research` spends an unused query slot on a
  focused `site:` query for the EU AI Act, Chinese CAC, Chrome Releases, Python,
  Redis or WCAG, filters the rows to that issuer's domain, and never promotes a
  landing/home page on authority alone. W3C WCAG pages that return 403 are
  retried through `w3c.github.io`.
- **`advanced_search` drops a redundant relative-time phrase.** When the caller
  passes an explicit window, a `过去 7 天内` / `last 7 days` fragment is removed
  from the engine query but kept in the returned `query`; the structured window
  already carries that constraint.
- **`domain_search` accepts `timeRange`.** `off`/`day`/`week`/`month`/`year`
  filters parsed publication dates for that call; undated rows survive only
  when no dated row qualifies, with a `note` explaining the outcome. arXiv
  switches to `submittedDate` order and a quoted phrase when a window is set,
  so "last month" returns recent papers rather than old relevance matches.
- **Chrome Stable filtering and security summary.** A Stable-only Chrome query now removes Beta, Dev, Early Stable and Extended Stable posts from the official feed; its snippets preview the version, fix count and listed Critical/High/Medium/Low CVE counts when the post states them. An empty Stable result falls back to the ordinary engine path.

- **Bilibili rows carry play counts, a topic gate, recency/popularity controls and an alternate endpoint.** Rows show `UP主`, play count and duration when the API returns them; adjacent topics are dropped; queries that explicitly ask for recency or high play apply a bounded date filter and play-count sort; and `/search/all/v2` is tried when `/search/type` is blocked with 412. Juejin rows also surface `点赞`/`阅读`, sort by likes for a `高赞` query, and Zhihu fallback drops a broad unrelated question when at least two specific answers remain.

- **PostgreSQL version/EOL questions use the official support table.** A PostgreSQL query that asks about the current version, support or EOL now prepends the project's official versioning row (current major/minor, first release and final release/EOL), while ordinary PostgreSQL how-to queries keep the normal engine path.
- **A clause/date AI Act question gets the official Article 113 page.** `research` adds the Article 113 page directly for a question that asks when a GPAI obligation applies or which article governs it, so a Chinese-language question does not depend on the English page winning a search ranking. The page is fetched through the normal ladder and its operative date windows are excerpted.
- **Legal article excerpts target the operative clause.** Research excerpts now keep standalone article numbers and pick the late clause that combines an operative verb with a date, so an EU AI Act Article 113 pack shows `2 August 2025` instead of only the table-of-contents links.
- **Longer, date-aware research excerpts.** `research` now keeps up to 1,800 characters per page and preserves the final window when a regulation query asks when a rule takes effect, so the effective-date article is not crowded out by definitions.

- **Ecosystem comparison refreshed.** The README now carries a 2026-10-25 snapshot with GitHub stars, last-month npm downloads, test evidence and package size for the main DSH competitors; stale claims about tool counts, store status and test leadership were removed.

**Test suite: 656 tests across 49 files** (655 pass, 1 skipped). The new
  unified-tool, platform, cache, mirror and landing-page tests are included.
- **`engine_test`: one probe per engine, with a reason.** A new model tool tests
  one engine or all eight, bypasses the cache and never changes cooldown state,
  and returns `ok`, latency, result count, and a failure class (`quota`, `auth`,
  `bot-wall`, `transient`, `invalid-response`, `unknown`). The Settings page's
  **Test engines** button calls the same runner through a loopback-only
  `POST /api/dsh-web-search/engine-test` bridge, so a person can see which
  engine is slow or blocked without reading a failure log.
- **`fallbackOn` and a result `Note`.** A primary failure now carries a
  structured `FailureKind`; the fallback tier runs only for the classes enabled
  in `fallbackOn`. The default keeps every class enabled, so the old
  always-fall-back behaviour is unchanged. When policy skips or uses the
  fallback, the result says so in a short `note` (also rendered by
  `search_many`).
- **`blockedDomains`.** A comma-separated blocklist applied to merged and cached
  results; a subdomain matches its parent. Empty by default, so it cannot change
  existing searches.
- **`safeSearch`.** `off` (default, engine default), `moderate` or `strict`.
  Forwarded as `adlt` to Bing and `kp` to DuckDuckGo; other engines are left
  untouched.
- **`advanced_search`: a per-call date window.** One query can ask for `day`,
  `week`, `month` or `year` without changing the global Settings value; `off`
  disables the window for that call and omitting it keeps the setting. It shares
  the engine, cache, cooldown and failure path with `web_search`, and falls back
  to `web_search_advanced` if the name is taken.
- **An explicit untrusted-content boundary.** Every plugin-owned tool wraps its
  web-derived render in `<untrusted-web-content>...</untrusted-web-content>`,
  strips forged boundary tags from the payload, and a short `systemPrompt`
  section tells the model those contents are data, not instructions. The host's
  own `web_search`/`web_fetch` notice is left in place.

### Changed
- **`research` now prefers authoritative sources on equal relevance.** A new
  authority signal recognises government/academic TLDs, standards bodies and
  well-known official documentation hosts, and uses documentation/legal/release
  paths as a small boost. A credible official page that matches the query leads
  the pack even when a generic page repeats more query words; relevance still
  gates out official pages that match none of it. The signal is structural, not
  a query-specific allowlist. Three regression tests.
- The retry-policy code now records HTTP `status` on failures and classifies
  them, but cooldown timing and retryability are unchanged.
- Cache keys include a non-default `safeSearch`, a non-default `fallbackOn`
  and a non-empty `blockedDomains`; the default paths keep their old keys, and
  removing a block cannot reuse a filtered entry.

### Fixed
- **`research` rejects bot challenges and login redirects.** A challenge page can
  be well over the 160-character floor (Anubis/Cloudflare shells, for example),
  and a redirect can still carry a readable login URL. Both are now reported as
  failed pages with a reason, instead of being quoted as evidence. Two
  regression tests.
- **npm publish dates are publish dates.** `domain_search` read the npm search
  index's `updated` field, which moves when the index refreshes; it now reads
  `package.date`, the version's actual publish time, and keeps an undated
  package undated instead of inventing a date. One regression test.
- **`advanced_search` says when a window is not proven.** If a caller asks for
  `day`/`week`/`month`/`year` and some results have no publication date, the
  result carries a note naming the count, instead of letting undated rows look
  like they passed the filter. One regression test.
- **Domain rows are readable and carry the metadata the API already returns.**
  Stack Exchange entities (`-&gt;`, `&#39;`) are decoded; Hacker News rows show
  points and comment counts; GitHub rows show the SPDX license and stars; PyPI
  rows show `Requires-Python`. These are presentation-only additions on the
  existing `Source` shape.
- **`research` rejects shells and boilerplate instead of citing them.** A fetch
  that comes back as a raw HTML document, or as fewer than 160 characters of
  text, is now reported as a failed page with a reason rather than quoted as
  evidence. Two regression tests.
- **Long RFC-style pages keep the document, not one appendix.** The reading-root
  heuristics could pick a code-heavy ABNF appendix (RFC 9110 returned only the
  collected ABNF) or one dense section of a long document with no
  `<article>`/`<main>`. A code-dominated container is now skipped, and a single
  section is trusted only when it holds at least half the page's text. Two
  regression tests; live RFC 9110 now converts to 603,506 characters of
  document text instead of an ABNF-only excerpt.

### Verification
- 579 tests / 41 files (578 pass, 1 skip); `npm run typecheck` clean.
- Replay bench unchanged: 20 queries P@8 `0.4375`, R@8 `0.7917`, MRR@8
  `0.8071`; long corpus `0.4500 / 1.0000 / 0.7667`.
- No publish yet: the version is bumped for the prepared package, not pushed to
  npm.

## 0.19.3

### Fixed
- **Firecrawl works again.** It was marked `viaProxy`, and the keyless endpoint
  rejects the tunnel's exit IP (`HTTP 403: Unfortunately, your IP address looks
  like a VPN or proxy`). A direct request still answers, so Firecrawl is now
  direct-first; the transport keeps its tunnel retry for networks where direct
  is blocked. Verified live: `hostIds: ['firecrawl']` returns Kubernetes
  Service rows again. One regression test pins the direct-first route.

## 0.19.2

### Fixed
- **`web_fetch` no longer returns only a page's footer.** The reading-root picker
  preferred the densest container, and a short comment form can be far denser
  than an article wrapped in `<div>` markup. On PostgreSQL's documentation that
  returned 307 characters (`Submit correction…`) instead of the chapter. The
  picker now falls back to the largest container that holds a real share of the
  page text, and then to the body, before accepting a fragment. The same URL now
  returns the chapter introduction and body (6,078 characters in the text
  format); GitHub and PyTorch pages keep their content. One regression test.

## 0.19.1

### Changed
- **The core-word retry now runs only when the first answer is unusable.**
  `transport` checks the original rows against the relevance gate before
  spending a second request. Queries whose `cn.bing` answer already has a
  relevant row (`pip 安装 requests 库`, `React useEffect 用法`,
  `PostgreSQL 逻辑复制 配置`) keep their original latency; the dictionary-page
  failures (`东数西算 八个国家枢纽节点 定位`, `如何配置 postgres 逻辑复制`) still
  retry and recover. Live comparison for the three working queries: rows and
  expected-domain hits unchanged, p50 latency back to the pre-variant value
  (React 2.8 s → 0.6 s in the sample).
- **Regression evidence for 0.19.0/0.19.1.** Replaying the 20-query corpus
  against the 0.15.0 result shows delta 0 on P@8, R@8, MRR@8, zero-hit and every
  category/language slice; the five-query long corpus is unchanged at
  `0.4500 / 1.0000 / 0.7667`. Only 3 of the 20 baseline queries trigger a
  variant at all (`zh-postgres-logical`, `zh-pip-requests`,
  `zh-react-useeffect`), and after the guard none of them does.

## 0.19.0

### Added
- **Core-word retry for whole-phrase engines.** `cn.bing` and 360 Search match a
  long Chinese query as one phrase and fall back to dictionary pages for its
  first character when the phrase is unknown. `src/variants.ts` derives at most
  one alternate query — the technical segments of a mixed query
  (`如何配置 postgres 逻辑复制` → `postgres 逻辑复制`), or the first content
  segment of a pure CJK query (`东数西算 八个国家枢纽节点 定位` → `东数西算`) —
  and the transport asks it once, merging the rows after the original list.
  Measured on the policy query, `cn.bing` went from zero usable rows to the NDRC
  article, the gov.cn implementation opinion, and the ncsti overview; the fused
  list is now eight relevant rows.
- **A five-query long-Chinese diagnostic corpus.** `bench/queries-long.json`
  covers a policy question, a mixed PostgreSQL question, and Python / Kubernetes
  / React release-doc questions; fixtures live in `bench/fixtures-long/`.
  `scripts/bench.mjs` grew `--queries` and `--fixtures` to record and evaluate it
  without touching the 20-query baseline. Result: P@8 `0.4500`, R@8 `1.0000`,
  MRR@8 `0.7667`, zero hits 0.

### Changed
- Two extra test files (`variants`, `variant-fetch`) and 5 tests; 498 cases
  total (497 pass, one environment skip).

## 0.18.0

### Fixed
- **Chinese queries no longer drag dictionary pages into the answer.** A long or
  mixed query (`东数西算 八个国家枢纽节点 定位`, `如何配置 postgres 逻辑复制`)
  made `cn.bing` answer with rows about a single character — `东（汉语文字）`,
  `如何（汉语词语）`, a company named 李在东 — and those rows collected votes in
  the fusion. A CJK relevance gate now splits the query into character bigrams
  and keeps a row only when its title, snippet or URL accumulates two units of
  overlap; an ASCII word counts double, so a PostgreSQL page survives a mixed
  query on `postgres` alone. An engine whose entire list is filtered is reported
  as `<id>: no relevant results`, which lets the fallback tier run instead of
  returning the junk.
- **Chinese official domains earn the authority boost.** `gov.cn`, `edu.cn`,
  `news.cn`, `people.com.cn`, `xinhuanet.com`, `ncsti.gov.cn` and `cas.cn` join
  the default authority list, so a ministry page outranks an SEO repost or a
  Baidu Baike entry at the same fused position.

### Changed
- `RankOptions.query` carries the query into fusion; `scripts/bench.mjs` passes
  the fixture's query, so offline replay applies the same gate production uses.
- The 20-query benchmark is byte-for-byte unchanged (`0.4375 / 0.7917 / 0.8071`):
  the recorded fixtures do not contain the dictionary-page failure mode.

### Tests
- 493 cases (492 pass, one environment skip), including the new
  `test/relevance.test.ts` coverage for dropped dictionary/company rows, URL-slug
  matches, English passthrough and the mixed-query ASCII weight.

## 0.17.0

### Fixed
- **Moved documentation URLs now arrive as pages, not redirect stubs.** PyTorch's
  `stable` docs answer an obsolete path with HTTP 200 and a body that only says
  "Redirecting…"; the fetch ladder rendered that stub as if it were the page.
  Both the direct and tunnelled rungs now follow up to two HTML-level redirects
  (`<meta http-equiv="refresh">`, or a redirect-titled page's
  `location.replace`/canonical link) inside the same deadline.
- **Bursts of searches no longer trip Exa's rate limit.** Five parallel
  `search_many` queries made the anonymous Exa endpoint return 429 on four of
  them. Engines can now declare a process-wide concurrency gate (`exa` and
  `firecrawl` two at a time, `anysearch` two), held for the whole
  direct-then-tunnel attempt and cancelled with the caller.
- **`research` fills its page quota.** A candidate that failed to load used to
  leave the pack one page short while a later candidate was never tried; workers
  now pull the next candidate until `maxSources` pages succeed. Candidates are
  also ranked by query-token overlap first, so a dictionary entry for the single
  character `东` no longer displaces an article about `东数西算`, and a failed
  URL is no longer listed under "found but not fetched".
- **Failure reasons are deduplicated**, so a page that fails the same renderer
  check three times no longer repeats the sentence three times in one message.
- **Markdown no longer carries Sphinx-style permalink noise**: a link whose
  whole text is `¶`, `#` or `§` is dropped, and a link whose text is its own URL
  is emitted as an autolink.

### Tests
- 487 cases (486 pass, one environment skip): new coverage for redirect
  detection and loop bounds, the engine gate, research quota filling and
  relevance ranking, and the cleaned Markdown links.

## 0.16.1

### Fixed
- The Settings page never showed which engines exist: the allow-list field now
  names all eight (four primary: `anysearch`, `exa`, `ddg-html`, `cn.bing`;
  four fallback: `firecrawl`, `www.bing`, `ddg-lite`, `so`) and the group label
  says "8 total". The page description now also lists the model-facing tools,
  including `research`, which is a tool rather than a setting.

## 0.16.0

### Added
- **Key rotation.** `anysearchApiKey` now accepts several keys separated by
  commas, spaces or newlines. They are used round-robin; a 401 retires the key
  for a day, a 429 rests it for 60 s doubling to 30 min, a 403 for ten minutes,
  and a success clears its strikes. A throttled or rejected key retries the same
  request on the next key once, in both search and page extraction. Pools are
  cached per Settings value, so editing the field starts clean.
- **Settings page polish.** A sticky jump bar scrolls to any of the six groups,
  and a line under the title compares the installed version with npm's latest
  (hidden when the registry is unreachable rather than guessed).
- **Portable skill.** `skills/dsh-web-search/SKILL.md` states when to search,
  fetch, or use `research`, and how to cite; it can be copied into any harness
  that reads `SKILL.md` from disk.

### Changed
- The AnySearch user agent reports `dsh-web-search/0.15`.
- Both READMEs document the rotation, the jump bar and the skill file.

## 0.15.0

### Added
- **`research` tool.** One call runs up to three searches, opens the leading
  pages through the same fetch ladder as `web_fetch`, and returns an evidence
  pack in which every excerpt sits under its URL. Pages that fail to load are
  reported by URL and reason; the search rows still arrive. Registers as
  `web_research` when another plugin owns the name, and uses its own
  `http-proxy-research` fetch provider so it cannot disturb the host renderer
  cache.
- **Two keyless engines.** `exa` (anonymous MCP semantic search) joins the
  primary tier; `firecrawl` (key-free search API) ships as the fallback.
- **26 new `domain_search` sources** (34 in total): GitLab, Codeberg, Docker
  Hub, Maven, NuGet, RubyGems, Packagist, pub.dev, Hugging Face models and
  datasets, Open VSX, Terraform modules and providers, WordPress, JetBrains,
  OpenAlex, Crossref, Europe PMC, UniProt, ClinicalTrials.gov, NVD, CoinGecko,
  Internet Archive, Wikidata, Nominatim, GitHub issues. All are opt-in through
  `sources`; the default remains the historical eight so GitHub's anonymous
  10-requests-per-minute limit is not spent by every call.

### Changed
- The benchmark replay is **tier-aware**: a fallback engine only contributes
  when every recorded primary came back empty, matching what `search()` does in
  production. The baseline was restated accordingly (MRR@8 0.8071, P@8 0.4375,
  zero-hit queries 0) and the 81-configuration sweep was re-run; numbers before
  0.15.0 used a different replay and are not directly comparable.
- Engine tiers were decided by measurement on one recorded fixture set: previous
  primaries 0.7738 MRR, +Exa 0.8071, +Firecrawl 0.7208, +both 0.7542.
- `SECURITY.md` lists the two new providers and all 34 vertical hosts.

### Fixed
- See 0.13.11: the 25 s fetch budget is per request instead of per plugin mount.

## 0.14.0

Distribution release. No runtime behaviour changed; the search and fetch paths are
the same as 0.13.11.

### Added
- A banner, a pipeline diagram, a benchmark chart and a real terminal capture in
  `docs/images/`, referenced by both READMEs through absolute raw URLs so they
  render on GitHub and npm alike.
- `docs/rrf-ranking.md`: how the fusion works, why each default exists, the
  baseline numbers, the 81-configuration sweep with its confidence interval, and
  the limits the benchmark makes visible.
- A "Why this one" section and an honest comparison table in both READMEs,
  including what the larger competing plugins do better.

### Changed
- The `files` list now ships `docs/rrf-ranking.md`.

## 0.13.11

### Fixed
- **`web_fetch` failed on every request more than 25 s after a restart**
  ("no time left in the fetch budget" from the direct, tunnel and extract
  rungs). The 25 s ladder budget was captured in the provider factory, which
  runs once when the plugin mounts, so the deadline had already passed for any
  later request. The budget now starts inside each `fetch()` call; a regression
  test builds a provider, waits 1.2 s, and fetches twice.
- The budget test that was supposed to prove the ladder cap had `useProxy:
  false`, so it never reached the tunnel rung. It now asserts the rung after the
  first timeout refuses to start.

## 0.13.10

### Security
- The macOS system-proxy probe now spawns `/usr/sbin/scutil` by absolute path
  instead of resolving `scutil` through `PATH`, so a hijacked `PATH` entry cannot
  receive that spawn. This also makes the process list in `SECURITY.md` exact.

## 0.13.9

### Fixed
- **`SECURITY.md` wrongly claimed the plugin spawns nothing.** It does: on macOS
  the system-proxy probe runs `scutil --proxy`. The page now lists that single
  process, the renderer's headless browser, and the `shell` return field that
  the store's scanner had flagged as a shell-mode call site.
- **The proxy probe had no deadline.** A wedged `scutil` could hold a search
  open; it is now capped at 2 s and the child is killed on expiry.
- **The store's fixed-source scanner read an internal field name as a
  capability.** `RenderReport.shell` (a boolean: "this page needs rendering") is
  now `shellPage`, so `shell: true` no longer matches the scanner's shell-mode
  pattern. Behaviour is unchanged; the store's capability list should now
  contain only real signals.

## 0.13.8

### Fixed
- **DSH STORE could not scan the runtime artifacts.** `lib/` was gitignored, so
  the fixed commit's tree contained no `lib/index.js` and no `lib/index.d.ts`;
  the store blocked the catalog entry with "runtime artifact is missing from the
  distributable surface" and "fixed-source scan is incomplete". The compiled
  output is now committed (40 files, 468 KiB) and stays inside the `files`
  surface, so the tree the store scans is the tree that runs.
- **Compatibility was not declared.** `package.json` now states the ranges it
  was actually exercised on — `engines.node: ">=22.18.0"` and
  `engines.dsh: ">=0.2.1-rc.2 <0.3.0"` — plus an exact
  `dsh.compatibility.dshReleases` record for `0.2.1-rc.2`. Only that version is
  declared, because only that version was run: the other two releases in the
  current latest-three window stay `unknown` instead of being claimed.

### Added
- `SECURITY.md`: dependencies, every network destination, credentials, the one
  file the plugin writes and its bounds, the time and size limits, and what the
  plugin deliberately never does.
- `docs/store-evidence.md`: the disposable-profile install / uninstall /
  rollback transcript and the live-host mount evidence behind the declared
  `dshOperations`.

## 0.13.7

### Fixed
- **A blank query burned seven seconds and six requests.** `search('')` and
  `search('   ')` reached every engine, each failed on its own, and the caller
  got an empty list. Validation now rejects it in under a millisecond with
  `SEARCH_INVALID`.
- **A PDF arrived as binary noise.** `kindOf()` treated every non-HTML response
  as text, so `application/pdf` came back as 20 000 characters of PDF stream and
  looked like a successful fetch. Binary content types now fail with
  `SEARCH_UNSUPPORTED` and name the type.
- **A JavaScript shell was a silent success.** `https://github.com/` and
  `https://excalidraw.com/` returned HTTP 200 with zero readable characters, so
  the caller saw an empty page with no explanation. A 200 with no text now
  fails with `SEARCH_EMPTY` and states why, including "no renderer installed".
  Non-2xx responses keep their old behaviour: the status line plus the body.
- **A layout `<main>` could throw away the article.** Extraction trusted any
  non-empty landmark, so a page whose `<main>` held a breadcrumb produced four
  characters while the prose sat in a sibling container. A landmark now has to
  keep a real share of the page's text, and the body is the last resort.
- **The fetch ladder stacked deadlines.** Direct (5 s) + tunnel (15 s) + render
  + extraction meant a dead host could hold a caller for half a minute. The
  whole ladder now shares a 25-second budget and later rungs get only the
  remainder.

### Added
- `scripts/sweep.mjs` sweeps the ranking knobs over the recorded corpus and
  prints a paired-bootstrap interval against the defaults. `bench/README.md`
  records the result: the best configuration beats the defaults by +0.044 MRR
  with an interval of [−0.004, +0.123], so **the defaults are unchanged**. The
  same page documents live `wait`-policy timings (`auto` keeps the most distinct
  domains).

## 0.13.6

### Fixed
- **The browser half registered under the old nickname.** The Host matches a
  client bundle by package name, so `__ModuleLoader__.load({ id:
  'dsh-web-search-bing' })` was rejected with "loaded without registering
  @fffangchenghao/dsh-web-search via __ModuleLoader__.load" and the Settings
  page never appeared. The id is the package name now, and a test evaluates the
  bundle against a stub loader and fails if the two ever diverge.

## 0.13.5

### Documentation
- The READMEs no longer repeat an exact test count, for the same reason they no
  longer repeat a version number: every added test made the sentence false.

## 0.13.4

### Fixed
- **The bundle overlay could not be parsed, so the plugin never loaded.** YAML
  reserves a leading `@`, and the overlay named the package as
  `name: @fffangchenghao/dsh-web-search` without quotes; the Host reported
  "bad indentation of a mapping entry" and dropped the entry. Every release from
  0.11.0 through 0.13.3 carried it. The name is quoted now, the README snippets
  are corrected, and a test fails if a bare scoped name returns — a copy-pasted
  snippet would break in exactly the same way.

## 0.13.3

### Documentation
- Both READMEs no longer claim that `fetchFormat` and `fetchMaxLength` need a
  restart. They are resolved per request; only `cachePersist`, `cacheFile`,
  `proxy` and `timeoutMs` are fixed when the plugin mounts.

## 0.13.2

### Documentation
- The READMEs point at npm for the current version instead of repeating a number
  that goes stale the moment a release is cut.

## 0.13.1

### Documentation
- `read_page`'s page cache is keyed by URL, output format and the fetch settings
  that shape the result — including the AnySearch key. The READMEs now say so,
  and the test count matches what the suite actually reports.

## 0.13.0

### Added
- **Optional JS rendering.** A page whose HTML is an empty shell can be rendered
  by a real browser — but only if `playwright` or `playwright-core` is already
  installed. Nothing is bundled, nothing is imported at load time, and a missing
  module is a silent fallback, not an error. `renderJs` picks `auto` (render only
  suspected shells), `always` or `off`; `renderTimeoutMs` bounds the attempt. A
  render that fails, times out or is aborted returns the earlier rung's result.
- **`read_page`, for pages longer than one answer.** The host fetch request
  carries only a url, so `web_fetch` can truncate but never continue. This tool
  takes `offset` and returns `nextOffset`, and consecutive chunks concatenate to
  exactly the full converted page — verified live against a long documentation
  page. Pages are fetched once and sliced, so continuing does not download again.
- **A retrieval quality benchmark.** Twenty queries (ten Chinese, ten English)
  across documentation, releases, packages and news, each with expected domains.
  `scripts/bench.mjs` records real engine output once, then replays it offline so
  a ranking change is a number, not an opinion. Metrics: precision@8, recall@8,
  MRR@8, per-engine contribution and consensus. Baseline 0.3625 / 0.7417 / 0.7600.

### Fixed
- An anti-bot page served with HTTP 200 was parsed as "zero results", which
  removed an engine silently while keeping its slot reserved and never engaging
  its cooldown. Such pages now report `SEARCH_BLOCKED`, and 360 — walled from
  this network through both the direct and tunnel paths — moved to the fallback
  tier so it no longer slows every search.

### Measured, not assumed
- `rrfConstant` 40 scores a higher MRR (0.7767) than the default 60 (0.7600) and
  a lower precision@8 (0.3500 vs 0.3625); `rrfConstant` 120 flips that trade in
  the other direction. Twenty queries and one-to-four-hit gaps are not enough to
  justify changing a default, so 60 stays and the numbers are in `bench/`.
- Dropping authority weighting costs 0.08 precision@8 and breaks Chinese
  results, which is why it remains on by default.

## 0.13.0

### Added
- **Optional JS rendering.** A page whose HTML is an empty shell can be rendered
  by a real browser — but only if `playwright` or `playwright-core` is already
  installed. Nothing is bundled, nothing is imported at load time, and a missing
  module is a silent fallback, not an error. `renderJs` picks `auto` (render only
  suspected shells), `always` or `off`; `renderTimeoutMs` bounds the attempt. A
  render that fails, times out or is aborted returns the earlier rung's result.
- **A retrieval quality benchmark.** Twenty queries (ten Chinese, ten English)
  across documentation, releases, packages and news, each with expected domains.
  `scripts/bench.mjs` records real engine output once, then replays it offline so
  a ranking change is a number, not an opinion. Metrics: precision@8, recall@8,
  MRR@8, per-engine contribution and consensus. Baseline 0.3625 / 0.7417 / 0.7600.

### Fixed
- An anti-bot page served with HTTP 200 was parsed as "zero results", which
  removed an engine silently while keeping its slot reserved and never engaging
  its cooldown. Such pages now report `SEARCH_BLOCKED`, and 360 — walled from
  this network through both the direct and tunnel paths — moved to the fallback
  tier so it no longer slows every search.

## 0.12.0

### Added
- **Fetched pages come back as article Markdown**, not raw markup. Extraction
  picks the article root, prunes page furniture, renders headings, lists, code
  fences, tables, entities and absolute links, and cuts at `fetchMaxLength` with
  a notice naming the original length. `fetchFormat` can return plain text.
- **`search_many`** answers one to five queries in a single call with a
  concurrency cap of three and per-query error isolation.
- **The cache survives a restart.** A versioned snapshot keeps summaries only,
  is written atomically, caps itself at 4 MiB with LRU eviction, and treats a
  corrupt or unreadable file as an empty cache. `cachePersist` and `cacheFile`
  control it.

### Fixed
- `cacheTtlMs`, `cacheEntries`, `cachePersist` and `cacheFile` are read live, so
  a Settings edit takes effect without a remount, like every other volatile
  field.

- `fetchFormat` / `fetchMaxLength` are resolved per request, so the Settings page
  no longer needs a reload for them either; only the cache file path and the
  persist switch are read at mount.

### Known limits
- The host fetch request carries only a url, so a page cannot be resumed at an
  offset: raise `fetchMaxLength` or fetch a narrower page.
- Two processes writing the same cache file is last-writer-wins.

## 0.11.0

### Added
- **`domain_search`, a second tool for the sources a web search cannot reach.**
  Eight keyless adapters — GitHub, npm, PyPI, arXiv, Wikipedia, Hacker News,
  Stack Exchange, crates.io — each with its own timeout, each failing alone:
  one dead source never empties the answer, and its name comes back in
  `failures`.
- **`timeRange`, and publication dates behind it.** Parsers now read dates from
  Bing, 360, the DuckDuckGo timestamp and the AnySearch content trailer;
  DuckDuckGo receives the window as `df=`, the rest are filtered locally, and
  `freshnessBoost` / `freshnessDays` finally score something real instead of
  sitting on a missing field.

### Changed
- Undated results no longer survive a time window by default. A window keeps the
  dated rows inside it; only when nothing dated falls inside does the undated
  list come back, so "the last day" is not answered with pages of unknown age.
- The Settings card has twenty controls in four groups, including a new
  dedicated-search group.

### Known limits
- PyPI publishes no search API, so its source matches the exact package name
  taken from the first word of the query.
- Wikipedia answers in one language: Chinese/Japanese/Korean questions use zh,
  everything else en.
- Bing and 360 accept time-filter parameters that the pages display as selected
  while returning the same results, so those two are filtered locally.

## Unreleased

### Added
- **`domain_search`: key-free vertical search for packages, papers, code, Q&A
  and reference material.** Eight credential-free adapters — GitHub
  repositories, npm, PyPI (exact package name, since PyPI has no public search
  API), arXiv, Wikipedia (zh for CJK queries, en otherwise), Hacker News,
  Stack Exchange and crates.io — run concurrently, interleave by rank and merge
  into the same `{ url, title, snippet, publishedAt }` shape `web_search`
  returns. One source failing, timing out or answering HTTP 500 is reported as
  a failure line and never blocks the others; every request carries a
  User-Agent and its own deadline.
- Three settings fields, all `.volatile()`: `domainSources` (enabled adapters),
  `domainLimit` (results per source, default 5) and `domainTimeoutMs` (per-source
  deadline, default 8000 ms). The dedicated-sources group is editable from the
  Settings page.
- The tool registers through the host's real `ToolRuntime.register(definition)`
  API, read with `ctx.get('tools')` rather than `inject` so a deployment without
  a tool registry still mounts the plugin and keeps `web_search`. If another
  plugin owns `domain_search`, it registers as `web_domain_search`.
- `scripts/verify-tool-registration.mjs` proves the definition against a real
  `ToolRuntime`; `scripts/smoke-verticals.mjs` is the live, non-CI smoke.
- Offline fixtures and tests for all eight parsers: normal and empty payloads,
  HTTP 500, timeout on an injected fetch, and single-source failure isolation.

## 0.10.0

### Added
- **Both READMEs are rewritten for people, not for compilers.** A worked
  before/after scene, a concepts section (what a model knows, what an agent is,
  `web_search` versus `web_fetch` with a library analogy, messages and sessions,
  providers, bundles), twelve capability lines, eight FAQ entries, and the
  technical material moved into an appendix.
- Every Settings control carries a plain-language label and a one-line hint; the
  hint line existed in the card but no dictionary entry ever filled it, and the
  AnySearch fields had no label at all.

### Fixed
- The two array fields no longer claim to be read-only: they are editable from
  the card as comma separated values.

## 0.9.1

### Fixed
- **The AnySearch fields were missing from the card.** The client renders the
  controls it knows about from its own field list, and `anysearchApiKey` /
  `anysearchDomain` were never added there, so the API key had no entry — the
  form showed 14 controls where the schema has 16.
- `max_results` asked AnySearch for 20 while the service caps it at 10 and
  silently returns 10, so the constant now states the real ceiling.

### Changed
- Every configuration description is in Chinese, and the API key field says what
  an empty value means: anonymous access at a lower rate limit.
- New [docs/plugin-settings-form.md](docs/plugin-settings-form.md): the four-layer
  chain that decides whether a plugin's configuration reaches Settings, with
  file:line evidence and the four wrong diagnoses this plugin went through.

## 0.9.0

### Added
- **The settings form works.** All sixteen fields are live-editable from
  Settings, because each one is marked `.volatile()` and the plugin reads the
  current value out of the holder the schema hands it.

### Fixed
- **`resolveConfig` validated an already-parsed config.** The Loader validates
  a row's config once and hands `apply` the parsed result, in which every
  volatile field is a `{ get }` holder. Running that parsed value back through
  `Config()` re-validated the holders as values and threw
  `$.timeoutMs expected number but got [object Object]`, so the plugin died
  inside `apply` — which the Host reports as "1 entry did not activate". The
  previous three attempts all failed on this same line, which is why marking
  fields, attaching `Config` to the runtime and re-installing each looked
  plausible and each changed nothing.
- Search re-reads the configuration on every call, so a volatile-only change
  from Settings takes effect without remounting the plugin.

All notable changes to `dsh-web-search-bing` are recorded here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Version dates were not recorded in this repository's history, so the entries
below are ordered but undated; only the ordering is authoritative.

## 0.8.0 (unreleased)

The fetch half, and AnySearch's paid-tier capabilities.

### Added

- **A fetch provider (`http-proxy`), so `web_fetch` works on a restricted
  network.** Measured here: a direct request to `github.com` or
  `html.duckduckgo.com` times out while the same URL answers through the local
  tunnel. The provider tries a direct request first, falls back to the tunnel
  the search side already resolves, and finally — when an AnySearch key is
  configured — to AnySearch's extraction endpoint, which renders the page
  server-side and so also rescues a page that answers 403 to a bare request.
  Select it with `fetchProvider: http-proxy` in the `web` service config.
- `anysearchApiKey`: sent as a Bearer token to `api.anysearch.com` only. It
  raises the rate limit and unlocks extraction; empty keeps anonymous access.
  Keep it in the profile, not in the bundle: the shipped default is empty.
- `anysearchDomain`: route the AnySearch engine at one of its vertical indexes
  (`finance`, `academic`, `security`, `legal`, `code`). A dotted value such as
  `finance.us_stock` selects the sub-domain.
- 9 tests for the fetch ladder and for the credential/domain resolution.

### Fixed

- **The settings page rendered nothing: the Host never saw the schema.**
  `dsh-settings` reads a plugin's schema from `entry.fiber.runtime.Config`, and
  for a function-style plugin the runtime *is* the `apply` function, so a named
  `Config` export was unreachable: the settings document dropped the row before
  it looked at the schema at all. The module now exports the plugin as
  `Object.assign(apply, { Config })`, which is what object-style plugins get from
  `static Config`. `test/plugin-entry.test.ts` pins the shape.
- **The settings page rendered nothing: the configuration was not marked
  live-editable.** `dsh-settings` keeps a namespace only when
  `volatileForm(schema)` yields a form, and that helper keeps a namespace when a
  field carries `meta.volatile`. None of the sixteen fields did, so the Host
  dropped the row before the client ever asked for it — the plugin was loaded,
  its schema was valid, and Settings still had nothing to show. Every field now
  ends in `.volatile()`; `test/config-live.test.ts` restates the Host's filter
  and fails if the mark is dropped again.
- **The settings page rendered nothing.** Two layers were wrong. The client
  declared `slots` and `locale` but not `configForms`, so Cordis never injected
  the service; and even with the service present, the page read its form once at
  mount, while the Host projects a row's settings namespace asynchronously. The
  page now subscribes to the settings mirror, derives the namespace from the
  projection the Host actually serves (patch id, entry id, package name, then a
  suffix match), and reads the controller for that namespace. When the service
  is present but no served namespace matches, the page names the namespaces it
  does see instead of showing a dead end.

## 0.7.0 (unreleased)

The TypeScript rewrite. Behaviour is meant to be unchanged where it was
measured, and stricter where it was previously guesswork.

### Added

- **Reciprocal rank fusion** (`src/rank.ts`): weight shapes *admission*, not
  score. The per-engine lists are walked in a weighted round robin (weight 2
  takes two consecutive rows per round, so it owns roughly twice the slots), and
  the merged walk gives each candidate a 1-based `position`; a source then scores
  `votes / (rrfConstant + position)`, where `votes` is the number of distinct
  engines that returned it, so agreement compounds while one engine's weight does
  not. Authority (`authorityDomains`, `authorityBoost`) and freshness
  (`freshnessBoost`, `freshnessDays`) multiply that score. Ties break
  deterministically on engine index, rank, then URL. Measured with weights 2/1/1
  over lists of 10/10/4 at `limit: 8`, admission splits 4/2/2.
- **TTL + LRU result cache** (`src/cache.ts`): entries expire after
  `cacheTtlMs` (default 10 minutes) and the least recently used entry is evicted
  past `cacheEntries` (default 100). Keys normalise case and whitespace, and are
  sensitive to `limit`, `wait`, lead region and the participating engine set.
- **Retry-After aware cooldown** (`src/health.ts`): the service's own wait hint
  wins over the guessed duration; otherwise the cooldown doubles per consecutive
  failure from `baseCooldownMs` up to `maxCooldownMs`. `SEARCH_PARSE` and
  `SEARCH_ABORTED` never cool an engine down.
- **Typed configuration schema** (`src/config.ts`, schemastery): the loader
  validates the mounted config before `apply` runs, so a bad value fails loudly
  with its path instead of silently using a default.
- **Fixture-based parser tests** (`test/fixtures/`, `test/parsers.test.ts`):
  captured responses for DuckDuckGo HTML, Bing, 360 and AnySearch, plus the six
  parser regressions (Bing `u=a1` tracker, DuckDuckGo `uddg`, single-quoted
  `class` attribute, 360 `data-mdurl`, AnySearch `code !== 0`, echoed-query
  check).
- **CI** (`.github/workflows/ci.yml`): Node 24 on Ubuntu and macOS, running
  `npm ci`, `npm run typecheck`, `npm test`, `npm run build`, and a check that the
  build really emits the declared `lib/index.js` entry point.
- **Proxy transport ported to TypeScript** (`src/proxy.ts`) with tests for
  `parseProxy`, `proxyFromEnv`, the `NO_PROXY` decision and the pure response
  framer (status/headers/body, chunked, gzip, deflate, brotli). `ProxyResponse`
  now carries the final hop's headers so the caller can read `Retry-After`.

### Changed

- All implementation moved from `index.js`/`engines.js`/`proxy.js` to
  `src/*.ts`, compiled to `lib/` by `npm run build`; `package.json` now points
  `main` and `types` at `lib/`.
- The merge is RRF in every case, replacing the fixed round-robin interleave.
  The bilingual region policy still runs first: the region the query language
  belongs to (`global` for ASCII, `cn` for CJK, `both` for either) fills the
  budget, and the other region only appends when it came up short.
- Cooldown durations are configurable instead of the previous hardcoded five
  minutes, and the failure list says how long an engine will rest and why.

### Fixed

- The `wait` setting now reaches the engine run. It was documented, validated and
  offered by the settings card, but `resolveConfig` never carried it into
  `search`, so every call silently used `auto`.
- A partially specified config no longer throws on activation: `resolveConfig`
  runs its input through the schema first, so a row written as `config: {}` (or
  edited by hand into a fragment) resolves to the documented defaults instead of
  dereferencing an absent key.
- Weight no longer multiplies every row of an engine. The previous rule let
  AnySearch (weight 2) outscore every other engine at every rank, so a CJK query
  returned 8/8 AnySearch sources and no Chinese results at all; the measured
  split is now 4/2/2 for weights 2/1/1.
- A `POST` that meets a `3xx` other than `307`/`308` is downgraded to a bodyless
  `GET`; `307`/`308` preserve the method and body (AnySearch needs both paths).
- A tunnelled response's `Retry-After` is read from the final hop instead of
  being discarded.

## 0.6.0

### Changed

- AnySearch now leads CJK queries too (`region: both`), not only ASCII ones.
  A/B measured on the same query set: with domestic engines alone,
  `如何配置 postgres 逻辑复制` was answered with a Baidu Baike page for the
  dictionary word "如何" and a song-lyric page; adding AnySearch returned Cloud
  SQL documentation, a cnblogs walkthrough, the AWS specification guide, Azure
  documentation and a Tencent Cloud article. `Python GIL 是什么` went from
  python.org's home page, its download page and a tutorial site — none of which
  mention the GIL — to explanations of the GIL. `DeepSeek Harness 插件开发` went
  from front pages and an App Store listing to hands-on tutorials.
- The cost of that is visible: a Chinese query now takes 1.4–2.3 s instead of
  0.2–0.7 s with domestic engines alone.

## 0.5.0

### Added

- **AnySearch anonymous JSON API engine** (`POST api.anysearch.com/v1/search`
  with `{ query, max_results }`, parsed from its `{ code, data: { results } }`
  envelope). It is reachable without a tunnel and is the most precise engine, so
  it takes a weight of 2.
- Weighted interleave for merging per-engine lists, so a high-precision engine
  holds more slots without silencing the others.
- `www.`-aware dedupe: `http(s)://www.` and a trailing `/` are the same
  document.

### Changed

- Bing global demoted from primary to fallback. Measured on stopword-heavy
  English queries it matched a single token: `how to set up postgres logical
  replication` returned jigsaw-puzzle sites for "set", while the other engines
  stayed on topic. It is worth reaching only when nothing else answered.

## 0.4.0

### Added

- **Bilingual region policy**: engines declare `global`, `cn` or `both`; the
  region matching the query script leads the merged list and the other region
  only fills gaps.
- **`wait` policy**: `auto` (wait for the leading region), `all` (wait for every
  engine) and `fast` (return on the first answer), each with a soft deadline.
- **Per-engine cooldown** after a block page, so a site that has decided this
  client is a robot is not re-triggered on every search.
- **Echoed-query check**: a result page must contain one query token of at least
  three characters, otherwise it is a rate-limit or "trending" page belonging to
  somebody else's search and is rejected.
- DuckDuckGo HTML parser with per-result snippets, alongside the lite endpoint.

## 0.3.0

### Fixed

- 360 result URLs: the real target lives in the anchor's `data-mdurl`, not in
  the click-tracker `href`; entries without it are skipped instead of emitting a
  redirect as a source.
- Single-quoted attributes (`class='result-link'`) are parsed, not only double
  quoted ones.

## 0.2.0

### Fixed

- Bing click-tracker links (`/ck/a?...&u=a1<base64url>`) are decoded to the real
  target. The `&` arrives HTML-escaped as `&amp;`, so the value is unescaped
  before the payload is looked for.
- DuckDuckGo outbound links (`/l/?uddg=<percent-encoded>`) are unwrapped.
- HTML entities in titles and snippets are decoded, and numeric entities outside
  the known table are dropped rather than leaked into a snippet.

## 0.1.0

### Added

- Initial provider: scraped 360 and Bing result pages, registered with the
  `ctx.web` seam as provider id `bing`, returning `{ url, title, snippet }` with
  no API key and no credential.

