dsh-web-search-opencode-style
Verifieddsh-web-search-opencode-style Β· v0.1.2 Β· MIT
Opencode-style web search for DeepSeek Harness: direct Exa / Parallel MCP calls, no API key required. Registers one ctx.web search provider.
Install
dsh plugin add dsh-web-search-opencode-style Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Readme
dsh-web-search-opencode-style
An opencode-style web search provider for DeepSeek Harness
(DSH). It registers a single opencode-style provider into ctx.web, so the
model-facing web_search tool from @deepseek-ai/dsh-tool-web keeps working
(cards, citations, timeout, searchMaxResults) without tripping
WEB_PROVIDER_AMBIGUOUS.
No MCP server to install. No API key. No LLM credits burned.
How it works
This is a direct port of how opencode performs web search
(packages/opencode/src/tool/websearch.ts +
packages/opencode/src/tool/mcp-websearch.ts): the CLI POSTs a JSON-RPC
tools/call straight to two public MCP endpoints, then parses the MCP response
(plain JSON or SSE data: ... lines):
- Exa:
https://mcp.exa.ai/mcpβweb_search_exa - Parallel:
https://search.parallel.ai/mcpβweb_search
The plugin is zero-dependency: it only uses the global fetch (Node >= 20).
Fidelity to opencode
Ported 1:1 and cross-checked against websearch.ts + mcp-websearch.ts:
- Same endpoints, tool names, JSON-RPC body (
id: 1,tools/call),acceptheader, 25s timeout, defaultredirect(follow). - Same default args:
type: auto,numResults: 8,livecrawl: fallback,contextMaxCharactersonly sent when set;numResults = request || config || 8(no clamping, exactlyparams.numResults || 8). - Same backend selection order as
selectWebSearchProvider(): envOPENCODE_WEBSEARCH_PROVIDERβ configproviderβ flagsenableExa/enableParallel(envOPENCODE_ENABLE_EXA,OPENCODE_ENABLE_PARALLEL,OPENCODE_EXPERIMENTAL*asRuntimeFlags) βchecksum(sessionID) % 2. The session comes fromctx.agents.currentInitiator(), so every search inside one session hits the same backend, exactly like opencode. - Same fields sent to Parallel:
objective,search_queries,session_id(real session; without one,dsh_<checksum(query)>),model_name(agent.options.model, truncated to 100 chars β mirrorswebSearchModelName). - Same parsing: raw JSON first, then scan
data:lines; a{...}payload that is not anMcpResult(JSON-RPCerror, malformed JSON) is an error, not a silent "no results". - Same empty-result sentence:
No search results found. Please try a different query. - Same FNV-1a base36
checksum(ported frompackages/core/src/util/encode.ts).
What cannot be reproduced from the provider layer (DSH architectural limits):
| Point | opencode | DSH |
|---|---|---|
numResults/livecrawl/type/contextMaxCharacters chosen by the model |
tool parameters | DSH's web_search only takes queries[]; maxResults is fixed by the tool-web row (searchMaxResults), the rest is config |
| Asking permission before searching | ctx.ask({permission:'websearch'}) |
dsh-tool-web has no approval layer; permission belongs to the host, the provider has no channel |
Card title + metadata.provider |
"Exa Web Search: <query>" |
the seam's WebSearchResult has no metadata channel |
| User-Agent (Parallel) | opencode/<version> |
dsh-web-search-opencode-style/<version> β deliberately not impersonating another product |
sources[] is the only translation step opencode does not have: the seam needs
structure to render cards and citations. The plugin parses structured formats
first β Parallel's JSON results[] (title, publish_date β publishedAt,
excerpts β snippet), then Exa's
Title:/URL:/Published:/Highlights: blocks β and only falls back to scanning raw
URLs when the text matches no known format. truncated is set when more sources
were extracted than maxResults, so web_search can nudge the model to refine
the query.
Error codes: WEB_PROVIDER_ERROR (HTTP failure / network failure / unparseable
body / response larger than 4 MiB), WEB_SEARCH_TIMEOUT (past timeoutMs, same
message as opencode: <tool> request timed out, same name family as
dsh-web-fetch-http's WEB_FETCH_TIMEOUT), WEB_ABORTED (caller aborted).
Install (one command)
dsh plugin --profile web add dsh-web-search-opencode-style
This runs pnpm add inside the profile and wires the bundle into
dsh.profile.bundles (via reconcilePlugins: any package declaring dsh.bundle
joins the stack automatically). The bundle patch both mounts the provider and
overrides the web row to searchProvider: opencode-style (env
DSH_WEB_SEARCH_PROVIDER still wins), so there is no
WEB_PROVIDER_AMBIGUOUS clash with the built-in deepseek-official. Restart
dsh --profile web and the model sees web_search as before β no key, no extra
file edits.
Go back to the old DeepSeek search:
DSH_WEB_SEARCH_PROVIDER=deepseek-official dsh --profile web.
Pin one opencode backend: OPENCODE_WEBSEARCH_PROVIDER=exa|parallel.
Install from a local checkout instead:
dsh plugin --profile web add /path/to/dsh-web-search-opencode-style
Uninstall:
dsh plugin --profile web remove dsh-web-search-opencode-style
Configuration
Everything below is editable from Settings β Plugins β Plugin configuration
(namespace web-search-opencode-style): the plugin registers a real settings
section, so the form lists every key and changes apply from the next search
onward. That section needs @deepseek-ai/schemastery from the running DSH
install; the plugin loads it from $DSH_HOME/profiles/node_modules (DSH's shared
fallback), so a symlinked dsh plugin add <path> install works without anything
extra. Without schemastery the plugin still works β only the form is missing.
You can also use the row's cordis.patch.yml (see the template file), or the
config object passed to apply() directly:
| key | default | meaning |
|---|---|---|
provider |
undefined (auto) |
"exa" / "parallel" to pin; lower priority than env OPENCODE_WEBSEARCH_PROVIDER (same order as opencode) |
enableExa |
env OPENCODE_ENABLE_EXA / OPENCODE_EXPERIMENTAL_EXA / OPENCODE_EXPERIMENTAL |
force Exa, like RuntimeFlags.enableExa |
enableParallel |
env OPENCODE_ENABLE_PARALLEL / OPENCODE_EXPERIMENTAL_PARALLEL |
force Parallel; checked before enableExa, like opencode |
exaApiKey |
env EXA_API_KEY |
optional β the public endpoint works without a key |
parallelApiKey |
env PARALLEL_API_KEY |
optional |
numResults |
8 |
used when the caller sends no maxResults; in practice tool-web.searchMaxResults decides |
type |
"auto" |
auto / fast / deep (Exa) |
livecrawl |
"fallback" |
fallback / preferred (Exa) |
contextMaxCharacters |
(not sent) | Exa context cap |
timeoutMs |
25000 |
matches opencode's "25 seconds" |
enabled |
true |
false β available() returns false |
Development
The module exports name, inject, apply and Config β and no default
export. cordis' loader normalizes module exports with default ?? exports
(cordis-plugin-loader), so a default export shadows the named metadata and the
plugin loses its inject/Config (this caused
cannot get property "web" without inject). Add a default export only if it
carries that metadata itself.
node --test test/*.test.mjs # plugin.test.mjs (ported logic) + dsh-seam.test.mjs (real cordis + dsh-web)
DSH_INTEGRATION=1 node --test test/dsh-seam.test.mjs # plus one real search against Exa/Parallel
test/dsh-seam.test.mjs mounts the plugin on a real cordis context with the real
ctx.web seam and stubs only the network, so the export shape and the settings
wiring are covered too; it skips itself when no dsh install is on PATH.
License
MIT