Supplies the Dashes table, the Replace replacer and the generic Chunk/Block dispatch this flavor is a thin closure over.
hook-dsh-normalize-dash
Hook @ DSH @ Normalize @ Dash • _The DeepSeek Harness Plugin Family for PlayForm._ The dash normalizer for model output - a DeepSeek Harness plugin that hooks the llm/stream waterfall (the interceptable wrapper around EVERY streaming model call, bound to the LlmRuntime) and normalizes the unicode dash family to ASCII hyphen-minus, live in the transcript: em dashes, en dashes and seventeen exotic relatives → -. Exactly the user's hermes hook ( ~/.hermes/agent-hooks/normalize-dashes.sh), at exactly the injection point hermes itself does not have. A CLASS flavor of the normalize family: the core's Dashes table plus a configurable replacement (default -). It also registers the family's raw-write tool - a write wrapper with an explicit normalize parameter (default false = verbatim), exempt from the stream normalization by name like edit.
$ pnpm add @playform/hook-dsh-normalize-dash The profile wiring for this plugin - the bundles list, the patch entry and the restart - is on the setup page.
Where It Fits
Family position (the @-sentence Hook @ DSH @ Normalize @ Dash): a hook child of the plugin-dsh-factory service and the hook-dsh-core machinery; the first of six stream normalizer siblings, all built on the shared machinery:
| Flavor | Table | Substitution |
|---|---|---|
| hook-dsh-normalize-dash (this bundle) | core Dashes class | → replacement (default -) |
| hook-dsh-normalize-quotes | core Quotes MAP | curly → straight |
| hook-dsh-normalize-ellipsis | core Ellipsis class | U+2026 → ... |
| hook-dsh-normalize-spaces | core Spaces class | unicode spaces → " " |
| hook-dsh-normalize-invisible | core Invisible class | removed (default "") |
| hook-dsh-normalize-fullwidth | core Fullwidth MAP | full-width → half-width |
It is also the factory's first NON-MANIFEST module: it injects ["pluginFactory"] and uses only State (cell unwrap + shared Ledger/Enabled mappings + its own fields) and Append. It touches no files, so fs/write-intent and fs/observed never see it; it wraps the downstream result and always calls next(), so it composes with other llm/stream listeners regardless of registration order. Untouched chunks pass through by object identity; a rewritten delta/block is a shallow copy with only the text field replaced.
In the DeepSeek Harness
| Seam | What the plugin does there | What you can observe |
|---|---|---|
| llm/stream - the model stream waterfall | The plugin's listener wraps the interceptable waterfall around EVERY streaming model call (bound to the LlmRuntime): next() is called first and unconditionally, options are never touched, one chunk in - one chunk out, upstream throws propagate. | The live UI and the durable transcript see the normalized stream as it is born. |
| The tool layer - the agent's toolset | Besides the listener, the plugin registers the family's raw-write tool into the agent's toolset (ctx.tools.register): a write wrapper around the built-in write operation with an explicit normalize parameter - exempt from stream normalization by name, exactly like edit. | The normalization decision moves to execution time, per call: verbatim by default, the family's transforms on demand. |
| ctx.fs - the filesystem service (dsh-fs) | The raw-write exec mirrors the built-in write's path exactly: resolve → the fs/write-intent waterfall → the standing sandbox policy → writeText → the fs/observed emit, so the governance hooks treat its writes like built-in writes. | One write path for the whole agent, plugin tools included. |
| The factory service | A non-manifest factory consumer: State for the config (hot-editable cells, no remount) and Append for every ledger line; it touches no files on the stream path. | Composes with other llm/stream listeners regardless of registration order. |
| The ledger / session | Two lines through the factory's Append: the activation proof from apply() and the per-stream count line on a normal completion with N > 0. | A thrown-away stream writes no ledger line; the count is per stream, visible in your own log. |
The Problem
The user's hermes hook rewrites the unicode dash family to ASCII hyphen-minus in files after the fact - perl -CSD -pe over whatever was already written. Model output, however, is born in the stream: the live UI and the durable transcript see the em dashes first, no matter what a file hook does later. A DSH plugin can go where hermes cannot: the llm/stream waterfall, where every streaming model call passes through.
Unlike the governance family (hook-dsh-governor-package, hook-dsh-pinner-package, hook-dsh-governor-cargo), whose rewrites are SILENT, this plugin's normalization is deliberately VISIBLE - it IS the feature: the rewrite flows into both the live UI and the durable transcript.
How It Works
Waterfall discipline: next() is ALWAYS called, unconditionally, first - omitting it short-circuits the waterfall and the model call never happens; options is NEVER touched (a loop-built request arrives deep-frozen and its content is a pure function of the session log); order preserved, no buffering - one chunk in, one chunk out, upstream throws propagate.
The transform - exactly the hermes hook's perl pattern ( perl -CSD -pe in normalize-dashes.sh), applied per text segment - the core's Dashes class, verbatim:
Armenian hyphen, Hebrew maqaf, Canadian syllabics hyphen, Mongolian todo soft hyphen, the U+2010-U+2015 hyphen/dash family (including the em dash and the non-breaking hyphen), double oblique hyphen, hyphen with diaeresis, two-/three-em dash, double hyphen, U+2E5D, wave/wavy dash, katakana-hiragana double hyphen, the vertical presentation dashes, small em dash, small hyphen-minus and the fullwidth hyphen-minus - all become the config replacement (default -, ASCII hyphen-minus U+002D). No context rules - hermes has none. Chunk-boundary-safe: single-character replacement, no lookahead, no multi-character sequences - per-chunk application can never disagree with whole-text application. The replacement is applied with a function replacer, so a custom replacement containing $ patterns is inserted literally. Replaced characters are counted per stream for the ledger line.
The raw-write tool
Besides the stream listener, the normalize-dash registers the family's raw-write tool: a plugin-registered write wrapper that wraps the built-in write operation with an explicit normalize parameter - the tool is exempt from the stream normalization by name (the core's Stream/Chunk + Stream/Block pass every raw-write call through by identity, exactly like edit), so the content is what the model sent, and the normalization decision happens at execution time:
| normalize | What the exec writes |
|---|---|
| absent or false (default) | the content VERBATIM, byte-for-byte |
| true or "all" | the family's SIX transforms applied in order, daisy-chaining the text: Dashes (→ the module's replacement), Quotes (curly → straight), Ellipsis (U+2026 → ...), Spaces (unicode spaces → " "), Invisible (removed), Fullwidth (full-width → half-width) |
| ["dash", ...] | ONLY the selected flavors, applied in the family's fixed order (dashes → quotes → ellipsis → spaces → invisible → fullwidth); the parameter's schema declares the union (boolean | "all" | the flavor-name array) |
The tool also takes a govern parameter - the per-call governance selection over the factory's direct-govern registry, applied after a successful write: absent or false (default) runs NO governance chain (the write still emits fs/observed exactly like a built-in write); true or "all" runs ALL registered governance steps for the target's basename; ["canonicalize", ...] runs ONLY the named steps. Governance is best-effort and contained: the steps run through the factory's Govern(target, selection, actor, version) with the written outcome's fresh version - a failing step never affects the write's outcome. The exec mirrors the built-in write's fs path exactly -ctx.fs.resolve → the fs/write-intent waterfall → the standing sandbox policy → ctx.fs.writeText(...) → the fs/observed emit with the written version. exec.signal is honored end to end. Inject: ["pluginFactory", "fs", "tools"].
Deterministic character mapping
A sample of the core's Dashes class - the full class is the hermes perl pattern verbatim, every entry mapping to the config replacement (default U+002D):
| Input Glyph | Code Point | Name | Output | ASCII |
|---|---|---|---|---|
| - | U+2014 | EM DASH | - | U+002D |
| - | U+2013 | EN DASH | - | U+002D |
| - | U+2015 | HORIZONTAL BAR | - | U+002D |
| - | U+2011 | NON-BREAKING HYPHEN | - | U+002D |
| - | U+FF0D | FULLWIDTH HYPHEN-MINUS | - | U+002D |
The Config
| Field | Type | Default | Volatile | Meaning |
|---|---|---|---|---|
| log | boolean | true | yes | write the durable ledger file |
| logFile | string | ~/.dsh/hook-dsh-normalize-dash.log | yes | the normalize-dash ledger (separate from the family's logs) |
| replacement | string | - | yes | the transform's only knob - hot-editable, the next stream picks it up with no remount |
| normalizeReasoning | boolean | true | no | normalize reasoning deltas and the assembled reasoning block too |
| normalizeToolArguments | boolean | false | no | IMPLEMENTED (default OFF): rewrite the tool-call argumentsDelta and the assembled ToolCallBlock.arguments when on (with the edit/raw-write name exemptions and the {"__normalize":false raw-marker pass-through) - execution-critical raw JSON, the user's accepted risk; the example patch turns it on |
Volatile cells commit without remounting the plugin (the fiber - and with it the llm/stream registration - stays alive); the factory's State builder unwraps them defensively. Example cordis.patch.yml row:
In Action
The model emits prose with typographic dashes; the live UI and the transcript receive the ASCII forms:
Before - the model stream as it is born
The em dash (U+2014), the en dash (U+2013) and the horizontal bar (U+2015) each become -; the ASCII hyphens that were already there are untouched (the count only counts replacements). The same pass runs over reasoning deltas ( normalizeReasoning, default on) and - with the example patch enabling normalizeToolArguments - over tool-call arguments, where the three-way gate keeps the sensitive calls intact:
After - the stream that reaches the transcript (and the gate for sensitive calls)
And the raw-write tool in the file-writing direction: its content lands verbatim by default, or runs the family's six-fold chain when called with normalize: true - the em dash above would survive the default call and become a hyphen under normalize: true. The selection can also be FINE-GRAINED:
The raw-write tool, per call
The order is FIXED by the family (dashes → quotes → ellipsis → spaces → invisible → fullwidth) even when the selection lists the flavors out of order: normalize: ["fullwidth", "dash"] still applies dashes first. And the governance direction, per call: a raw-write to a package.json with govern: true runs the registered chain pass (canonicalize) and the update stage after the write lands; govern: ["pin"] runs only the pinner's chain pass. When a stream finishes normally with replacements made, the ledger gets the count line shown below.
The Ledger
Two lines, both written through the factory's Append (the hook-dsh-normalize-dash: prefix is the logger's <State.Module>:; the durable file line is [<ISO>] <message>):
The activation line is written by apply(); the count line only follows a normal stream completion and only when N > 0 (a thrown-away stream writes no ledger line).
Related plugins
The second stream normalizer sibling - the Quotes MAP flavor, curly → straight.
The parent service: State, Append - plus the named Schema helper (shared: false) for the config.
License: MIT. The workbench and the matrix pages run the same flavors live.