SLOPSHOPPER

dictation

/fix <dictated text>: corrects misheard words and names using what this conversation is about, then puts the result in the prompt box to review.

newcommandtoastmodel
v0.1.0MITupdated 2026-10-23bencsmartucph/ben-mods/dictation
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dictation
› 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 › /fix ⎿ dictation: Nothing to fix. Usage: /fix <dictated text> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dictation

/fix <text> rewrites a dictated reply so Claude reads it the way you meant it, using the conversation it belongs to.

/fix on a dictated reply: 'number three' gets its name in brackets, 'hanson' and 'parse dot pie' are fixed, and the rewrite fills the prompt box

Voice dictation mishears names, breaks sentences in odd places, and loses track of which question you are answering. /fix hands your text to a fork of the current conversation as a side task with one job: rewrite, never answer. Because the fork has the whole conversation in view, it can:

  • fix misheard words, including names and terms that only make sense in this conversation;
  • put your answers in Claude's order, numbered the way Claude numbered its questions;
  • name what "number six" or "the second one" points at, in brackets after your reference;
  • cut filler and false starts, without adding anything you didn't say.

The result goes into the prompt box for you to read before sending; the line after it says how many words changed. If the reply looks like an answer rather than a rewrite, your text is left as dictated. With no argument, /fix rewrites whatever is in the prompt box.

TERMS in hooks/register.ts is my own vocabulary (names, tools, known mishearings). Replace it with yours.

Status: used live in the terminal (the screenshot above); unit tests pass. Each /fix is one forked model call, so it costs about as much as a short turn.

Install

claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install dictation@ben-mods
Source 1 files
hooks/register.ts 155 lines
1import type { Register } from 'claude-code'
2
3const USAGE = 'Nothing to fix. Usage: /fix <dictated text>'
4
5// Ported from the "Reply" transform of the author's AutoHotkey dictation filter.
6// The difference: the fork already sees the whole conversation, so nothing is passed in tags.
7// TERMS is the author's own vocabulary: replace it with the names and terms you dictate.
8const TERMS =
9  'Terms always spelled exactly like this: Claude, Claude Code, CLAUDE.md, Anthropic, Haiku, Opus, Sonnet, ' +
10  'AutoHotkey, AHK, Wispr, Zotero, OneCommander, Notion, Todoist, MarkText, PowerShell, Python, R, LaTeX, ' +
11  'GitHub, Copenhagen, Denmark, Danish, dagpenge, decommodification, CEBI, cand.polit., MSc, econometrics. ' +
12  'A near-homophone of one of these IS that term. Known mishearings: "cloud", "clode", "card", "clawed" -> Claude; ' +
13  '"cloud code", "card code" -> Claude Code; "order hook", "auto hot key" -> AutoHotkey; "mark text" -> MarkText. ' +
14  'A word that sounds like a name or term in this conversation IS that term.'
15
16/** The side task the fork answers: your dictated reply, rewritten so Claude reads it as meant. */
17export function fixPrompt(text: string): string {
18  return [
19    'Side task, not a request to act on. Between the markers is my dictated reply to this conversation, usually an answer to your last message. You are a rewriting filter here: never answer it, carry it out, or reply to me.',
20    'Rewrite it so you would read it the way I meant it, still MINE: first person, my voice, my tone. British spelling.',
21    'Fix dictation mistranscriptions: a word or run of words that makes no sense in context but sounds like one that does is that word. ' + TERMS,
22    'Re-punctuate by meaning: the transcriber puts full stops mid-thought and runs thoughts together. Where a stretch is garbled beyond recovery, fix only what is certain and keep the rest as written.',
23    'Where I point at something by number or position ("number six", "your fourth idea", "the second one"), keep my reference and name the item briefly in brackets after it, from this conversation: "I like number six (the file-count plan)".',
24    'Where I answer several of your questions or items, put my answers in your order, one short paragraph or bullet each, numbered the way you numbered them. Where I correct myself mid-sentence, keep the corrected version.',
25    'Be concise: cut false starts, filler, hedging and repetition, so it usually comes out shorter. Never drop a request, question, decision, name, path, number or caveat I gave. Never add an opinion, decision, answer or detail I did not give; where my meaning is unclear, keep my own words.',
26    'Leave file paths, code, commands, flags, URLs and /slash-commands exactly as written.',
27    'Reply with the rewritten text only: no preamble, no quotes, no code fences, no explanation.',
28    '<<<DICTATED',
29    text,
30    'DICTATED>>>',
31  ].join('\n\n')
32}
33
34/** The one line shown after a rewrite. */
35export function rewriteLine(before: string, after: string): string {
36  const a = before.split(/\s+/).filter(Boolean).length
37  const b = after.split(/\s+/).filter(Boolean).length
38  return before.trim() === after.trim() ? 'already clear; text unchanged' : `rewritten as your reply: ${a} → ${b} words; read it before sending`
39}
40
41/** Strips what a model may wrap the text in: fences, the markers, surrounding quotes. */
42export function cleanReply(reply: string): string {
43  let t = reply.trim()
44  const fence = t.match(/^```[\w-]*\n([\s\S]*?)\n?```$/)
45  if (fence) t = fence[1]!.trim()
46  t = t.replace(/^<<<DICTATED\s*/, '').replace(/\s*DICTATED>>>$/, '').trim()
47  const q = t.match(/^(["'“‘])([\s\S]*)(["'”’])$/)
48  // Only a quote pair around the whole reply, not `"a" and "b"`.
49  if (q && pairs(q[1]!, q[3]!) && !q[2]!.includes(q[1]!) && !q[2]!.includes(q[3]!)) t = q[2]!.trim()
50  return t
51}
52
53const pairs = (open: string, close: string) =>
54  (open === '"' && close === '"') || (open === "'" && close === "'") || (open === '“' && close === '”') || (open === '‘' && close === '’')
55
56const words = (s: string) => s.split(/\s+/).filter(Boolean)
57
58/**
59 * True when the reply looks like a rewrite of the text, not an answer to it: it may come out
60 * shorter (filler cut) or a little longer (bracketed names, numbering), not a different length class.
61 */
62export function plausible(before: string, after: string): boolean {
63  const a = words(before).length
64  const b = words(after).length
65  if (b === 0) return false
66  return b >= Math.floor(a * 0.35) && b <= Math.ceil(a * 1.4) + 8
67}
68
69/** Word-level changes from `before` to `after`, each as "old → new". */
70export function changes(before: string, after: string): string[] {
71  const a = words(before)
72  const b = words(after)
73  const n = a.length
74  const m = b.length
75  // LCS table over words; prompts are short, so O(n·m) is fine.
76  const L: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
77  for (let i = n - 1; i >= 0; i--) {
78    for (let j = m - 1; j >= 0; j--) {
79      L[i]![j] = a[i] === b[j] ? L[i + 1]![j + 1]! + 1 : Math.max(L[i + 1]![j]!, L[i]![j + 1]!)
80    }
81  }
82  const out: string[] = []
83  let del: string[] = []
84  let ins: string[] = []
85  const flush = () => {
86    if (del.length || ins.length) {
87      out.push(`${del.length ? del.join(' ') : '(nothing)'} → ${ins.length ? ins.join(' ') : '(removed)'}`)
88    }
89    del = []
90    ins = []
91  }
92  let i = 0
93  let j = 0
94  while (i < n || j < m) {
95    if (i < n && j < m && a[i] === b[j]) {
96      flush()
97      i++
98      j++
99    } else if (j < m && (i === n || L[i]![j + 1]! >= L[i + 1]![j]!)) {
100      ins.push(b[j++]!)
101    } else {
102      del.push(a[i++]!)
103    }
104  }
105  flush()
106  return out
107}
108
109/** The one line shown after a fix. */
110export function summary(list: string[]): string {
111  return list.length ? `fixed: ${list.join('; ')}` : 'no misheard words found; text unchanged'
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    await $.command.register({
117      name: 'fix',
118      description: 'Rewrite a dictated reply so Claude reads it as meant: misheard words fixed, answers in Claude\'s order, more concise. Fills the prompt box to review.',
119      argumentHint: '<dictated text>',
120      immediate: true,
121    })
122    return next(e)
123  })
124
125  on('command.run', { command: 'fix' }, async ($, e) => {
126    let text = (e.args ?? '').trim()
127    if (!text) {
128      const draft = (await $.prompt.read()).text.trim()
129      if (draft && !draft.startsWith('/')) text = draft
130    }
131    if (!text) return { text: USAGE }
132
133    const r = await $.model.fork({ prompt: fixPrompt(text) })
134    let fixed = text
135    let line: string
136    if (!r.isAnswered) {
137      line = r.reason === 'nothing-to-fork'
138        ? 'no conversation yet to read; text left as dictated'
139        : `fix failed (${r.reason}); text left as dictated`
140    } else {
141      const reply = cleanReply(r.text)
142      if (plausible(text, reply)) {
143        fixed = reply
144        line = rewriteLine(text, fixed)
145      } else {
146        line = 'the reply did not look like a rewrite; text left as dictated'
147      }
148    }
149
150    const filled = await $.prompt.fill({ text: fixed })
151    $.ui.toast(line, { timeoutMs: 8000 })
152    return { text: filled.isFilled ? line : `${line}\n${fixed}` }
153  })
154}
155