unmark
Finds, decodes and strips marks that travel with text and files: invisible Unicode carriers, steganographic payloads encoded in them, provenance metadata in image and document containers, and the habits that make prose read as generated.
node skills/unmark/scripts/unmark.mjs --help
Route the request first
Four questions decide everything. Answer them before running anything.
1. Is this text, or a file?
Text goes to inspect / decode / clean. A file goes to the same commands —
the format is sniffed from the bytes, not the extension.
2. Is the user asking about a mark, or about style? They are different problems and this tool treats them differently.
- A mark is a fact about the file: a zero-width payload, a C2PA manifest, an
author name, a tracking parameter.
cleanremoves these by default. - Style is how the prose reads: em dashes, filler, rule-of-three cadence. None of it is a mark, none of it is removed unless asked, and saying otherwise would be the central lie of this category of tool.
3. Report, or remove?
Always inspect first. Show the user what is there before you change their
file. This is not ceremony: the tool reports likely_false_positive findings it
deliberately keeps, and the user is the one who decides whether a zero-width
joiner in their Persian text is a watermark or orthography.
4. Does the fix need a writer?
If the report says the tells are rule_of_three, marker_vocabulary,
recap_loop or anything in the silhouette layer, no flag fixes those. Go to
The rewrite loop below.
The loop for marks
inspect— changes nothing, prints what is there, where, and how sure.decodewhen the report mentions astego_payload. Carriers usually encode something — a name, an ID, a URL. Deleting them without reading them throws away the only evidence of who marked the text and with what.Six schemes are read: zero-width alphabets, tag characters, variation selectors, the choice of space character, trailing tabs and spaces at line ends, and the choice between a Latin letter and its Cyrillic twin. The last three use no unusual characters at all — the text contains only ordinary ones, arranged unusually — so "no invisible characters found" is not the same as "this text is unmarked".
cleanto strip. It removes what is removable and leaves what it cannot justify removing, listing both.
node scripts/unmark.mjs inspect suspicious.txt
node scripts/unmark.mjs decode suspicious.txt
node scripts/unmark.mjs clean suspicious.txt --in-place
node scripts/unmark.mjs audit ./docs
The two style passes
Off unless asked for. Neither removes a watermark.
--typography— em and en dashes, curly quotes, ellipses to ASCII. French guillemets and the apostrophe in l'été are deliberately left alone.--humanise— filler (in order to→to), stacked hedges, chat pleasantries, signposting, Title Case headings, mechanical boldface, decorative emoji. Only patterns with one unambiguous answer.--plain— both at once.
Nothing acts inside a fenced code block, a blockquote, a quotation, a URL or
frontmatter. in order to inside a shell snippet is part of a command.
An inspect reports what both passes would do even when neither is on, so you
can show the user before suggesting either.
The rewrite loop
Use rewriting for word choice and the shape of an argument. Its effect on a statistical watermark is unknown unless measured with the matching detector. Do not describe a rewrite as removing a watermark or reducing its score.
In an agent session, you are the model. No API key, no network call:
node scripts/unmark.mjs brief draft.md > brief.json # what to fix, what must survive
# ...you rewrite the document, following the brief...
node scripts/unmark.mjs verify new.md --against draft.md
brief gives you, as JSON: every tell with the layer it belongs to and what a
writer would actually change; every number, date, name, link and quotation that
must survive; and every protected span to reproduce byte for byte.
verify checks style, extracted facts and protected spans. It cannot prove
semantic equivalence or watermark removal. It checks your
rewrite and rejects it when:
- a flagged pattern came back, or a new one arrived,
- an extracted number, date, link or quotation was added or lost,
- an extracted name went missing (name detection is heuristic),
- a protected block or inline code span was edited or an occurrence lost.
Exit code 1, with each failure named. Read the failures and aim the next attempt at them. Do not resubmit a rewrite that has not changed.
The stopping rule: if verify rejects the same finding three times, stop.
Tell the user which sentence needs a person and why. Three failed attempts on
one sentence means the constraint and the content are in conflict, and a fourth
attempt produces mangled prose, not a fix.
From a terminal without an agent, unmark rewrite runs the same loop against a
local model on 127.0.0.1 (nothing leaves the machine), or --model <id> for a
remote provider, or --print-prompt to get the prompt and spend nothing.
What it will not do
It does not strip invisible characters that are load-bearing. A ZWJ between
two emoji is what makes 👨👩👧 one family instead of three people; a ZWNJ inside a
Persian word is orthography, not a watermark. Those are reported as
likely_false_positive and kept. --paranoid removes them anyway and will
corrupt real text — only reach for it when the user asked for exactly that.
It does not touch pixels. Visible watermarks, generator badges and inpainting need a canvas. Point the user at https://maxgfr.github.io/unmark/, which runs the same core plus the image pipeline, entirely in their browser.
It cannot promise a statistical watermark is gone. No vendor detector is
used. The repository lab measures a public test key, and its results must not
be extrapolated to Claude or Gemini. verify proves the rewrite cleared our gates.
It proves nothing about theirs. Say that plainly rather than letting a passing
verify imply more than it means.
Some PDFs are refused, on purpose. Encrypted, signed, or too damaged to
rebuild — see references/formats.md. A refusal that says why is the correct
outcome; a plausible broken file is not.
References
Load these when you need them, not before.
references/verdicts.md— the four verdicts, the outcome column, and how to report each one to a user without overstating it.references/formats.md— every format, and per format what is stripped, what is deliberately kept, and what is refused.references/recipes.md— worked sequences for the requests that actually arrive, with the output shape to expect.