leo-web-access
A DeepSeek Harness (dsh) plugin
that routes the harness's native web_search through the keyless search
engines that ship inside pi-web-access.
It is an adapter, not a search engine. No search logic is re-implemented here: the engines are pi-web-access's own provider modules, compiled and vendored at build time, and this plugin only picks one, runs it under a time budget, and maps the result into dsh's web seam.
web_search (native dsh tool)
│
▼
ctx.web.search() ← dsh web seam, provider id "webaccess"
│
▼
leo-web-access/index.js ← this plugin: order, budget, mapping
│
├── exa (MCP, keyless) ~0.9 s
├── keenable (MCP, keyless) ~0.6 s
├── duckduckgo (HTML, keyless) ~1.1 s
└── anysearch (anonymous quota) ~1.8 s
The npm package is
leo-web-access; the GitHub repository is still nameddsh-web-access. The package name was chosen becausedsh-web-accesson npm already belongs to an unrelated plugin — installing that one by mistake would be worse than an unmemorable name.
Why the engines can be transplanted
pi-web-access is a Pi extension, so dsh plugin add pi-web-access cannot work
directly: it has no dsh.bundle. Its dist/ is a single esbuild file exporting
only four symbols, and the search internals are unreachable from there.
Its source .ts files are published alongside dist/, and the provider
modules do not depend on the Pi runtime on the search path. Node refuses to strip
types under node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so the
sources are compiled once, at build time, into dist/providers/*.mjs.
Two rules keep that honest:
- No vendored source.
pi-web-accessis a pinned dependency ("pi-web-access": "0.46.0");npm run buildreadsnode_modulesand recompiles. Bumping the pin is the whole upgrade. - No Pi packages.
build.mjsscans every produced bundle and deletes any provider that reaches an@earendil-works/pi-*runtime import, reporting it instead of shipping something broken.
Install
The desktop profile is managed by the Electron app, so install through the UI: 插件 → 添加插件, point it at this directory (or a packed tarball), then restart dsh.
Then confirm the plugin row is present and enabled on the Plugins page.
Configuration
The bundle patch pins the web row and mounts the plugin:
- id: web
config:
searchProvider: webaccess
fetchProvider: http # restated: a patch row replaces the whole config
- insert:
- id: webaccess
name: leo-web-access
Row config (all optional, every value falls back to a working default):
| key | default | meaning |
|---|---|---|
providers |
["exa","keenable","duckduckgo","anysearch"] |
failover order |
budgetMs |
15000 |
wall-clock budget for one search() call |
attemptTimeoutMs |
7000 |
per-engine timeout |
snippetMaxChars |
300 |
snippet characters kept per source |
degoog is not in the default chain: it measured ~4 s per query, which would
consume most of the budget before the first fallback. Add it to providers if
you want it.
API keys are optional
All four default engines work with no key. Keys only upgrade an engine to its keyed tier, and this plugin feeds them in from either source, at call time:
- dsh credentials —
ctx.credentials.resolve('EXA_API_KEY')and friends. A resolved value is placed inprocess.envright before the attempt, which is where the upstream modules read it from. - Pi's own config —
~/.pi/agent/web-search.json, read by the upstream modules themselves, so a user who already set up Pi does not re-enter keys.
Recognised names: EXA_API_KEY, KEENABLE_API_KEY, ANYSEARCH_API_KEY.
Behaviour
- Ordered failover. The first engine that is available and returns at least one result wins; anything else moves to the next entry.
- Honest trails.
contentnames the engine that answered, its latency, the failover position, and the credential source. A total failure throws with one line per attempt — which engine, why, and how long it took. - Snippets are recovered, not invented. Exa's keyless MCP route returns
snippet: ""and puts the readable excerpt in itsanswertext as<excerpt>\nSource: <title> (<url>). Those excerpts are lifted back onto the matching source, so citation cards and the model both get text. Nothing extra is fetched. fetchProvider: httpstays pinned soweb_fetchnever becomes ambiguous (WEB_PROVIDER_AMBIGUOUS) once the host also registers a fetch provider.
Development
npm install
npm run build # compile dist/providers/*.mjs from node_modules, then check deps
npm run smoke # mount the plugin against a stub ctx + live search
node scripts/smoke.mjs --offline
--offline mounts the adapter without touching the network.
dist/providers.json is the build manifest and is committed. It carries no
timestamp, so rebuilding an unchanged pin produces byte-identical output and no
spurious diff.
A warn install-scripts line about esbuild's postinstall is expected under a
package manager with script approval; esbuild still works.
Verification status
Checked against the live desktop profile:
leo-web-accessappears in the profile's bundle list and itswebaccessrow mounts (cordis_inspect_query→include:webaccess).- The native
web_searchtool answers through the adapter, reportingEngine: Exa (exa) · 8 resultsin its content. - Disabling the bundle does not strand the setting. With the bundle off,
web_searchstill answered (via the profile's ownmodsearchlayer) instead of failing withconfigured web provider "webaccess" is not registered; re-enabling restored the adapter.
Not verified in this environment: the rendered Plugins-page row and its icon
(no browser control from here), and dsh --dump-config (dsh is not on PATH —
the app is launched from the Electron bundle). The patch contents are as shown
above; both provider pins are present in cordis.patch.yml.
Uninstall safety
Disabling or removing this bundle removes its patch row, so the profile returns
to its own searchProvider setting instead of leaving webaccess configured but
unregistered (the configured web provider "webaccess" is not registered failure).
License
MIT. Vendored build products derive from pi-web-access (MIT); its source and
license remain in node_modules and are not committed here.
No comments yet. Be the first to write one.