PLUGIN DETAIL FLAVOR: DASH CLASS FLAVOR + THE RAW-WRITE TOOL

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.

CLI INSTALL COPIED
$ pnpm add @playform/hook-dsh-normalize-dash
Namespace: @playform/hook-dsh-normalize-dash Release: v0.0.1
Archetype: Hook + Tool Event: llm/stream Injects: pluginFactory

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: 1ST OF 6 STREAM NORMALIZERS

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:

FlavorTableSubstitution
hook-dsh-normalize-dash (this bundle)core Dashes class→ replacement (default -)
hook-dsh-normalize-quotescore Quotes MAPcurly → straight
hook-dsh-normalize-ellipsiscore Ellipsis classU+2026 → ...
hook-dsh-normalize-spacescore Spaces classunicode spaces → " "
hook-dsh-normalize-invisiblecore Invisible classremoved (default "")
hook-dsh-normalize-fullwidthcore Fullwidth MAPfull-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

WHERE THE FLAVOR OPERATES
SeamWhat the plugin does thereWhat you can observe
llm/stream - the model stream waterfallThe 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 toolsetBesides 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 serviceA 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 / sessionTwo 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

WHY THE DASH FLAVOR EXISTS

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

THE STREAM PIPELINE
llm/stream waterfall (options, next) the interceptable wrapper around │ EVERY streaming model call ▼ next() called FIRST, always - options never touched Normalize(upstream, state) the async generator (Function/Normalize) │ for await (chunk of upstream) ▼ CoreChunk(chunk, transform, reasoning, toolArgs, raw) │ the core's per-chunk dispatch │ ├─ text-delta ────────► Replace(text, Dashes, "-") rewrite the text field ├─ reasoning-delta ───► same, when normalizeReasoning (default ON) ├─ block-end ─────────► the assembled block's text fields - │ TextBlock.text / ReasoningBlock.text (plus a │ runtime `thinking` string field) - the deltas │ AND the block must agree, or consumers see │ inconsistencies ├─ tool-call-delta ───► Replace(argumentsDelta, Dashes, "-") when │ normalizeToolArguments (IMPLEMENTED, default │ OFF - execution-critical raw JSON, the user's │ accepted risk; the example patch turns it │ on) - the assembled ToolCallBlock.arguments │ follows the same flag via block-end │ │ The gate is THREE-WAY with the flag on: a │ delta/block whose `name` is "edit" or │ "raw-write" passes through BY IDENTITY (the │ edit tool's `old_string` must match the real │ file bytes; the raw-write tool owns its │ normalization through the explicit `normalize` │ parameter), and a call whose arguments open │ with the `{"__normalize":false` marker (FIRST │ key, tracked per call id) passes through │ UNNORMALIZED with the marker entry stripped, │ so the executed call carries no unknown key └─ block-start / usage / finish ──► PASSTHROUGH BY IDENTITY, ALWAYS (usage/finish ordering is the adapter contract) │ count === 0 → original chunk BY IDENTITY; rewritten → shallow copy ▼ yield ──► downstream consumers = the live UI + the durable transcript │ (order preserved, no buffering; upstream throws propagate) ▼ normal loop completion, Count > 0 Factory.Append ──► `hook-dsh-normalize-dash: normalized N dash char(s) in one stream`

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:

\u058A\u05BE\u1400\u1806\u2010-\u2015\u2E17\u2E1A\u2E3A-\u2E3B\u2E40 \u2E5D\u301C\u3030\u30A0\uFE31-\uFE32\uFE58\uFE63\uFF0D → replacement

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

NORMALIZATION AT EXECUTION TIME

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:

normalizeWhat 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

THE DASH CLASS, SAMPLED

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 GlyphCode PointNameOutputASCII
-U+2014EM DASH-U+002D
-U+2013EN DASH-U+002D
-U+2015HORIZONTAL BAR-U+002D
-U+2011NON-BREAKING HYPHEN-U+002D
-U+FF0DFULLWIDTH HYPHEN-MINUS-U+002D

The Config

SCHEMA + DEFAULTS AT LOAD
FieldTypeDefaultVolatileMeaning
logbooleantrueyeswrite the durable ledger file
logFilestring~/.dsh/hook-dsh-normalize-dash.logyesthe normalize-dash ledger (separate from the family's logs)
replacementstring-yesthe transform's only knob - hot-editable, the next stream picks it up with no remount
normalizeReasoningbooleantruenonormalize reasoning deltas and the assembled reasoning block too
normalizeToolArgumentsbooleanfalsenoIMPLEMENTED (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:

- insert: - id: hook-dsh-normalize-dash name: "@playform/hook-dsh-normalize-dash" config: log: true logFile: ~/.dsh/hook-dsh-normalize-dash.log replacement: "-" normalizeReasoning: true

In Action

ONE STREAM, ONE TRANSFORMATION

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

text-delta in (what the model wrote): "The refactor is complete — every call site updated, ranges 10–15 covered, and the odd U+2015 bar ― swept too." text-delta out (what reaches the transcript): "The refactor is complete - every call site updated, ranges 10-15 covered, and the odd U+2015 bar - swept too."

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)

tool-call-delta, name "edit" → passes through BY IDENTITY (old_string must match real file bytes, em dashes and all) tool-call-delta, name "raw-write" → passes through BY IDENTITY (the tool owns its normalization through its explicit `normalize` parameter) tool-call-delta, name "write", arguments {"file": "a — b.txt"} → normalized to {"file": "a - b.txt"}

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

raw-write, content "The refactor is complete — every call site updated, ranges 10–15 covered, and the odd U+2015 bar ― swept too.", normalize absent: → the file matches the input byte-for-byte (em dash, en dash and the horizontal bar all survive) raw-write, same content, normalize: ["dash"]: → The refactor is complete - every call site updated, ranges 10-15 covered, and the odd U+2015 bar - swept too. (ONLY the dash-family characters are replaced; a curly quote, an ellipsis or a full-width character in the same content would survive untouched) raw-write, content "He said “wait” … then left.", normalize: ["quotes"]: → He said "wait" … then left. (ONLY the curly quotes are replaced — the ellipsis and the full-width period are untouched by the quotes-only selection) raw-write, content "He said “wait” … then left.", normalize: true: → He said "wait" ... then left. (all six transforms, in the family's fixed order — the legacy behavior)

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 VIA FACTORY APPEND

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>):

hook-dsh-normalize-dash: activated (replacement=-, reasoning=on, toolArgs=off, logFile=~/.dsh/hook-dsh-normalize-dash.log) hook-dsh-normalize-dash: normalized 4 dash char(s) in one stream

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

12 TOTAL
hook-dsh-core DSH FAMILY

Supplies the Dashes table, the Replace replacer and the generic Chunk/Block dispatch this flavor is a thin closure over.

plugin-dsh-factory PARENT SERVICE

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.