dsh-plugin-word-lookup
Verifieddsh-plugin-word-lookup Β· v0.1.0 Β· MIT Β· Web UI
Look up a word, idiom, or sentence in the composer, explain it with dictionary-helper, and read it aloud
Install
dsh plugin add dsh-plugin-word-lookup Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
dsh-plugin-word-lookup
One control in the composer opens a lookup box for English text. It fills itself from the selection or the clipboard, explains the text with the dictionary-helper skill, and reads the looked-up part aloud β all on demand, with nothing added to the conversation.
What it does
Press the control and a small panel opens beside it:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β This test is a <piece of cake>. β
β Put <...> around the part to explain. [Explain] β
βββββββββββββββββββββββββββββββββββββββββββββββββββ€
β piece of cake π β³ β
β IPA: /piΛs Ιv keΙͺk/ β
β In this passage β
β Meaning: It means the test was very easy. β
β In general β
β Meaning: It means something is very easy to do. β
β Example: The quiz was a piece of cake. β
β Example: The job was a piece of cake. β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
The box is editable, and Explain sends exactly what it contains. Selecting text on its own never opens anything, so the page stays clear while you read.
What fills the box
| What you did | What the box contains |
|---|---|
| Selected a word or idiom | The whole sentence around it, with the selection wrapped: This test is a <piece of cake>. |
| Selected anything longer | The selected text, as it is |
| Selected nothing | The clipboard |
| Selected nothing, clipboard empty | An empty box, with Type or select something first when you press Explain |
The angle brackets are dictionary-helper's own convention: Task C explains only the bracketed part, in the way that passage uses it, and gives two new example sentences. It also shows the IPA and the usual meaning In general, after the meaning in that passage. When the sentence around a selection cannot be found β or is longer than 400 characters β the box falls back to the selection alone, which the skill then answers as a word or idiom.
The answer
The Host sends a prompt built from the dictionary-helper skill, so the answer keeps the skill's shape:
- A word β IPA, a meaning and an example for every part of speech, the word origin, and more examples.
- An idiom β IPA, every common meaning, an example for each, and why the idiom is used that way.
- A word or idiom inside a sentence
<...>β its IPA, the meaning in this passage, the usual meaning In general, and two new examples. - A full sentence β a chunk-by-chunk analysis with a one-line summary.
/analysis,/fix,/questiontyped into the box β the skill's sentence analysis, correction, and grammar-question tasks.
Everything is written in short, beginner-friendly English, like you are explaining to a 10-year-old β that is the skill's most important rule. The prompt makes it concrete: sentences
of about 15 words, no copied hard words, and a final check of every sentence with Would a
10-year-old understand this?
The panel's π reads the looked-up text: the bracketed phrase when the box marks one, otherwise the whole box. β³ asks the Host again, bypassing the page cache. While a text reads, nothing floats over the page: the same π turns into a β and pressing it stops the reading. Closing the panel also stops it.
Nothing is added to the conversation and no Session is touched: the answer is fetched over a loopback route and rendered in place.
How the explanation works
The model lives on the Host, and the page cannot call it directly β the key is in the Host environment and no Remote exposes a secret. So the Host serves one route and the page calls it:
POST /word-lookup/explain { input }
β { answer }
input is the contents of the lookup box, trimmed and capped at 2000 characters. answer is
dictionary-helper markdown, trimmed and capped at 12000 characters.
- Host (
lib/index.js) registers the route onctx.webServer, resolves the session's own default route (ctx.agentDefaultModel.currentSelection()), and callsctx.llm.streamwith the skill prompt. - Client (
lib/client.js) posts the box contents, then renders the answer.
Two details make this possible:
- The app is served over HTTP, so the page and the route share an origin and a relative
fetchis same-origin. - The Host half imports nothing at all β it only uses the
ctxit receives. That matters because a bundle installed withlink:resolves modules from its real path on disk, outside the profile, so it cannot reach the packages that ship inside the dsh installation. An earlier attempt to import@deepseek-ai/dsh-typert-protocolthere failed withERR_MODULE_NOT_FOUND, which is why the explanation does not use a Remote.
The task hint
The skill picks its task from the prompt, but a lookup box does not always carry the skill's own trigger, so the Host adds one line:
- the text starts with a command β no hint; the command is the task;
- it contains
<...>β This is Task C; - it is one word β This is Task A;
- otherwise β Choose Task B when this is a fixed expression or idiom, or Task D when this is a full sentence.
The prompt itself is the skill's grammar, formats, and style rules, condensed into one system message. The user turn ends with one more simplicity reminder, and the model call runs at a low temperature, because the beginner-English rule is the one a model drops most easily. Nothing is read from the skills directory at run time, so the plugin works even if that directory is not installed.
Empty answers
A model call can return an empty reply. The Host asks once more; if the second answer is
also empty, the route answers 502 with a message. The panel shows Could not look this up and
leaves β³ available β an empty card is never presented as an explanation. A code fence around
a whole answer is stripped before it is returned.
The route answers this shape:
{"answer":"**deprecated**\nIPA: /ΛdeprΙkeΙͺtΙͺd/\n\n**As an adjective**\nMeaning: old.\nExample: This app is deprecated."}
The Host trims the text before answering, and .dsh-probe/host-test.mjs drives the route end to
end against a stubbed model.
Rendering the answer
The answer is plain markdown, and the panel renders the subset the skill actually uses: bold
headings, Label: lines, - bullets, bold, and inline code. A first heading that only repeats
the looked-up text is dropped, because the panel already shows it in its header. There is no
markdown library in the plugin; the renderer is a short function at the bottom of
lib/client.js.
Caching
Answers are cached in the page for the life of the tab, keyed by the lookup text:
explainCache.get(input)
The box contents include the sentence, so the same word in a different sentence is a different
entry. The cache holds up to 60 answers (oldest evicted), never reaches disk, and stores only
successful answers. An entry expires 30 minutes after it was fetched
(EXPLAIN_CACHE_TTL_MS), checked on read, so a tab left open overnight never serves yesterday's
answer. β³ deletes the entry and refetches.
Why a composer control and not an item in the right-click menu
The Copy you get when you right-click selected text is built in the application's Electron main process, not in plugin space:
window.webContents.on("context-menu", (_event, { isEditable, selectionText, editFlags }) => {
const items = [];
if (isEditable) items.push(undo, redo, separator, cut, copy, paste, separator, selectAll);
else if (selectionText.length > 0) items.push({ role: "copy", enabled: editFlags.canCopy });
// ...
Menu.buildFromTemplate(items.map(/* ... */)).popup({ window });
});
That handler is hardcoded in the shipped shell. It exposes no Cordis Service and no slot, and
DSH plugins run in two other places β a private Node host process (dsh-desktop-host β
runProfile) and the web renderer. Adding an item next to Copy would mean patching a
code-signed application bundle, which is unsupported and is erased by every app update. A
control in the composer is the plugin-legal equivalent, and the panel it opens never blocks the
app underneath because the overlay layer is click-through by default.
How it works
One occupant on the frame-wide shell.overlay slot (replaceRisk: none), which hosts the lookup
panel and short notices, plus one control on conversation.input.left. The
layer is click-through, so every element opts back into pointer events.
- Scope. A selection is eligible anywhere except the composer,
input,textarea,contenteditable, and the overlay itself. It deliberately does not require a conversation marker: the exact markers could not be confirmed against the live DOM from outside the page, and a missing control is worse than one that also appears in another panel. - Anchor. The panel is placed from the composer control's rect. Because the control sits at the bottom of the window, the panel is placed by its bottom edge and grows upward; the room above also caps it, and the answer area scrolls inside. The header and the box never scroll away.
- Prefill. The selection is read on click, because pressing a button can collapse it; the
trigger's
mousedownis prevented for the same reason, and the last eligible selection is kept as a fallback. The clipboard is read only when there is no selection, so an ordinary click never asks the browser for it. - The sentence around a selection. The anchor node is walked up to the nearest block-level
element (
p,li,blockquote,pre, a table cell, a heading), the block'sinnerTextis collapsed, and the sentence holding the selection is cut out. Sentence ends are.!?followed by a quote or bracket and then whitespace and a capital letter β good enough for rendered chat text. - Dismissal. Escape, a click elsewhere, a page scroll, or a resize closes the panel. A scroll inside the panel β the answer's own scroll area β does not, because that would make a long answer impossible to read.
- Language. The dominant script picks the voice: Hangul β Korean, kana β Japanese, Han β Chinese, otherwise English. Latin text that merely quotes a Korean word stays on the English voice. A local voice for the language is preferred, then the platform default.
- Speaking. The speaker control in the answer header starts the system voice and turns into a stop control while the utterance runs; closing the panel or explaining again stops it. Each utterance carries a generation counter and the engine is cancelled first, so a superseded utterance can never clear the state of the live one.
Where the lookup can read
- Conversation transcript β yes.
- File previews for text, Markdown, and code β yes. Those render in the page's own document
(
pre,CodeBlock,MarkdownText), so an ordinary selection reaches the control. - The embedded browser panel β no. Its content is an
iframewhosesrcis the page you navigated to (http:/https:), sandboxed withallow-same-origin, so it keeps the target site's origin. The page cannot read a selection inside a foreign frame, and events do not bubble out of one; only the app could cross that boundary, by injecting a script into the frame. - PDF and Office previews β no, for the same reason: their renderers are iframes.
- The terminal β no; xterm.js keeps its own selection model rather than the browser's.
- Composer, inputs, and contenteditable β excluded on purpose, so drafts are never read.
For text you cannot select in place β the embedded browser page, a PDF β copy it (βC) and
open the panel. With nothing selected, the clipboard fills the box, exactly as if you had typed
it. The app's main window grants every permission except media, so the clipboard read is
allowed and the button click supplies the gesture it requires.
Install
From npm (any DSH)
The package is published as dsh-plugin-word-lookup.
- In DSH, open Plugins β Add plugin β Install a third-party plugin.
- Type
dsh-plugin-word-lookupand press Install. - Choose Enable now, and restart if DSH reports that a restart is required.
Or, from a terminal:
dsh plugin add dsh-plugin-word-lookup
The package has no npm dependencies and no DSH peer requirement, so any DSH build with
the services it uses can install it: llm, webServer, agentDefaultModel, shell.overlay,
conversation.input.left, slots, and locale. It runs the session's own default model, so
there is no key to configure.
The plugin is self-contained: its dictionary-helper prompt is embedded in lib/index.js, so
the skill does not need to be installed on that device. If you also want the skill for normal
chat there, copy ~/.agents/skills/dictionary-helper.
In this workspace (development link)
Installed into the desktop profile as a bundle:
dsh-plugin-word-lookup -> link:/Users/wangcheol/Documents/deepseek-harness/default-workspace/dsh-plugin-speak-selection
The profile dependency is a link, so this directory must stay exactly where it is. pnpm
resolved it to a symlink at node_modules/dsh-plugin-word-lookup, and the Loader resolves
the row through it.
The package declares no dependencies or peerDependencies. That is deliberate: an unmet
DSH peer requirement blocks bundle activation outright, and packages shipped with dsh resolve
from the dsh installation.
From a tarball (development)
Build the tarball with npm pack, copy it to the other device, then give Add plugin its
absolute local path or a direct URL. The dialog reads the package before installing, installs
it with pnpm, and offers Enable now.
Iterating
Reload the page for lib/client.js changes. lib/index.js changes need an app restart,
because host hot reload is opt-in per module root and this profile watches none.
- Toggle it in Plugins in the sidebar.
- Uninstall it there too, or with
dsh plugin remove dsh-plugin-word-lookup.
Probes
The tests in .dsh-probe/ run against stubs, with no app and no model:
node .dsh-probe/prompt-test.mjs # task hints, input caps, fence stripping
node .dsh-probe/host-test.mjs # route, retry, error status, method guard
node .dsh-probe/render-test.mjs # prefill, Explain, markdown, speak, reload, empty box
Limitations
- The route is unauthenticated. It binds to loopback, so only local processes can reach it, and the worst case is that one of them spends model tokens. Hardening it would need a secret injected into the page.
- The explanation is generated, so it can be wrong. It follows the dictionary-helper skill, not a fixed dictionary database.
- One model call per Explain, two when the first reply is empty. Cached per lookup text afterwards; β³ forces a fresh one.
- System voice quality. These are the OS voices, not a cloud model. Voice and rate are fixed at the engine default.
- English and Chinese UI copy. Those are the two built-in Client locales; the dictionary carries both. The explanation itself is always simple English, whichever locale is active.
- The answer is not read aloud. π reads the looked-up text (the bracketed phrase, or the whole box); the explanation stays on screen for reading.
- The markdown renderer is deliberately small. Bold,
Label:lines, bullets, and inline code. Tables, links, and nested lists would show their source characters. - A word's sentence is lifted from its block. In rendered chat that is normally a paragraph or a list item; a selection spanning several blocks falls back to the selection alone.
- Long input is capped. 2000 characters in the box, 400 for a lifted sentence, 12000 for the answer.
Layout
| Path | Purpose |
|---|---|
package.json |
Bundle patch plus the dsh.client declaration for the browser half |
cordis.patch.yml |
Inserts the single ui-speak-selection Loader row |
lib/index.js |
Node half β the explanation route, the dictionary-helper prompt, the model call; imports nothing |
lib/client.js |
The whole browser feature: scope, selection, prefill, lookup panel, markdown renderer, speaking, cache |
locale/*.json |
Display metadata for the Plugins list |
.dsh-probe/ |
Stubbed render and route tests |