Strips filler words, stutters and your usual typos from dictated prompts before Claude reads them.

Cleans dictated prompts (Wispr Flow etc.) before Claude reads them:
uh, um, erm, hmm, , you know,) and tidies the commas and capitals they leave behindthe the → the), but keeps doubles that are often meant (that that, had had)inline code, URLs, paths, --flags, @mentions or file namesclaude -p) prompts are never rewrittenclaude -p) prompts are never read or changed.clean.ts plus any you add with /dictation add. A toast shows what changed. A prompt with nothing to clean is passed through unchanged.Commands: /dictation (status), /dictation off|on, /dictation add wrong=right, /dictation remove wrong, /dictation test <text>.
At the prompt of a Claude Code terminal session:
/plugin install dictation-cleaner --marketplace hansraj316/hansraj-mods
Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install dictation-cleaner --marketplace hansraj316/hansraj-mods.
hooks/register.ts 64 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { clean, summary } from './clean'
4
5const OFF_KEY = 'off'
6const WORDS_KEY = 'words'
7
8async function extraWords($: EngineInterface): Promise<Record<string, string>> {
9 return ((await $.store.get(WORDS_KEY)) as Record<string, string> | undefined) ?? {}
10}
11
12const USAGE = 'Usage: /dictation [on | off | add <wrong>=<right> | remove <wrong> | test <text>]'
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 const started = await next(e)
17 await $.command.register({
18 name: 'dictation',
19 description: 'Dictation cleaner: on/off, add a correction, or test a sentence',
20 argumentHint: '[on|off|add wrong=right|remove wrong|test <text>]',
21 })
22 return started
23 })
24
25 on('command.run', { command: 'dictation' }, async ($, e) => {
26 const [action = '', ...rest] = e.args.trim().split(/\s+/)
27 const arg = rest.join(' ')
28 if (action === 'off' || action === 'on') {
29 await $.store.set(OFF_KEY, action === 'off')
30 return { text: `Dictation cleaner is ${action}.` }
31 }
32 if (action === 'add') {
33 const pair = arg.match(/^([A-Za-z]+)\s*=\s*(\S.*)$/)
34 if (!pair) return { text: USAGE }
35 const words = { ...(await extraWords($)), [pair[1]!.toLowerCase()]: pair[2]!.trim() }
36 await $.store.set(WORDS_KEY, words)
37 return { text: `Will correct "${pair[1]}" to "${pair[2]!.trim()}".` }
38 }
39 if (action === 'remove') {
40 const words = { ...(await extraWords($)) }
41 delete words[arg.toLowerCase()]
42 await $.store.set(WORDS_KEY, words)
43 return { text: `Removed "${arg}".` }
44 }
45 if (action === 'test') return { text: clean(arg, await extraWords($)).text }
46 if (action !== '') return { text: USAGE }
47 const isOff = (await $.store.get(OFF_KEY)) === true
48 const words = Object.entries(await extraWords($)).map(([w, r]) => `${w}→${r}`)
49 return {
50 text: `Dictation cleaner is ${isOff ? 'off' : 'on'}. Your corrections: ${words.join(', ') || 'none yet'}.\n${USAGE}`,
51 }
52 })
53
54 on('prompt.submit', async ($, e, next) => {
55 // Only what the person typed or dictated; slash commands and hidden prompts take no rewrite.
56 if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
57 if ((await $.store.get(OFF_KEY)) === true) return next(e)
58 const result = clean(e.text, await extraWords($))
59 if (result.text === e.text || result.text === '') return next(e)
60 $.ui.toast(summary(result))
61 return next({ ...e, text: result.text })
62 })
63}
64hooks/clean.ts 128 lines1// Pure text cleaning: no `$`, so every rule is tested on plain strings.
2
3/** Typos from this user's own prompts, then common English ones. Keys are lowercase. */
4export const TYPOS: Readonly<Record<string, string>> = {
5 defauly: 'default',
6 merege: 'merge',
7 thenb: 'then',
8 tehre: 'there',
9 compelte: 'complete',
10 fromom: 'from',
11 artifcat: 'artifact',
12 artifcats: 'artifacts',
13 artifcts: 'artifacts',
14 incorporte: 'incorporate',
15 arcchitercture: 'architecture',
16 architecure: 'architecture',
17 resturants: 'restaurants',
18 resturatants: 'restaurants',
19 kirkaldn: 'Kirkland',
20 teh: 'the',
21 adn: 'and',
22 taht: 'that',
23 recieve: 'receive',
24 seperate: 'separate',
25 definately: 'definitely',
26 occured: 'occurred',
27 untill: 'until',
28 wich: 'which',
29 becuase: 'because',
30 enviroment: 'environment',
31 dependancy: 'dependency',
32 dependancies: 'dependencies',
33 succesful: 'successful',
34 sucessful: 'successful',
35 udpate: 'update',
36 upate: 'update',
37 fucntion: 'function',
38 funtion: 'function',
39 lenght: 'length',
40 reponse: 'response',
41 repsonse: 'response',
42 pls: 'please',
43}
44
45export type Cleaned = { text: string; fillers: number; stutters: number; fixes: string[] }
46
47// Spoken hesitations, with the comma or ellipsis dictation leaves after them.
48// Marks where a word was removed, so tidying touches only those places.
49const GAP = '\u0001'
50
51const FILLER = /(^|[\s(])(?:u+h+m*|u+m+|e+r+m+|h+m+)\b(?:[,.]{1,3}|…)?(?=\s|$)/gi
52const YOU_KNOW = /,\s*you know,/gi
53// A word said twice in a row; doubles that are often meant are left alone.
54const STUTTER = /\b([a-z']+)(\s+\1\b)+/gi
55const MEANT_TWICE = new Set(['that', 'had', 'is', 'very', 'no', 'so', 'bye', 'ha', 'really', 'go'])
56
57// Code, URLs, paths, flags and @mentions are never touched.
58const PROTECTED = /```[\s\S]*?```|`[^`\n]*`|https?:\/\/\S+|(?:~|\.{1,2})?\/[\w./@-]+|@[\w./-]+|(?<!\w)--?[\w-]+|\b\w+\.\w{1,5}\b/g
59
60function cleanProse(prose: string, extra: Readonly<Record<string, string>>, out: Cleaned): string {
61 let text = prose.replace(FILLER, (_, lead: string) => {
62 out.fillers++
63 return lead + GAP
64 })
65 text = text.replace(YOU_KNOW, () => {
66 out.fillers++
67 return ',' + GAP
68 })
69 text = text.replace(STUTTER, (all, word: string) => {
70 if (MEANT_TWICE.has(word.toLowerCase())) return all
71 out.stutters++
72 return word
73 })
74 text = text.replace(/\b[a-z]+\b/gi, word => {
75 const fix = extra[word.toLowerCase()] ?? TYPOS[word.toLowerCase()]
76 if (!fix || fix === word) return word
77 const fixed = word[0] === word[0]!.toUpperCase() && fix[0] === fix[0]!.toLowerCase() ? fix[0]!.toUpperCase() + fix.slice(1) : fix
78 out.fixes.push(`${word}→${fixed}`)
79 return fixed
80 })
81 return text
82}
83
84/**
85 * Tidies the gaps removed words left: the spaces and commas around them, and
86 * a sentence that now starts lowercase. Text away from a gap is left as typed.
87 * `isFirst` marks the stretch that opens the prompt, where a gap at the very
88 * start is a sentence start.
89 */
90function tidy(text: string, isFirst: boolean): string {
91 const start = isFirst ? '^|' : ''
92 return text
93 .replace(new RegExp(`(${start}[.?!]\\s+|\\n[ \\t]*)((?:\\s*${GAP}[\\s,;:]*)+)([a-z])`, 'g'), (_, lead: string, _gap: string, letter: string) => lead + letter.toUpperCase())
94 .replace(new RegExp(`(${start}[.?!]\\s+|\\n[ \\t]*)(?:\\s*${GAP}[\\s,;:]*)+`, 'g'), '$1')
95 .replace(new RegExp(`[ \\t]*${GAP}[ \\t]*([,.?!;:])`, 'g'), '$1')
96 .replace(new RegExp(`([,;:])[ \\t]*${GAP}[ \\t]*[,;:]`, 'g'), '$1')
97 .replace(new RegExp(`(\\S)[ \\t]*${GAP}[ \\t]*(?=\\S)`, 'g'), '$1 ')
98 .replace(new RegExp(`[ \\t]*${GAP}[ \\t]*`, 'g'), '')
99}
100
101export function clean(input: string, extra: Readonly<Record<string, string>> = {}): Cleaned {
102 const out: Cleaned = { text: input, fillers: 0, stutters: 0, fixes: [] }
103 const parts: { text: string; isProse: boolean }[] = []
104 let last = 0
105 for (const match of input.matchAll(PROTECTED)) {
106 parts.push({ text: input.slice(last, match.index), isProse: true }, { text: match[0], isProse: false })
107 last = match.index + match[0].length
108 }
109 parts.push({ text: input.slice(last), isProse: true })
110 const cleaned = parts.map(part => (part.isProse ? cleanProse(part.text, extra, out) : part.text))
111 // A prompt with nothing to clean stays byte for byte.
112 if (out.fillers + out.stutters + out.fixes.length === 0) return out
113 out.text = cleaned
114 .map((text, i) => (parts[i]!.isProse ? tidy(text, i === 0) : text))
115 .join('')
116 .trim()
117 return out
118}
119
120export function summary(c: Cleaned): string {
121 const parts = [
122 c.fillers ? `${c.fillers} filler${c.fillers > 1 ? 's' : ''}` : '',
123 c.stutters ? `${c.stutters} repeat${c.stutters > 1 ? 's' : ''}` : '',
124 c.fixes.length ? c.fixes.slice(0, 4).join(', ') + (c.fixes.length > 4 ? '…' : '') : '',
125 ].filter(Boolean)
126 return `Cleaned ${parts.join(' · ')}`
127}
128