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

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

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:
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.
claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install dictation@ben-modshooks/register.ts 155 lines1import 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