SLOPSHOPPER

dictation-cleaner

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

newcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dictation-cleaner
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dictation ⎿ dictation-cleaner: Dictation cleaner is on. Your corrections: none yet. ⎿ dictation-cleaner: Usage: /dictation [on | off | add <wrong>=<right> | remove <wrong> | test <text>] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dictation-cleaner

Cleans dictated prompts (Wispr Flow etc.) before Claude reads them:

  • removes fillers (uh, um, erm, hmm, , you know,) and tidies the commas and capitals they leave behind
  • collapses stutters (the the → the), but keeps doubles that are often meant (that that, had had)
  • fixes your usual typos (compelte, merege, defauly, tehre, artifcat, arcchitercture…) plus common English ones
  • never touches code fences, inline code, URLs, paths, --flags, @mentions or file names
  • a toast shows what changed; slash commands, hidden prompts and headless (claude -p) prompts are never rewritten

What the mod reads, changes and sends

  • What it reads: the text of each prompt you type or dictate, at the moment you submit it. Slash commands, hidden prompts and headless (claude -p) prompts are never read or changed.
  • What it changes: it rewrites that prompt text before Claude sees it, removing fillers and stutters and applying the typo corrections listed in clean.ts plus any you add with /dictation add. A toast shows what changed. A prompt with nothing to clean is passed through unchanged.
  • What it stores: your on/off setting and the corrections you add, in the plugin's own local store on your machine.
  • What it sends out: nothing. The mod makes no network calls, runs no programs and reads no files.

Commands: /dictation (status), /dictation off|on, /dictation add wrong=right, /dictation remove wrong, /dictation test <text>.

Install

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.

Source 2 files
hooks/register.ts 64 lines
1import 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}
64
hooks/clean.ts 128 lines
1// 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