Strips spoken filler words (uh, um, hmm, mm) and stutter repeats from prompts before they reach the model

A Claude Code mod that removes spoken filler words from your prompts, made for voice dictation.
uh, um, uhm, erm, hmm, mm (and their trailing commas) plus stutter repeats like "check check" or "where you where you" before the prompt reaches the model.To keep a literal "um" in a prompt, put it in backticks.
The desktop app draws its own prompt box, so there the cleanup happens only when you send.
The mod is one hooks module, hooks/register.ts, with two hooks:
prompt.submit: rewrites the text of every prompt you send, removing fillers and stutter repeats. It skips slash commands and prompts Claude Code sends on its own. Since prompt.submit is also a call other plugins can make ($.prompt.submit), prompts that other plugins submit pass through this cleanup too.prompt.edit: in the terminal prompt box, cleans text that lands in one go (dictation, a paste of plain prose) and drops a filler word when you type the space after it. Pasted code and anything inside backticks is left as is.It reads no files, runs no commands, and sends nothing anywhere.
At the prompt of a terminal Claude Code session:
/plugin install uh-filter --marketplace amirzak/claude-code-voice-uh-filter
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install uh-filter --marketplace amirzak/claude-code-voice-uh-filter
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with amirzak/claude-code-voice-uh-filter, then Add plugin.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.ts 39 lines1import type { Register } from 'claude-code'
2
3import { cleanInsertedText, dropTypedFiller, stripFillers } from './filter'
4
5export const register: Register = on => {
6 // Terminal prompt box. A chunk landing at once (dictation, a paste) is cleaned
7 // before it shows; when typing, a filler word goes as soon as a space ends it.
8 on('prompt.edit', async ($, e, next) => {
9 if (!e.key) {
10 const inputText = cleanInsertedText(e.inputText)
11
12 return next(inputText === undefined ? e : { ...e, inputText })
13 }
14
15 const box = await next(e)
16 const isTypedSpace = e.inputText === ' ' && e.start === e.end
17
18 return (isTypedSpace && dropTypedFiller(box)) || box
19 })
20
21 // Last pass on submit, for anything the box didn't catch (and for the desktop app,
22 // whose prompt box mods can't reach).
23 on('prompt.submit', ($, e, next) => {
24 // Slash commands ignore a rewrite, and meta prompts aren't the person's words.
25 if (e.command || e.isMeta) {
26 return next(e)
27 }
28
29 let text = e.text
30 try {
31 text = stripFillers(e.text)
32 } catch {
33 // A filter bug must never cost the person their prompt.
34 }
35
36 return next(text === e.text ? e : { ...e, text })
37 })
38}
39hooks/filter.ts 88 lines1// Spoken fillers, matched as whole tokens: "uh", "uhh", "um", "umm", "uhm", "erm", "hmm", "mm".
2// A hyphen on either side leaves it alone, so "uh-huh" and "uh-oh" survive.
3const FILLER = /(?<![\w-])(?:u+h+m*|u+m+|e+r+m+|h+m+|m{2,})(?![\w-])[,.]?/gi
4
5// One to three words said twice in a row: "check check", "where you where you".
6const REPEAT = /\b(\w+(?:\s+\w+){0,2})(?:[\s,]+\1\b)+/gi
7
8// Single words that legitimately double up in English.
9const KEEP_DOUBLED = new Set(['that', 'had', 'is', 'do', 'very', 'no', 'bye'])
10
11// Inline code and fenced blocks are passed through untouched.
12const CODE = /(```[\s\S]*?```|`[^`\n]*`)/
13
14function cleanProse(text: string): string {
15 return text
16 .replace(FILLER, '')
17 .replace(REPEAT, (match, phrase: string) =>
18 !phrase.includes(' ') && KEEP_DOUBLED.has(phrase.toLowerCase()) ? match : phrase,
19 )
20 .replace(/[ \t]{2,}/g, ' ')
21 .replace(/[ \t]+([,.;:!?])/g, '$1')
22 .replace(/([,;:])(?:\s*[,;:])+/g, '$1')
23 .replace(/^[ \t]*[,;:][ \t]*/gm, '')
24 .replace(/[ \t]+(?=\n)/g, '')
25}
26
27export function stripFillers(text: string): string {
28 const cleaned = text
29 .split(CODE)
30 .map((part, i) => (i % 2 === 1 ? part : cleanProse(part)))
31 .join('')
32 .trim()
33
34 if (cleaned === '') {
35 return text
36 }
37
38 const wasCapitalized = /^[A-Z]/.test(text.trimStart())
39
40 return wasCapitalized ? cleaned.charAt(0).toUpperCase() + cleaned.slice(1) : cleaned
41}
42
43// Only plain spoken prose: letters, digits, spaces and sentence punctuation.
44// Anything else (braces, operators, newlines) is likely pasted code and kept as is.
45const PROSE = /^[\p{L}\p{N} ,.'’?!-]+$/u
46
47// Cleans a chunk landing in the prompt box at once (dictation or a paste),
48// keeping its edge spaces so it still joins the draft around it.
49// Returns undefined when the chunk should go in untouched.
50export function cleanInsertedText(inputText: string): string | undefined {
51 if (inputText.length < 2 || !PROSE.test(inputText)) {
52 return undefined
53 }
54
55 const lead = /^ */.exec(inputText)?.[0] ?? ''
56 const trail = / *$/.exec(inputText)?.[0] ?? ''
57 const body = inputText.trim()
58 const cleaned = stripFillers(body)
59
60 if (cleaned === body) {
61 return FILLER_ONLY.test(body) ? '' : undefined
62 }
63
64 return lead + cleaned + trail
65}
66
67// A chunk that is nothing but fillers, which stripFillers keeps rather than empty.
68const FILLER_ONLY = /^(?:(?:u+h+m*|u+m+|e+r+m+|h+m+|m{2,})[,.]?\s*)+$/i
69
70// The word just before the caret, when it is a filler followed by the space just typed.
71const TYPED_FILLER = /(^|\s)((?:u+h+m*|u+m+|e+r+m+|h+m+|m{2,})[,.]?) $/i
72
73// After a space is typed: drops the filler word it ends, with that space,
74// so "hello um " becomes "hello ". Skipped inside an open inline-code span.
75export function dropTypedFiller(box: { text: string; cursor: number }): { text: string; cursor: number } | undefined {
76 const before = box.text.slice(0, box.cursor)
77 const match = TYPED_FILLER.exec(before)
78 const isInCode = (before.match(/`/g)?.length ?? 0) % 2 === 1
79
80 if (!match || isInCode) {
81 return undefined
82 }
83
84 const start = box.cursor - (match[2]?.length ?? 0) - 1
85
86 return { text: box.text.slice(0, start) + box.text.slice(box.cursor), cursor: start }
87}
88