dsh-villager-hmm
A DeepSeek Harness plugin.
Whenever the model goes "hmm" — inside its chain of thought, or in the reply
itself — a Minecraft villager hums back at you.
A villager stands in the top-right corner, bobs on every hit, and carries the
controls: pause, volume, which stream to watch, and the matcher itself.
Drag the title bar to move it; double-click the bar to send it back to the
corner. The position is remembered in localStorage. It defaults to the
top-right rather than the bottom-right on purpose — the composer is full-width,
so a bottom-anchored overlay would sit on top of the send button.
┌──────────┬──────────────────────────────────┐
│ villager │ Villager hmm - │
│ figure │ hits 12 · played 12 │
├──────────┴──────────────────────────────────┤
│ Reasoning 1284 chars Reply 356 chars │
│ Sounds 2 clips │
│ [ Listening · click to pause ] [ Play once ] │
│ Volume ─●─ │
│ Source [ Reasoning only ▾ ] │
│ Matcher [ (?<!…) ] [Apply] [Default] │
│ Recent hmm 嗯 唔 │
└──────────────────────────────────────────────┘
Collapsed, the panel shrinks to nothing but the villager's head (a
36x40 crop of the face plus nose). Clicking the head expands the panel
again; it still drags, and double-clicking still resets the corner. It
bobs on every hmm, just like the full figure.
The panel follows the interface language: it registers English and Chinese
dictionaries with the shared locale service and re-renders when the locale
changes. If that service is unavailable it falls back to English rather than
failing to render.
Install
dsh plugin --profile web add dsh-villager-hmm
That is the whole install. The package declares dsh.bundle, so dsh plugin
registers it as a profile layer by itself — there is no configuration file to
edit. Restart the profile when it finishes.
Then fetch the assets, from the profile directory (where the package's bin is
linked):
cd "$DSH_HOME/profiles/web"
npx dsh-villager-hmm-assets
Skip this step and the plugin still loads. It shows its panel and prints the
exact command to run — with an absolute path, so it works from any directory —
and never fails silently.
Assets
This package ships no Minecraft material. The villager's sounds and textures
are fetched on your machine into a per-user cache:
$DSH_HOME/.dsh-villager-hmm/assets/
idle1.ogg villager "hmm"
idle2.ogg villager "hmm", second take
hit1.ogg damage grunt, played when you pet the villager
hit2.ogg damage grunt
hit3.ogg damage grunt
hit4.ogg damage grunt
villager.png the 64x64 base skin, cropped into the figure by CSS
villager-type.png the villager type overlay — this is what puts the
brown robe on, see "The figure" below
damage.png the 8x8 damage-indicator heart thrown by the pet
interaction, see "Petting" below
The damage clips are named hit*, not hurt*: the game's own sounds.json
maps the entity.villager.hurt event onto mob/villager/hit1..hit4, and no
hurt*.ogg exists in the vanilla assets.
The panel shows the exact command if the cache is empty. Two sources are
supported:
| Flag | Meaning |
|---|
| (none) | Download from a public mirror of the vanilla 1.21.4 client assets |
--from <dir> | Copy from an asset tree you extracted yourself — the option that matches the game's terms |
--base <url> | Your own mirror with the same layout |
--dir <path> | Use a different cache directory |
--force | Re-fetch files that are already there |
# from an extraction you made yourself
node "$DSH_HOME/profiles/web/node_modules/dsh-villager-hmm/scripts/fetch-assets.mjs" \
--from "C:/mc-extract/assets/minecraft"
The cache lives outside node_modules, so it survives plugin upgrades. The host
half re-reads it per request, so running the fetch script takes effect on a page
reload — no harness restart.
Configuration
Defaults live in the row. To override them, replace the insert in your own
$DSH_HOME/profiles/web/cordis.patch.yml:
- insert:
- id: dsh-villager-hmm
name: 'dsh-villager-hmm'
config:
enabled: true
mode: both # 'reasoning' (chain of thought only) | 'both'
pattern: '(?<![0-9A-Za-z])(?:h+m+|mhm+|m{2,})|[嗯唔哼呃]'
assetDir: 'D:/my-assets'
Everything except assetDir is also editable from the floating panel.
mode defaults to both on purpose: plenty of models expose no separate
reasoning channel, and a plugin that silently does nothing looks broken. Switch
to reasoning once you have confirmed your model streams reasoning-delta —
the panel's Reasoning counter tells you: if it climbs, you have a reasoning
channel and can narrow the scope.
The matcher
The default pattern is
(?<![0-9A-Za-z])(?:h+m+|mhm+|m{2,})|[嗯唔哼呃].
It uses a lookbehind rather than \b, for two reasons:
\b is ASCII-only in JavaScript, so it behaves unpredictably next to CJK
characters — 嗯hmm would not match.
- The CJK branch deliberately has no boundary at all, because those characters
are always standalone interjections.
h+m+ covers hm, hmm, hmmm…; mhm+ covers the closed-mouth variant;
m{2,} covers a bare mm. None of them fire inside ordinary words — comma,
comment, command and ahmm all stay silent.
Write your own in the panel's 匹配式 field and hit 应用. A pattern that
does not compile is reported inline instead of being swallowed.
How it works
The host half listens to the agent/assistant-stream event and scans
reasoning-delta (and optionally text-delta) chunks against the matcher. The
browser half polls that counter over the plugin's own HTTP prefix and plays a
sound per hit.
The plugin only consumes. It registers one event listener and one HTTP
prefix route; it publishes no service and patches nothing. If the harness stops
emitting the event, or the overlay slot disappears, the plugin degrades to doing
nothing — it does not take the boot or the page with it.
Two details shape the scanner:
frame.revision is not a stream identity. It increments on every frame.
Keying the scan buffer by it resets the buffer on every delta, so the matcher
only ever sees one character and never matches. The buffer is keyed by
attemptId:turn:step.
The scan is inline, not debounced. Deltas arrive far faster than any
debounce window, so every new delta cancels the pending timer before it can
fire. Because the matcher keeps a consumed-offset cursor, scanning on every
delta is amortised O(n). A match that ends exactly at the buffer end is deferred
unless the attempt is finishing, so hm is never counted before the next delta
can turn it into hmm.
The figure
The villager is composited in CSS out of the fetched 64x64 atlases. Every crop
comes from Mojang's own model — Mojang/bedrock-samples,
resource_pack/models/entity/villager.geo.json, whose Bedrock cube list matches
the Java villager. The front face of a w x h x d cube with box-UV origin
(u, v) is (u + d, v + d) sized w x h, and the model spans y 0..34 with the
feet at 0, so the canvas is 16x34 and the head sits on top:
| Cube | Origin | Size | UV | Front face | Canvas |
|---|
| head | [-4,24,-4] | 8,10,8 | [0,0] | (8,8) 8x10 | 4,0 |
| nose | [-1,23,-6] | 2,4,2 | [24,0] | (26,2) 2x4 | 7,7 |
| body | [-4,12,-3] | 8,12,6 | [16,20] | (22,26) 8x12 | 4,10 |
| robe | [-4,6,-3] | 8,18,6 | [0,38] | (6,44) 8x18 | 4,10 |
| arm | [-8,16,-2] / [4,16,-2] | 4,8,4 | [44,22] | (48,26) 4x8 | 0,10 / 12,10 |
| forearms | [-4,16,-2] | 8,4,4 | [40,38] | (44,42) 8x4 | 4,14 |
| leg | [-4,0,-2] / [0,0,-2] | 4,12,4 | [0,22] | (4,26) 4x12 | 4,22 / 8,22 |
Two of those cubes are easy to miss, and the figure looks wrong without them:
- The robe is the second body cube. The base skin leaves its pixels fully
transparent, because the game paints the robe from the villager type
overlay texture at the same UVs. That is why
villager-type.png is not
decoration: without it the villager wears only the base under-robe.
- The forearms are the folded arms across the chest, drawn last so they sit
over the robe.
The nose hangs one pixel below the chin (y 23..27 against a head ending at
24), which is why the collapsed head's canvas is 11 tall rather than 10.
Every size in the stylesheet is derived from FIGURE_SCALE / FACE_SCALE and
the canvas extents computed from the part list, so editing a part cannot leave
the stylesheet behind. Coordinates stay integral, which is what keeps the 4x
nearest-neighbour upscale crisp.
Petting
Collapsing the panel leaves the villager's head floating on its own, and that
head is a pet:
- Click it — the head flinches, flashes red, grunts, and throws the game's
damage particles:
minecraft:damage_indicator, the dark-red hearts from
textures/particle/damage.png. That sprite is an 8x8 luminance mask with a
grey interior, so the burst is brightened and tinted through the same red
matrix as the flash rather than pasted in raw (an untinted heart is grey, and
one tinted exactly like the villager disappears into it).
- The
▣ button under it restores the full panel. Petting and restoring are
separate controls on purpose: folding both onto the head's click is what made
the head unpetable in the first place.
The expanded panel's body reacts to a click the same way, so the villager can be
petted in either form. Neither path touches the host: a pet plays a damage clip
straight away rather than waiting behind the rate-limited queue that the ambient
hmm sounds go through. The burst is derived from the pet counter through a hash
rather than Math.random(), because the panel re-renders on every 250 ms poll
and a fresh random set each time would make the hearts teleport mid-flight.
The gestures are split deliberately, because a pet is a click and petting twice
in a row is a dblclick:
| Gesture | Effect |
|---|
| Click the villager | pet it |
| Drag the villager | move the panel — 4px of travel before it counts as a drag |
| Double-click the villager | two pets, and nothing else |
| Double-click the bar chrome | send the panel back to its default corner |
The villager swallows double-clicks instead of letting them reach the bar.
Binding the reset to a double-click anywhere on the bar is what made a run of
pets jump the panel to the top-right corner; the drag threshold is what stops a
pixel of hand shake from being read as a drag, which both pinned the panel to a
pixel position and swallowed the pet.
The tint lives inside the @keyframes, never on the class that starts them.
An element keeps its class after an animation ends, so a filter declared on
.vhm-pet would leave the villager permanently red; and because filter: url()
cannot interpolate, the keyframes hold the tint at full strength and then swap
to none across two stops 4% apart, which is what makes it read as a flash.
Routes
The plugin owns one HTTP prefix, /dsh-villager-hmm:
| Route | Purpose |
|---|
GET /state?cursor=N | counters, asset status, and triggers newer than N |
GET /config?enabled&mode&pattern | change settings at runtime |
GET /sound/<n>.ogg | ambient "hmm" audio, read from the cache |
GET /hurt/<n>.ogg | damage audio for the pet interaction, from the cache |
GET /texture.png | the base villager skin, read from the cache |
GET /type.png | the villager type overlay — the robe, read from the cache |
GET /particle.png | the 8x8 damage-indicator heart, read from the cache |
Development
See CONTRIBUTING.md for the test suites, what CI enforces,
and the release process.
License
The code is MIT (see LICENSE — kept verbatim, with nothing appended, so
GitHub detects it as MIT rather than "Other").
No third-party assets are bundled or redistributed: scripts/fetch-assets.mjs
downloads them on your machine, at your direction, into a local cache. See
NOTICE for the asset terms. Minecraft is a trademark of Mojang
Studios; this project is not affiliated with or endorsed by Mojang or
Microsoft.