@playform/ets-hook-dsh-normalize-invisible
v0.1.0
Published
The invisible hook: a DeepSeek Harness plugin that hooks the llm/stream waterfall — the interceptable wrapper around EVERY streaming model call — and normalizes the zero-width/invisible character family (U+00AD soft hyphen, U+200B–U+200F zero-width spaces
Readme
@playform/ets-hook-dsh-normalize-invisible
The DeepSeek Harness Plugin Family for PlayForm.
[!NOTE]
The invisible-character 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 zero-width/invisible character family in model output, live in the transcript - by REMOVING it: zero-width spaces, joiners, bidi controls and the BOM character simply vanish (the default replacement is the empty string).A CLASS flavor of the normalize family: the core's
Invisibleclass plus a configurablereplacementstring (default""- removal).The @-sentence identity: Hook @ DSH @ Normalize @ Invisible.
The family's
raw-writetool (registered byets-hook-dsh-normalize-dash) bypasses this flavor's transforms too - the exemption is family-wide: the core'sStream/Chunk+Stream/Blockpass everyraw-writecall through by identity, so the tool's explicitnormalizeparameter (default false = verbatim) is the only normalization it applies.
Where It Fits
Family position (the @-sentence Hook @ DSH @ Normalize @ Invisible): a hook child of the
ets-plugin-dsh-factory service and the ets-hook-dsh-core
machinery; the fifth of the 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 Invisible class and
the generic Replace/Chunk/Block dispatch:
| flavor | table | substitution |
| ----------------------------------------------------------------- | ---------------------- | ----------------------------- |
| ets-hook-dsh-normalize-dash | 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 (this bundle) | core Invisible class | removed (default "") |
| ets-hook-dsh-normalize-fullwidth | core Fullwidth MAP | full-width → half-width |
A non-manifest factory consumer: it injects ["pluginFactory"] and uses State (cell
unwrap + shared Ledger/Enabled mappings + its own fields), Append and
Journal (the shared P5 record on every N > 0 pass); the config is composed
by the factory's standalone Schema helper with shared: false - the minimal block, no
fs/observed
dead fields.
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.
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.
Install
Terminal (registry)
pnpm add @playform/ets-hook-dsh-normalize-invisible- Remote / dependency install (auto-activation):
pnpm add @playform/ets-hook-dsh-normalize-invisiblein 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 installinside the bundle (this bundle's ownpnpm-workspace.yaml-packages: [.],nodeLinker: hoisted- keeps the install local) →npm run prepublishOnlyto buildTarget/→dsh plugin --profile <name> add <this directory>and a restart.
Activation is at boot; verify with the ets-hook-dsh-normalize-invisible: 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).
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-invisible
name: "@playform/ets-hook-dsh-normalize-invisible"
config:
log: true
logFile: ~/.dsh/ets-hook-dsh-normalize-invisible.log
replacement: ""The 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
Zero-width and format characters carry no visible width - which makes them the perfect smugglers.
A zero-width joiner inside a file path, a right-to-left mark inside a command, the U+202E visual-spoofing override in what looks like plain text: none of them show up in the transcript, all of them change what a parser, shell or diff sees.
This flavor deletes the whole family before it reaches the 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
│ for await (chunk of upstream)
▼
CoreChunk(chunk, transform, reasoning, toolArgs, raw) the core's per-chunk dispatch
│
├─ text-delta ────────► Replace(text, Invisible, "") 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, Invisible, "") 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", "raw-write"
│ or "normalize-file" passes through BY IDENTITY
│ (the edit tool's `old_string` must match the real
│ file bytes; the raw-write tool owns its content
│ normalization; 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` 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-invisible: normalized N invisible char(s) in one stream`The transform
The core's Invisible class (see ets-hook-dsh-core), applied per text segment
through the core's generic class-to-string Replace:
[\u00AD\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060\uFEFF] → replacement| U+ codepoints | Characters | | ------------- | ------------------------------------------------------------------------------------------------- | | 00AD | soft hyphen | | 200B | zero-width space | | 200C | zero-width non-joiner | | 200D | zero-width joiner | | 200E | left-to-right mark | | 200F | right-to-left mark | | 202A-202E | LRE, RLE, PDF, LRO, RLO (bidi embedding controls; U+202E is the classic visual-spoofing override) | | 2060 | word joiner | | FEFF | zero-width no-break space (the BOM character) |
All are removed (default replacement: "" - REMOVAL) per text segment:
- No context rules, chunk-boundary-safe - single-character replacement, no lookahead, 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 Pitfalls
- Removal is the replacement -
replacement: ""(the default) removes the family (soft hyphen, zero-width spaces and joiners, bidi controls, word joiner, BOM); a hot edit to a non-empty replacement turns the pass into a replace instead. - A dependency alone is NOT activation - the package must be in
dsh.profile.bundles(or the patch row inserted), or no listener registers and no ledger exists. - The stream gate rewrites model output only - it never touches disk; files already on disk are
the
normalize-filetool's job. - The count is per replaced code point - N counts matched characters; N = 0 and an upstream throw both write no count line and no journal record.
The Config
| Field | Type | Default | Volatile | Meaning |
| ------------------------ | ------- | ----------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| log | boolean | true | yes | write the durable ledger file |
| logFile | string | ~/.dsh/ets-hook-dsh-normalize-invisible.log | yes | the invisible ledger (separate from the family's logs) |
| replacement | string | "" | yes | the transform's only knob - default REMOVAL; hot-editable |
| 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 factory's State builder unwraps them defensively.
The Granular Scopes (all volatile)
The scopes object gates every arm of the ets-hook-dsh-normalize-invisible 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 invisible overpass lines (N > 0 only):
ets-hook-dsh-normalize-invisible: overpass: normalized N char(s) in tool result <toolName> <callId>
ets-hook-dsh-normalize-invisible: overpass: normalized N char(s) in step batch (turn T step S)
ets-hook-dsh-normalize-invisible: 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-invisible
config:
scopes:
conversation: false
reasoning: false
toolInput: false
toolResults: false
files: false
batch: falseIn Action
One stream, one deletion.
The model emits text carrying invisible smugglers; the live UI and the transcript receive clean text - and the two lines below look identical in print, because the removed characters are invisible.
The incoming line carries, in order: a soft hyphen (U+00AD), a zero-width space (U+200B), a zero-width joiner (U+200D), a left-to-right mark (U+200E), a right-to-left mark (U+200F), a word joiner (U+2060) and a BOM character (U+FEFF):
text-delta in (what the model wrote):
passsword: correct
text-delta out (what reaches the transcript):
"password: correct"In the incoming line the smugglers sit inside the word password and around the colon; a diff would
see a password that does not match the author's text, a parser would tokenize differently, a shell
could splice a command.
After the pass all of them are simply gone - the output line is what the reader should see.
When a stream finishes normally with replacements made, the ledger gets the count line:
ets-hook-dsh-normalize-invisible: normalized 7 invisible char(s) in one streamThe replacement is hot-editable (the default "" removes; a non-empty value such as "?" would
mark each smuggler's position for debugging), and the same pass runs over reasoning deltas when
normalizeReasoning is on and over tool-call arguments when the example patch enables
normalizeToolArguments - with the edit, raw-write and normalize-file names exempt and raw-marker calls passing through
unnormalized.
The Ledger
Two lines, both written through the factory's Append (the ets-hook-dsh-normalize-invisible: prefix
is the logger's <State.Module>:; the durable file line is [<ISO>] <message>):
ets-hook-dsh-normalize-invisible: activated (replacement=, reasoning=on, toolArgs=off, logFile=~/.dsh/ets-hook-dsh-normalize-invisible.log)
ets-hook-dsh-normalize-invisible: normalized N invisible char(s) in one streamThe empty default replacement renders as the empty replacement=.
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).
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.
