@playform/ets-hook-dsh-normalize-dash
v0.1.0
Published
The normalize-dash hook: a DeepSeek Harness plugin that hooks the llm/stream waterfall — the interceptable wrapper around EVERY streaming model call — and normalizes the unicode dash family (U+058A, U+05BE, U+1400, U+1806, U+2010–U+2015, U+2E17, U+2E1A, U
Readme
@playform/ets-hook-dsh-normalize-dash
The DeepSeek Harness Plugin Family for PlayForm.
[!NOTE]
The dash normalizer for model output - a DeepSeek Harness plugin that hooks the
llm/streamwaterfall (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 →-.The normalize-dashes hook, at the injection point the harness itself does not have.
A CLASS flavor of the normalize family: the core's
Dashestable plus a configurablereplacement(default-).It also registers the family's
raw-writetool - a write wrapper with an explicitnormalizeparameter (default false = verbatim), exempt from the stream normalization by name likeedit.The @-sentence identity: Hook @ DSH @ Normalize @ Dash.
Where It Fits
Family position (the @-sentence Hook @ DSH @ Normalize @ Dash): a hook child of the
ets-plugin-dsh-factory service and the ets-hook-dsh-core
machinery; the first of six stream normalizer siblings.
One of six siblings in the stream-normalization family, all built on the shared machinery -
ets-plugin-dsh-factory for State/Append (and the named Schema helper,
shared: false, for the config), ets-hook-dsh-core for the Dashes table and the
generic Chunk/Block dispatch.
The normalize-dash is the family's CLASS flavor with a
replacement knob - the core's Dashes class applied verbatim; the
siblings share the same whole-chunk replacement approach:
| flavor | table | substitution |
| ----------------------------------------------------------------- | ---------------------- | ----------------------------- |
| ets-hook-dsh-normalize-dash (this bundle) | core Dashes class | → replacement (default -) |
| ets-hook-dsh-normalize-quotes | core Quotes MAP | curly → straight |
| ets-hook-dsh-normalize-ellipsis | core Ellipsis class | U+2026 → ... |
| ets-hook-dsh-normalize-spaces | core Spaces class | unicode spaces → " " |
| ets-hook-dsh-normalize-invisible | core Invisible class | removed (default "") |
| ets-hook-dsh-normalize-fullwidth | core Fullwidth MAP | full-width → half-width |
It is also the factory's first NON-MANIFEST module and its second consumer: it injects
["pluginFactory"] and uses only State (cell unwrap + shared Ledger/Enabled mappings + its
own fields) and Append.
NOT consumed, deliberately: Wire (fs/observed-hardwired - the family's
silence; the normalize-dash implements its own llm/stream registration), Attach (no jobs /
inflight / storage), Journal (the shared P5 record on every N > 0 pass), and
the gate/write/refresh/continue machinery (no fs/observed path).
The DSH plugin family is the DeepSeek Harness plugin layer of the PlayForm ecosystem:
TypeScript-first Source/ → Target/, the deterministic @playform build, prepublishOnly-only -
the same conventions as every other @playform package.
The Interplay
- The governance trio and the factory: pure additive - no shared event, no shared state. The
STREAM listener touches no files, so
fs/write-intent and
fs/observed never see it; the
raw-writeTOOL does write files, and does so through the built-in write's exact fs path - resolve → fs/write-intent → writeText → fs/observed - so the governance hooks treat its writes exactly like built-in write calls. - Other llm/stream listeners: the normalize-dash wraps the downstream result and always calls
next(), so it composes regardless of registration order; it neither short-circuits nor yields its own chunks. - Chunk identity: untouched chunks pass through by object identity; a rewritten delta/block is a shallow copy with only the text field replaced (the one exception: the raw-marker strip rewrites the arguments text while the count stays 0 - marker removal is not normalization)
Install
Terminal (registry)
pnpm add @playform/ets-hook-dsh-normalize-dash- Remote / dependency install (auto-activation):
pnpm add @playform/ets-hook-dsh-normalize-dashin a profile dir (+ the package name indsh.profile.bundles, ordsh plugin add) - installed automatically and activated at the next host start. - Local git clone: clone →
pnpm install --ignore-workspace(the bundle carries its own node_modules) → build (npx Build 'Source/**/*.ts' --ESBuild Configuration/ESBuild.ts) →dsh plugin --profile <name> add <this directory>. The linked checkout must carry its ownnode_modules:pnpm install --ignore-workspaceinside the bundle. - Tarball / npm:
dsh plugin --profile <name> add <tarball-or-spec>- the package ships builtTarget/(minified,.d.tstwins), so no build runs at install time. - Git: fetches sources, not builds - requires a
preparescript and anallowBuildsentry, or a published tarball.
Activation is at boot (bundle layers compose at host restart); verify with the
ets-hook-dsh-normalize-dash: activated (...) ledger line (the --dump-config verification path is
gone behind the app-managed profile guard - the ledger and the runtime's inspect providers are the
activation assessors).
A file: dependency alone is NOT activation.
Usage
The bundle is configured through its cordis.patch.yml row (or the profile's dsh.bundle
manifest) - the full config table is in The Config:
- insert:
- id: ets-hook-dsh-normalize-dash
name: "@playform/ets-hook-dsh-normalize-dash"
config:
log: true
logFile: ~/.dsh/ets-hook-dsh-normalize-dash.log
replacement: "-"
normalizeReasoning: trueThe shared Effect-TS plumbing - the runtime wiring, the stream machinery and the hook contract types - comes from the base ets-base-dsh package.
The Problem
The established file-hook approach 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 a file hook cannot: the llm/stream waterfall, where every
streaming model call passes through.
Unlike the governance family (ets-hook-dsh-package-governor,
ets-hook-dsh-package-pinner,
ets-hook-dsh-cargo-governor), 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 ──► `ets-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.optionsis NEVER touched: a LOOP-built request arrives deep-frozen (mutation throws) and its content is a pure function of the session log - listeners read it, never rewrite it.- Order preserved, no buffering: one chunk in, one chunk out; upstream throws propagate (the LlmRuntime normalizes adapter failures to a terminal error/aborted finish - a thrown-away stream writes no ledger line).
The transform
The core's Dashes class, verbatim - the same perl pattern (perl -CSD -pe), applied per text
segment:
[\u058A\u05BE\u1400\u1806\u2010-\u2015\u2E17\u2E1A\u2E3A-\u2E3B\u2E40
\u2E5D\u301C\u3030\u30A0\uFE31-\uFE32\uFE58\uFE63\uFF0D] → replacementArmenian 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 - a class match and replace, no lookahead, no context.
- 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
replacementcontaining$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
(Source/Function/Write.ts): 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), because the harness tools reject unknown keys |
The tool also takes a govern parameter - the per-call governance selection over the factory's
direct-govern registry (factory SCHEME.md §2.16–§2.17), applied after a successful write:
| govern | what the exec runs |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| absent or false (default) | NO governance chain — the current posture, the "skip everything" escape hatch (the write still emits fs/observed exactly like a built-in write, so the event-path hooks behave as always) |
| true or "all" | ALL registered governance steps for the target's basename (package.json: canonicalize + update; Cargo.toml: cargo + update) |
| ["canonicalize", ...] | ONLY the named steps (canonicalize, pin, cargo, update); unknown names are rejected by the schema and ignored by the registry |
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, and an empty registry makes the call a contained no-op.
The exec mirrors the built-in write's fs path exactly - ctx.fs.resolve → the fs/write-intent
waterfall (the observation policy's single-slot guarded-write decision) → the standing sandbox
policy (resolved like the built-in's no-escalation path; raw-write advertises no escalation of its
own) → ctx.fs.writeText(target, content, intent, exec.signal, policy) → the fs/observed emit
with the written version - so the governance family's fs/observed chain fires for these writes the
same way it fires for built-in writes.
exec.signal is honored end to end (a pre-aborted call never
writes).
The registration is readonly after registration; the presenters are pure and recompute the same selection-aware chain, so the diff card always matches the file.
Inject:
["pluginFactory", "fs", "tools"].
The Source Layout
Source/Library.ts - the loader contract (name/apply/Config/inject + default object)
Source/Function/Apply.ts - thin wire-up: Factory.State → activation proof → ctx.on("llm/stream")
→ ctx.tools.register(raw-write)
Source/Function/Normalize.ts - the async-generator stream wrapper + the per-stream count line
Source/Function/Chunk.ts - the thin per-chunk dispatch (delegates to the core's generic dispatch)
Source/Function/Replace.ts - the thin transform leaf (the core's Replace + Dashes, pinned)
Source/Function/Write.ts - the raw-write tool definition (built, registered in Apply; never
mutated after registration)
Source/Function/Rewrite.ts - the tool's selection-aware normalize step (the core's tables,
chained; the family's fixed order, ONLY the selected flavors)
Source/Variable/Config.ts - the module's own Schemastery schema (the factory's standalone
helper with `shared: false` - the minimal block)
Source/Interface/*.ts - Config/State shapes, the factory service view (chunk vocabulary
imported directly from @deepseek-ai/dsh-llm, type-only; the fs
vocabulary arrives through the factory's own declarations)The Pitfalls
- The stream gate rewrites model output only - it never touches disk; files already on disk are
the
normalize-filetool's job (orraw-write's explicitnormalizeparameter). - The count is per replaced code point - N counts matched characters, not output characters; N = 0 and an upstream throw both write no count line and no journal record.
- One class, one table - the em/en dash family is this flavor's class; the quotes map never touches a dash (and this table never touches a quote) - run the flavors the session's characters call for.
- The replacement is a literal string - a
$pattern inreplacementis never interpreted (the function replacer inserts it verbatim).
The Config
| Field | Type | Default | Volatile | Meaning |
| ------------------------ | ------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| log | boolean | true | yes | write the durable ledger file |
| logFile | string | ~/.dsh/ets-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 three exemptions: edit, raw-write and normalize-file calls pass through by identity (the edit tool's old_string must match the real file bytes; the raw-write tool owns its content normalization through its explicit normalize parameter; the normalize-file tool's arguments carry a file path - a normalized dash inside a filename would corrupt the target); and a call whose arguments open with the {"__normalize":false first-key marker passes through unnormalized, the marker entry stripped) - 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.
The Granular Scopes (all volatile)
The scopes object gates every arm of the ets-hook-dsh-normalize-dash hook independently — the defaults are exactly the
pre-scopes behavior, and because the schema shape is identical in every family hook, one grouped
override in the profile patch turns the whole family off in one edit (see the patch fragment at the
end of this section). The whole object carries the volatile marker (its leaves stay plain —
schemastery rejects volatile leaves under an enclosing volatile field): one leaf change commits one
loader/volatile-update dispatch whose whole object the State's refresh listener re-reads — live,
silent, no remount, no new activation line.
| Scope | Default | Gates |
| ------------ | ------- | --------------------------------------------------------------------- |
| conversation | on | text-delta text + the assembled text/reasoning blocks |
| reasoning | on | the reasoning-delta sub-gate (ANDed with normalizeReasoning) |
| toolInput | off | the tool-call arguments arm (OR-ed with normalizeToolArguments) |
| toolResults | off | the tools/post-execute arm (tool results in the transcript) |
| files | on | inert in a stream flavor (present so one patch override drives the whole family) |
| batch | off | the agent/pre-step arm (the incoming step batch) |
normalizeToolArguments is kept and OR-ed in (effective toolInput = the field ||
scopes.toolInput), so every existing profile keeps working.
The per-call {"__normalize":false first-key marker is the tool-args arm's escape hatch — the
"leave that search alone" control: a deliberate search for an irregular character (an em dash, a
curly quote, a full-width form as a PATTERN) survives byte-exact while the rest of the call stream
normalizes. Documented edges: the marker must be the FIRST key, and a pathologically tiny first
delta (shorter than the marker) is invisible to the startsWith probe — the call normalizes
normally.
toolResultsExempt (volatile): a tool result whose exec name is listed passes the tool-results arm
by identity — read, edit, raw-write, normalize-file, ccr_retrieve, subagent,
ask_user_question by default.
The Overpass Arms
Two additional listeners, both registered at apply and both opt-in (gated at RUNTIME on the scope cells, so flipping a scope lands through the P10 volatile-refresh without any re-registration):
- Tool results —
tools/post-execute,{ prepend: true }(OUTERMOST vs the ccr hook's own prepended gate: the ccr compression sees the NORMALIZED bytes; the<<<CCR:marker guard makes any load order safe). Accepted, non-value-carrying decisions only; TEXT blocks only (never tool-call/argument blocks); N = 0 is a true no-op — no ledger line; fail-open. The invisibility contract: the replacement is PRE-COMMIT — the waterfall settles before the result is appended to the durable log — so the model only ever sees the normalized output and never notices the replacement (the write-path counterpart is the governance trio's never-notify precedent: the tool result is built from the author's own content, and the observation-policy's version record is refreshed by the re-emitted fs/observed). - Step batch —
agent/pre-step, default order. Whilescopes.batchis on every incoming batch is normalized; the command's one-shot arm fires ONCE regardless of the scope value and is cleared after one use. Text blocks only; never the assistant/tool history.
The dash overpass lines (N > 0 only):
ets-hook-dsh-normalize-dash: overpass: normalized N char(s) in tool result <toolName> <callId>
ets-hook-dsh-normalize-dash: overpass: normalized N char(s) in step batch (turn T step S)
ets-hook-dsh-normalize-dash: overpass: swept K file(s), normalized M char(s)The normalize-overpass Command
Registered through the commands service when it is present (probe-once — a missing service simply
skips the registration). Grammar:
| Input | Effect |
| ------------------ | --------------------------------------------------------------------------------- |
| files <path…> | declined — the sweep is owned by the normalize-file hook |
| batch | arm the one-shot step-batch overpass (consumed by the next incoming batch) |
| results on\|off | flip scopes.toolResults via the settings service / config editor — never direct State mutation; absent services → reports the settings-UI path |
| scopes | read-only report of the current effective values (the activation line never changes) |
The Patch-Level Global Off
- id: ets-hook-dsh-normalize-dash
config:
scopes:
conversation: false
reasoning: false
toolInput: false
toolResults: false
files: false
batch: falseIn Action
One stream, one transformation.
The model emits prose with typographic dashes; the live UI and the transcript receive the ASCII forms:
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 (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:
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.
When a stream finishes normally with replacements made, the ledger gets the count line:
ets-hook-dsh-normalize-dash: normalized 4 dash char(s) in one streamThe selection can also be FINE-GRAINED - the same call that wrote the prose above, done with the raw-write tool per call.
The literal characters in these examples are written with the RAW-WRITE tool itself (this is exactly the stream-exempt path the tool exists for):
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 through the factory's direct-govern
registry after the write lands; govern: ["pin"] runs only the pinner's chain pass;
govern: ["update"] runs only the update stage.
The default — govern absent — writes the file and
lets the fs/observed event path govern it exactly as before:
raw-write package.json, govern: ["canonicalize"]
→ the write completes verbatim (or normalized, per `normalize`)
→ then the governor's chain pass runs: canonicalizes the chain-governed
pins (ledger: `governed <path> → <version>`) — best-effort, contained,
never affecting the write's outcome
raw-write Cargo.toml, govern: ["cargo", "update"]
→ then the cargo module's chain + normalization pass and its update stage
run, in the registered orderThe Ledger
Two lines, both written through the factory's Append (the ets-hook-dsh-normalize-dash: prefix is the
logger's <State.Module>:; the durable file line is [<ISO>] <message>):
ets-hook-dsh-normalize-dash: activated (replacement=-, reasoning=on, toolArgs=off, logFile=~/.dsh/ets-hook-dsh-normalize-dash.log)
ets-hook-dsh-normalize-dash: normalized N dash char(s) in one streamThe 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).
Every N > 0 pass also journals one normalized record into the shared package_governance v2
domain - event normalized, path = the empty stream-level path, detail byte-identical to the
count line - best-effort: with no storage facility the record buffers or drops, and the human
ledger stays the complete record.
License 📜
CC0-1.0.
