Plain replies backed by evidence: strips filler and AI tells from the model's text, and flags factual claims that cite no file:line, command output or URL

Keeps Claude's replies plain and backed by evidence. Two parts, each with its own switch.
Hooks session.append on the model's response rows and rewrites their text blocks before they are stored. The model and the transcript file read the clean text; thinking and tool-use blocks are never touched.
| Removed | Examples |
|---|---|
| Praise openers (only at a sentence, line or list-item start) | "Great question!", "You're absolutely right, ...", "That's a great point." |
| Closers (last line only, never one that mentions code or a link) | "I hope this helps!", "Let me know if you have questions." |
| Filler adverbs | honestly, genuinely, truthfully |
| Throat-clearing | "it's worth noting that", "needless to say" |
| Swapped, because deleting breaks the sentence | straightforward -> simple, delve -> dig |
Fenced code (also an unclosed one), inline code, URLs, "quoted" text and > blockquote lines are never touched. Words inside longer words are left alone ("dishonestly"). What the removal leaves behind is repaired: stray commas and spaces, and the next word is capitalised at a sentence start. A reply that would end up empty is kept as it was. Known limit: the capitalised word may be a lowercase identifier written without backticks ("Honestly, npm is fine" becomes "Npm is fine").
Transcript versus screen. The engine's documentation says the screen may paint a reply just before its row is rewritten. The mod therefore also rewrites the AssistantMessage drawing with the same function, so what is drawn matches what is stored (the stored row is the source of truth; the drawing is best effort). It cannot change anything an SDK stream or Remote Control already received.
Status line: voice: 5 edits. In flag mode it reads voice: 5 flagged and nothing is rewritten.
On turn.complete of an answer, the mod asks a small model to list factual claims about code or systems that cite no file:line, command output or URL. The call runs after the turn, so it never delays an answer, and its result is dropped if a newer turn started.
Skipped: refused, aborted or errored turns, subagent runs, answers shorter than the minimum length, and answers with fewer than three code or system signals (backticks, file names, words like config, service, commit), so chat replies cost nothing.
The result shows as a row above the prompt: voice: 3 claims in the last reply cite no source with two buttons, plus the first three claims when there is room. The auditor also reads the tool output of that turn (the newest 20 results, each cut to 1,200 characters), so a claim the output supports is not flagged just because the reply doesn't quote it.
The row is added to the shared band with await next(e), so other mods' rows stay. It yields to surveys. The terminal and desktop surfaces draw it; other surfaces have no band.
The auditor is a model judging text it cannot check against the code, so treat the list as leads: it finds missing citations, not false statements.
| Field | Default | Meaning |
|---|---|---|
slopFilter | on | Turn the filter off entirely. |
mode | rewrite | rewrite edits the stored text; flag only counts. |
extraPhrases | empty | Comma-separated phrases to delete (whole words, any spacing), or /regex/flags entries. Example: leverage, /\bseamless(ly)?\b/i. Invalid entries and ones that match the empty string are skipped. |
audit | on | Turn the auditor off. |
auditModel | haiku | Alias or id for the audit call. |
auditMinLength | 600 | Characters an answer needs before it is audited. |
$.model.complete call per qualifying answer. Input is the answer (cut at 12,000 characters) plus about 250 tokens of instructions; output is capped at 700 tokens, usually under 150. On Haiku that is a fraction of a cent. "Ask for sources" costs one normal turn. With tool output included the input grows by up to about 4k tokens.hooks/register.tsx 143 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { VoiceAudit, VoiceStats } from '../types'
5import { bandLabel, buildAuditPrompt, buildBackupPrompt, keepEvidence, parseClaims, shouldAudit } from './audit'
6import { filterSlop, parseExtra } from './slop'
7
8const initialStats: VoiceStats = { edits: 0, audited: 0, isAuditing: false }
9const stats = atom({ plugin: 'voice', key: 'stats' } as const, initialStats)
10const audit = atom({ plugin: 'voice', key: 'audit' } as const, { claims: [] } as VoiceAudit)
11
12async function refreshStatus($: EngineInterface, verb: string) {
13 const s = await read($, stats)
14 const parts: string[] = []
15 if (s.edits) parts.push(`${s.edits} ${verb}`)
16 if (s.isAuditing) parts.push('auditing…')
17 $.ui.status(parts.length ? `voice: ${parts.join(' · ')}` : undefined)
18}
19
20async function countEdits($: EngineInterface, edits: number, verb: string) {
21 await update($, stats, s => ({ ...s, edits: s.edits + edits }))
22 await refreshStatus($, verb)
23}
24
25// One small-model call per qualifying answer; a newer turn makes an older result stale.
26async function runAudit($: EngineInterface, answer: string, evidence: readonly string[], model: string, id: number, current: () => number, verb: string) {
27 await update($, stats, s => ({ ...s, isAuditing: true }))
28 await refreshStatus($, verb)
29 const reply = await $.model
30 .complete({ model, prompt: buildAuditPrompt(answer, evidence), maxTokens: 700, effort: 'low', timeoutMs: 30000 })
31 .catch(() => undefined)
32 if (current() !== id) return
33 const claims = reply?.isAnswered ? parseClaims(reply.text) : []
34 await update($, audit, () => ({ claims }))
35 await update($, stats, s => ({ ...s, isAuditing: false, audited: s.audited + 1 }))
36 await refreshStatus($, verb)
37}
38
39async function backThemUp($: EngineInterface, claims: readonly string[]) {
40 await update($, audit, () => ({ claims: [] }))
41 await $.prompt.submit({ text: buildBackupPrompt(claims), asUser: true })
42}
43
44async function dismiss($: EngineInterface) {
45 await update($, audit, () => ({ claims: [] }))
46}
47
48export const register: Register = (on, options) => {
49 const isFilterOn = options.slopFilter !== false
50 const isRewrite = options.mode !== 'flag'
51 const verb = isRewrite ? 'edits' : 'flagged'
52 const extra = parseExtra(typeof options.extraPhrases === 'string' ? options.extraPhrases : '')
53 const isAuditOn = options.audit !== false
54 const model = typeof options.auditModel === 'string' && options.auditModel.trim() ? options.auditModel.trim() : 'haiku'
55 const minLength = typeof options.auditMinLength === 'number' ? options.auditMinLength : 600
56
57 let generation = 0
58 // Tool output seen during the running turn: what a claim may rest on without quoting it.
59 let evidence: string[] = []
60
61 on('session.start', async ($, e, next) => {
62 await update($, stats, () => initialStats)
63 await update($, audit, () => ({ claims: [] }))
64 await refreshStatus($, verb)
65 return next(e)
66 })
67
68 // The stored row is rewritten, so the model and the transcript file read the clean text.
69 on('session.append', { door: 'response' }, async ($, e, next) => {
70 if (!isFilterOn) return next(e)
71 let edits = 0
72 const content = e.message.content.map(block => {
73 if (block.type !== 'text' || typeof block.text !== 'string') return block
74 const r = filterSlop(block.text, extra)
75 edits += r.edits
76 return isRewrite && r.edits > 0 ? { ...block, text: r.text } : block
77 })
78 if (edits === 0) return next(e)
79 await countEdits($, edits, verb)
80 return next(isRewrite ? { ...e, message: { ...e.message, content } } : e)
81 }).catch(($, e, next) => next(e))
82
83 // The screen may paint a reply before its row is stored; draw the same text it will keep.
84 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
85 if (!isFilterOn || !isRewrite) return next(e)
86 const r = filterSlop(e.props.text, extra)
87 return next(r.edits > 0 ? { ...e, props: { ...e.props, text: r.text } } : e)
88 })
89
90 on('tool.call', async ($, e, next) => {
91 const ran = await next(e)
92 if (isAuditOn && ran.deny === undefined) {
93 const text = typeof ran.text === 'string' ? ran.text : JSON.stringify(ran.result ?? '')
94 evidence = keepEvidence(evidence, String(e.tool), text)
95 }
96 return ran
97 })
98
99 on('turn.start', async ($, e, next) => {
100 if (!e.agentId) evidence = []
101 generation += 1
102 await update($, audit, () => ({ claims: [] }))
103 return next(e)
104 })
105
106 on('turn.complete', async ($, e, next) => {
107 if (isAuditOn && e.reason === 'answer' && !e.agentId && shouldAudit(e.answer, minLength)) {
108 generation += 1
109 const id = generation
110 void runAudit($, e.answer, evidence, model, id, () => generation, verb)
111 }
112 return next(e)
113 })
114
115 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116 const rest = await next(e)
117 if (e.props.hasSurvey) return rest
118 const { claims } = await read($, audit)
119 if (claims.length === 0) return rest
120
121 const { Box, Text, Button } = $.ui.resolve(e)
122 const shown = e.props.maxRows >= 6 ? claims.slice(0, 3) : []
123 return (
124 <Box flexDirection="column">
125 <Box key="voice" flexDirection="column">
126 <Box>
127 <Text color="yellow">{bandLabel(claims.length)} </Text>
128 <Button key="back" label="Ask for sources" onPress={() => backThemUp($, claims)} />
129 <Text> </Text>
130 <Button key="dismiss" label="Dismiss" onPress={() => dismiss($)} />
131 </Box>
132 {shown.map((c, i) => (
133 <Text key={`c${i}`} dimColor wrap="truncate-end">
134 {` · ${c}`}
135 </Text>
136 ))}
137 </Box>
138 {rest}
139 </Box>
140 )
141 })
142}
143hooks/audit.ts 83 lines1// Pure helpers of the evidence auditor.
2
3const SIGNAL =
4 /`[^`\n]+`|```|\b[\w./-]+\.(?:ts|tsx|js|jsx|py|cs|go|rs|java|json|ya?ml|toml|md|sh|sql|rb|php|kt|swift|cpp|c|h)\b|\b\w+\(\)|\b(?:function|class|method|endpoint|config|service|container|database|query|handler|module|package|version|branch|commit|server|deploy\w*|migration|schema|column|api|bug|error|test|build|repo|file|files)\b/gi
5
6/** A long answer that talks about code or systems; chat and short replies are skipped. */
7export function shouldAudit(answer: string, minLength: number): boolean {
8 if (answer.length < Math.max(1, minLength)) return false
9 return (answer.match(SIGNAL) ?? []).length >= 3
10}
11
12export function buildAuditPrompt(answer: string, evidence: readonly string[] = []): string {
13 const seen = evidence.length
14 ? 'Tool output the assistant saw while writing it (a claim this output supports counts as backed, even when the answer does not quote it):\n' +
15 `<tool_output>\n${evidence.join('\n---\n').slice(0, 16000)}\n</tool_output>\n\n`
16 : ''
17 return (
18 'Below is an answer an AI assistant gave about code or a system. List the factual claims it states as certain ' +
19 'about code, files, configuration, behaviour, versions or infrastructure that carry NO evidence: ' +
20 'no file:line reference, no quoted command output, no URL in the text, and no support in the tool output. Skip opinions, plans, questions, advice, ' +
21 'claims marked UNKNOWN or unverified, and claims the text or the tool output backs up. At most 8, each one short sentence in the answer\'s own words.\n' +
22 'Reply with JSON only: {"claims": ["...", "..."]}. If every claim is backed, reply {"claims": []}.\n\n' +
23 seen +
24 `<answer>\n${answer.slice(0, 12000)}\n</answer>`
25 )
26}
27
28// Keeps the newest tool results of a turn, each cut short, as evidence for the auditor.
29export function keepEvidence(list: readonly string[], tool: string, text: string, max = 20): string[] {
30 const one = `${tool}: ${text.replace(/\s+/g, ' ').trim().slice(0, 1200)}`
31 return [...list, one].slice(-max)
32}
33
34const MAX_CLAIMS = 8
35
36/** Reads the model's reply as JSON, tolerating fences, prose around it, arrays and objects. */
37export function parseClaims(raw: string): string[] {
38 const text = raw.replace(/```(?:json)?/gi, '').trim()
39 let value: unknown
40 const attempts = [text]
41 const obj = [text.indexOf('{'), text.lastIndexOf('}')] as const
42 const arr = [text.indexOf('['), text.lastIndexOf(']')] as const
43 if (obj[0] >= 0 && obj[1] > obj[0]) attempts.push(text.slice(obj[0], obj[1] + 1))
44 if (arr[0] >= 0 && arr[1] > arr[0]) attempts.push(text.slice(arr[0], arr[1] + 1))
45 for (const a of attempts) {
46 try {
47 value = JSON.parse(a)
48 break
49 } catch {
50 // try the next slice
51 }
52 }
53 const list: unknown = Array.isArray(value)
54 ? value
55 : value && typeof value === 'object'
56 ? ((value as Record<string, unknown>).claims ?? (value as Record<string, unknown>).unbacked ?? (value as Record<string, unknown>).items)
57 : undefined
58 if (!Array.isArray(list)) return []
59 const seen = new Set<string>()
60 const out: string[] = []
61 for (const item of list) {
62 const field = item && typeof item === 'object' ? ((item as Record<string, unknown>).claim ?? (item as Record<string, unknown>).text ?? (item as Record<string, unknown>).statement) : item
63 if (typeof field !== 'string') continue
64 const claim = field.replace(/\s+/g, ' ').trim().slice(0, 300)
65 if (!claim || seen.has(claim.toLowerCase())) continue
66 seen.add(claim.toLowerCase())
67 out.push(claim)
68 if (out.length === MAX_CLAIMS) break
69 }
70 return out
71}
72
73export function buildBackupPrompt(claims: readonly string[]): string {
74 return (
75 'In your last answer these claims had no evidence:\n' +
76 claims.map((c, i) => `${i + 1}. ${c}`).join('\n') +
77 '\n\nFor each one, check it now and cite file:line, command output or a URL. ' +
78 'If you cannot verify it, mark it UNKNOWN. If it turns out wrong, correct it.'
79 )
80}
81
82export const bandLabel = (n: number) => `voice: ${n} claim${n === 1 ? '' : 's'} in the last reply cite${n === 1 ? 's' : ''} no source`
83hooks/slop.ts 201 lines1// Pure text filter: no engine, so tests import it directly.
2
3export type SlopResult = { text: string; edits: number }
4
5type Rule = {
6 re: RegExp
7 /** Replace instead of delete; keeps the first letter's case. */
8 sub?: (match: string) => string
9 /** Only where a sentence starts (an opener, not a mid-sentence use). */
10 startOnly?: boolean
11}
12
13const OPEN = ''
14const CLOSE = ''
15const SLOT = /(\d+)/g
16
17// Where a sentence (or a list item, heading, quote line) begins.
18const SENTENCE_START = /(?:(?:^|\n)[ \t]*(?:(?:[-*+>]|\d+[.)]|#{1,6})[ \t]+)*|[.!?]["'”’)\]\uE001]*[ \t]+)$/
19
20// Not inside a path or file name (src/delve/a.ts, straightforward.ts): those are data, not prose.
21const word = (core: string) => new RegExp(`(?<![\\w/\\\\-]|\\w\\.)(?:${core})(?![\\w-]|\\.\\w|/)`, 'gi')
22const matchCase = (from: string, to: string) => (/^[A-Z]/.test(from) ? to.charAt(0).toUpperCase() + to.slice(1) : to)
23
24const PRAISE =
25 "(?:(?:great|good|excellent|fantastic|fair|interesting|awesome|wonderful|sharp|nice) (?:question|point|catch|idea|observation)" +
26 "|you(?:'|’)re (?:absolutely|totally|completely|entirely|exactly|quite) (?:right|correct)" +
27 "|you are (?:absolutely|totally|completely|entirely|exactly|quite) (?:right|correct)" +
28 "|that(?:'|’)s (?:a|an) (?:really |very )?(?:great|good|excellent|fantastic|fair|interesting|sharp) (?:question|point|catch|idea|observation)" +
29 "|what a (?:great|good|fantastic|excellent) (?:question|idea)" +
30 "|sure thing" +
31 "|(?:absolutely|certainly|of course)(?=!)" +
32 "|(?:great|perfect|excellent|wonderful)(?=[!.,]))"
33
34const DEFAULT_RULES: Rule[] = [
35 { re: new RegExp(`(?<![\\w-])${PRAISE}[!.,;:]*(?:[ \\t]*[—–]+)?`, 'gi'), startOnly: true },
36 { re: word('honestly|genuinely|truthfully'), },
37 {
38 re: word('straightforward(?:ly)?|delve[sd]?|delving'),
39 sub: m => {
40 const k = m.toLowerCase()
41 const to = k === 'straightforward' ? 'simple' : k === 'straightforwardly' ? 'simply' : k === 'delve' ? 'dig' : k === 'delves' ? 'digs' : k === 'delved' ? 'dug' : 'digging'
42 return matchCase(m, to)
43 },
44 },
45 {
46 re: word("it(?:'|’)s (?:worth|important) (?:noting|to note)(?: that)?|it is (?:worth|important) (?:noting|to note)(?: that)?|needless to say|it goes without saying(?: that)?"),
47 },
48]
49
50const CLOSER =
51 /^(?:and |also |so )?(?:i hope (?:this|that|it)(?: all)? helps|hope (?:this|that|it)(?: all)? helps|(?:please )?let me know (?:if|whether|how|what)|(?:please )?feel free to|don(?:'|’)t hesitate to|happy to (?:help|clarify|elaborate|explain|assist)|i(?:'|’)m happy to (?:help|clarify|elaborate|explain|assist)|is there anything else|i(?:'|’)d be (?:happy|glad) to)/i
52
53const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54
55/** "leverage, /\bseamless(ly)?\b/i" -> regexes. Bad or empty-matching entries are skipped. */
56export function parseExtra(spec: string): RegExp[] {
57 const out: RegExp[] = []
58 for (const raw of spec.match(/\/(?:\\.|[^/\\])+\/[a-z]*|[^,]+/g) ?? []) {
59 const entry = raw.trim()
60 if (!entry) continue
61 const literal = /^\/((?:\\.|[^/\\])+)\/([a-z]*)$/.exec(entry)
62 try {
63 const re = literal
64 ? new RegExp(literal[1] as string, (literal[2] as string).includes('g') ? (literal[2] as string) : `${literal[2]}g`)
65 : word(escape(entry).replace(/\s+/g, '\\s+'))
66 if (!re.test('')) out.push(re)
67 } catch {
68 // an invalid pattern is ignored, not fatal
69 }
70 }
71 return out
72}
73
74/** Hide code, URLs, quotes and blockquotes behind placeholders the rules cannot match inside. */
75export function protect(text: string): { work: string; slots: string[] } {
76 const slots: string[] = []
77 const hold = (s: string) => `${OPEN}${slots.push(s) - 1}${CLOSE}`
78
79 // Fenced blocks, line by line; an unclosed fence (a reply cut off) runs to the end.
80 const lines = text.split('\n')
81 const kept: string[] = []
82 let open: { char: string; size: number; body: string[] } | undefined
83 for (const line of lines) {
84 const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line)
85 if (!open) {
86 if (fence) open = { char: (fence[1] as string)[0] as string, size: (fence[1] as string).length, body: [line] }
87 else kept.push(line)
88 continue
89 }
90 open.body.push(line)
91 const close = /^[ \t]*(`{3,}|~{3,})[ \t]*$/.exec(line)
92 if (close && (close[1] as string)[0] === open.char && (close[1] as string).length >= open.size) {
93 kept.push(hold(open.body.join('\n')))
94 open = undefined
95 }
96 }
97 if (open) kept.push(hold(open.body.join('\n')))
98
99 let work = kept.join('\n')
100 work = work.replace(/(`+)(?:(?!\1)[^\n])+?\1/g, hold)
101 work = work.replace(/(?:https?|ftp):\/\/[^\s<>"'`)\]]*[^\s<>"'`)\].,;:!?]|www\.[^\s<>"'`)\]]*[^\s<>"'`)\].,;:!?]/gi, hold)
102 work = work.replace(/^[ \t]*>.*$/gm, hold)
103 work = work.replace(/“[^”\n]*”|"[^"\n]*"/g, hold)
104 return { work, slots }
105}
106
107export function restore(work: string, slots: string[]): string {
108 let text = work
109 for (let i = 0; i < 6 && SLOT.test(text); i += 1) {
110 SLOT.lastIndex = 0
111 text = text.replace(SLOT, (_, n: string) => slots[Number(n)] ?? '')
112 }
113 return text
114}
115
116function apply(input: string, rule: Rule): { text: string; edits: number } {
117 let text = input
118 let edits = 0
119 let pos = 0
120 const re = rule.re
121 for (;;) {
122 re.lastIndex = pos
123 const m = re.exec(text)
124 if (!m) break
125 if (m[0] === '') {
126 pos = m.index + 1
127 if (pos > text.length) break
128 continue
129 }
130 const end = m.index + m[0].length
131 let before = text.slice(0, m.index)
132 let after = text.slice(end)
133 const isStart = SENTENCE_START.test(before)
134 if (rule.startOnly && !isStart) {
135 pos = end
136 continue
137 }
138 let middle = ''
139 if (rule.sub) {
140 middle = rule.sub(m[0])
141 } else if (isStart) {
142 const isLineStart = before === '' || before.endsWith('\n')
143 after = after.replace(/^[.!?]+[ \t]*/, '').replace(isLineStart ? /^[,;:]?\s*/ : /^[,;:]?[ \t]*/, '')
144 if ((after === '' || after.startsWith('\n')) && /[ \t]$/.test(before)) before = before.replace(/[ \t]+$/, '')
145 after = after.replace(/^[a-z]/, c => c.toUpperCase())
146 } else if (/^[,;:.!?)]/.test(after)) {
147 before = before.replace(/[ \t]+$/, '')
148 if (before.endsWith(',') && /^[.!?;:]/.test(after)) before = before.slice(0, -1)
149 else if (before.endsWith(',') && after.startsWith(',')) after = after.slice(1)
150 } else if (/[ \t]$/.test(before)) {
151 after = after.replace(/^[ \t]+/, '')
152 }
153 text = before + middle + after
154 pos = before.length + middle.length
155 edits += 1
156 }
157 return { text, edits }
158}
159
160// Drops trailing "hope this helps" / "let me know if" sentences from the last line of prose.
161// A sentence that mentions code or a link is information, so it stays.
162function dropClosers(work: string): { text: string; edits: number } {
163 const body = work.replace(/\s+$/, '')
164 const tail = work.slice(body.length)
165 const lineStart = body.lastIndexOf('\n') + 1
166 const line = body.slice(lineStart)
167 if (/^\s*(?:[-*+>|#]|\d+[.)])/.test(line)) return { text: work, edits: 0 }
168 const sentences = line.split(/(?<=[.!?])\s+/)
169 let edits = 0
170 while (sentences.length > 0) {
171 const last = (sentences[sentences.length - 1] as string).trim()
172 if (last.includes(OPEN) || !CLOSER.test(last)) break
173 sentences.pop()
174 edits += 1
175 }
176 if (edits === 0) return { text: work, edits: 0 }
177 const head = body.slice(0, lineStart)
178 const rest = sentences.join(' ')
179 return { text: rest === '' ? head.replace(/\s+$/, '') : head + rest + tail, edits }
180}
181
182/** Removes filler and AI tells from prose; code, URLs and quotes are never touched. */
183export function filterSlop(input: string, extra: readonly RegExp[] = []): SlopResult {
184 if (input.includes(OPEN) || input.includes(CLOSE)) return { text: input, edits: 0 }
185 const { work, slots } = protect(input)
186 let text = work
187 let edits = 0
188 for (const rule of [...DEFAULT_RULES, ...extra.map((re): Rule => ({ re }))]) {
189 const r = apply(text, rule)
190 text = r.text
191 edits += r.edits
192 }
193 const closed = dropClosers(text)
194 text = closed.text
195 edits += closed.edits
196 const result = restore(text, slots)
197 // Never turn an answer into nothing.
198 if (edits === 0 || result.trim() === '') return { text: input, edits: 0 }
199 return { text: result, edits }
200}
201types/index.d.ts 19 lines1export type VoiceStats = {
2 /** Edits made (rewrite mode) or edits that would have been made (flag mode). */
3 edits: number
4 /** Answers the auditor has examined. */
5 audited: number
6 isAuditing: boolean
7}
8
9export type VoiceAudit = {
10 /** Claims the auditor found without evidence; empty hides the band. */
11 claims: string[]
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 voice: { stats: VoiceStats; audit: VoiceAudit }
17 }
18}
19