Writing feedback on every prompt you type, in the language you are practising: a small model reviews it out of band and the fixes appear under your prompt…

<img src="Logo.png" alt="Language Coach, a Claude Code plugin" width="640">
Get writing feedback on every prompt you type, in the language you are practising. A small model reviews each prompt in a separate call and shows the fixes under it. Nothing is added to the main conversation, so its context stays clean. Each review is a small, separate request that counts against your usage (see Cost and privacy).
https://github.com/user-attachments/assets/4a21de20-32ec-4cdb-b7a4-b9ff78d2b93b

Each fix also comes with a short explanation of the rule behind it:
↳ line under the fix.To always print it inline, or to hide it, change the Explanations setting (see Settings).

Fixes stay attached to their prompts after --resume.
In a Claude Code terminal session:
/plugin install language-coach --marketplace sergiojrdotnet/claude-language-coach
Answer y to add the marketplace, then pick the user scope so the coach runs in every session.
Requires Claude Code 2.1.291 or newer (mods / function hooks). The mods API is early access and can change between releases.
| You do | You get |
|---|---|
| Type a prompt | About 1–2 s later, any fixes appear under your prompt. A clean prompt shows nothing. |
/language-coach | A pane with your history: corrections by category, a 30-day trend of fixes per prompt, your recurring mistakes, and recent fixes. It has Pause and Clear history buttons. |
/language-coach off / /language-coach on | Pause or resume the coach for the current session. |

Prompts are skipped when they:
/ or !Prompts that you did not type are also skipped: task notifications, scheduled runs and messages from other sessions.
Change them under /plugin → Installed → language-coach → Configure options:
| Setting | Default | What it does |
|---|---|---|
| Language you practise | English | The language your prompts are coached in. |
| Your native language | Brazilian Portuguese | Helps the coach spot transfer errors (false friends, calques, prepositions). |
| Coach model | haiku | haiku, sonnet or opus. Haiku is fast and cheap. |
| Coach effort | low | How hard Sonnet or Opus think about each review (low to max, or model default). Haiku 4.5 takes no effort setting and ignores it. |
| Minimum prompt length | 8 | Shorter prompts are not coached. |
| Maximum prompt length | 2000 | Longer prompts are not coached. |
| Explanations | popup | popup opens a card on hover (inline where the surface can't hover), inline always prints a ↳ line under the fix, off hides it. History keeps explanations in every mode. |
| Coach my prompts | on | Turns the coach off without uninstalling it. |
The coach exports its counters as environment variables. Your status line command inherits them, so you can show them in your own layout:
| Variable | Example | What it holds |
|---|---|---|
LANGUAGE_COACH_STATUS | ✎ 2 fixes today · 5 clean in a row | A ready-made line. ✎ coach paused while paused, unset when the coach is off. |
LANGUAGE_COACH_STATE | on | on, paused (/language-coach off) or off (Coach my prompts turned off). |
LANGUAGE_COACH_REVIEWS_TODAY | 7 | Prompts reviewed today. |
LANGUAGE_COACH_FIXES_TODAY | 2 | Fixes found today. |
LANGUAGE_COACH_STREAK | 5 | Clean prompts in a row. |
For example, in a status line script:
[ -n "$LANGUAGE_COACH_STATUS" ] && printf '%s' "$LANGUAGE_COACH_STATUS"
The values change when a review finishes, and the status line picks them up the next time it runs. Set refreshInterval on your statusLine to see them sooner.
you type ──► prompt.submit hook ──► the prompt goes to the main model unchanged
│
└─► (timer) $.model.complete({ model: haiku }) ──► JSON fixes
│
UserMessage row ◄── ui.render wraps the engine's row ◄───────┘
$.model.complete call. It has no tools and no conversation history, and it uses the credentials of your Claude Code session.ui.render hook on your prompt's row. The stored message and the model's context are never changed.$.store, on your machine. It keeps the corrected fragments (original, fix, reason, category, explanation) of the last 500 prompts with fixes, plus a daily count of reviews and fixes for the trend. It never stores whole prompts.claude plugin validate .
claude plugin test .
claude --plugin-dir . # run a session with the local copy
The coach prompt lives in hooks/prompt.ts. It uses {{TARGET_LANGUAGE}}, {{NATIVE_LANGUAGE}} and {{PROMPT}} placeholders, so it stays language-generic.
hooks/register.tsx 365 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren, RenderSurface, RenderViewport } from 'claude-code'
3
4import type { Entry, Fix } from '../types'
5import { isCoachable, parseFixes, requestFor, rowIdOf, settingsOf, textKey } from './coach'
6import type { Explanations, Settings } from './coach'
7import {
8 appendEntry,
9 asDaily,
10 asEntries,
11 asRemembered,
12 dayKey,
13 nextStreak,
14 recordReview,
15 forget,
16 remember,
17 rememberedByKey,
18 statusText,
19 summarize,
20} from './history'
21import { glyphsOf, rasterCells, trendOf, trendSummary, trendSvg } from './trend'
22import { linesWhenWrapped } from './wrap'
23
24const PANE = 'language-coach'
25const HISTORY_KEY = 'history'
26const DAILY_KEY = 'daily'
27const STREAK_KEY = 'streak'
28const REMEMBERED_KEY = 'remembered'
29const RECENT_FIXES = 15
30const OWN_WORDS = new Set(['composer', 'bridge'])
31
32const coaching = { plugin: 'language-coach', key: 'coaching' } as const
33const history = atom({ plugin: 'language-coach', key: 'history' } as const, [])
34const daily = atom({ plugin: 'language-coach', key: 'daily' } as const, {})
35const remembered = atom({ plugin: 'language-coach', key: 'remembered' } as const, {})
36const streak = atom({ plugin: 'language-coach', key: 'streak' } as const, 0)
37const isPaused = atom({ plugin: 'language-coach', key: 'isPaused' } as const, false)
38
39type Table = { Box: Elements['terminal']['Box']; Text: Elements['terminal']['Text'] }
40
41type Layout = { explanations: Explanations; cardWidth: number }
42
43const countOf = (stored: unknown) => (typeof stored === 'number' && Number.isFinite(stored) ? stored : 0)
44
45const layoutOf = (surface: RenderSurface, viewport: RenderViewport | undefined, columns: number, explanations: Explanations): Layout => {
46 const canHover = surface === 'desktop' || (surface === 'terminal' && viewport?.isFullscreen === true)
47
48 return {
49 explanations: explanations === 'popup' && !canHover ? 'inline' : explanations,
50 cardWidth: Math.max(24, Math.min(72, columns - 6)),
51 }
52}
53
54const explanationOf = (fix: Fix, layout: Layout) =>
55 layout.explanations !== 'off' && fix.explanation !== undefined && fix.explanation !== fix.reason ? fix.explanation : undefined
56
57const fixRow = ({ Box, Text }: Table, fix: Fix, key: string, layout: Layout, lead: RenderChildren, tag: string) => {
58 const explanation = explanationOf(fix, layout)
59 const cardRows = explanation === undefined ? 0 : linesWhenWrapped(explanation, layout.cardWidth - 4) + 2
60
61 return (
62 <Box key={key} flexDirection="column">
63 <Text wrap="wrap">
64 {lead}
65 <Text color="error">{fix.original}</Text>
66 <Text dimColor>{' → '}</Text>
67 <Text color="success" bold>
68 {fix.fix}
69 </Text>
70 <Text dimColor>{tag}</Text>
71 </Text>
72 {explanation !== undefined && layout.explanations === 'inline' && (
73 <Text dimColor italic wrap="wrap">
74 {` ↳ ${explanation}`}
75 </Text>
76 )}
77 {explanation !== undefined && layout.explanations === 'popup' && (
78 <Box
79 position="absolute"
80 top={-cardRows}
81 left={2}
82 width={layout.cardWidth}
83 height={cardRows}
84 overflow="hidden"
85 display="none"
86 hover={{ display: 'flex' }}
87 borderStyle="round"
88 borderColor="suggestion"
89 backgroundColor="userMessageBackground"
90 >
91 <Box paddingX={1} flexGrow={1}>
92 <Text wrap="wrap">{explanation}</Text>
93 </Box>
94 </Box>
95 )}
96 </Box>
97 )
98}
99
100const tagOf = (fix: Fix) => ` ${fix.category}${fix.reason === '' ? '' : ` · ${fix.reason}`}`
101
102async function exportStatus($: EngineInterface, settings: Settings) {
103 const today = (await read($, daily))[dayKey(await $.clock.now())]
104 const run = await read($, streak)
105 const state = !settings.isEnabled ? 'off' : (await read($, isPaused)) ? 'paused' : 'on'
106
107 await $.env.set('LANGUAGE_COACH_STATE', state)
108 await $.env.set('LANGUAGE_COACH_REVIEWS_TODAY', String(today?.prompts ?? 0))
109 await $.env.set('LANGUAGE_COACH_FIXES_TODAY', String(today?.fixes ?? 0))
110 await $.env.set('LANGUAGE_COACH_STREAK', String(run))
111 await $.env.set('LANGUAGE_COACH_STATUS', statusText(state, today, run))
112}
113
114async function coach($: EngineInterface, settings: Settings, prompt: string, key: string) {
115 const reply = await $.model.complete(requestFor(prompt, settings))
116 if (!reply.isAnswered) {
117 $.ui.log(`language-coach: no review (${reply.reason})`, { to: 'debug' })
118 return
119 }
120
121 const fixes = parseFixes(reply.text, prompt)
122 if (fixes === undefined) {
123 $.ui.log('language-coach: the review was not the JSON the coach asked for', { to: 'debug' })
124 return
125 }
126
127 const now = await $.clock.now()
128 const days = recordReview(asDaily(await $.store.get(DAILY_KEY)), dayKey(now), fixes.length)
129 const run = nextStreak(countOf(await $.store.get(STREAK_KEY)), fixes.length)
130 await $.store.set(DAILY_KEY, days)
131 await $.store.set(STREAK_KEY, run)
132 await update($, daily, () => days)
133 await update($, streak, () => run)
134
135 const rows = asRemembered(await $.store.get(REMEMBERED_KEY))
136 await $.store.set(REMEMBERED_KEY, fixes.length > 0 ? remember(rows, key, fixes) : forget(rows, key))
137
138 if (fixes.length > 0) {
139 await $.state.set({ ...coaching, id: key }, { fixes })
140
141 const entry: Entry = { at: now, language: settings.targetLanguage, fixes }
142 const entries = appendEntry(asEntries(await $.store.get(HISTORY_KEY)), entry)
143 await $.store.set(HISTORY_KEY, entries)
144 await update($, history, () => entries)
145 }
146
147 await exportStatus($, settings)
148}
149
150async function fixesFor($: EngineInterface, keys: readonly string[]) {
151 for (const key of keys) {
152 const live = await read($, { ...coaching, id: key })
153 if (live !== undefined) return live.fixes
154 }
155
156 const rows = await read($, remembered)
157
158 return keys.map(key => rows[key]).find(fixes => fixes !== undefined) ?? []
159}
160
161export const register: Register = (on, options) => {
162 const settings = settingsOf(options)
163 const appendedRows = new Map<string, string>()
164
165 on('session.append', { door: 'prompt' }, async ($, e, next) => {
166 const stored = await next(e)
167 const row = rowIdOf(e.uuid)
168 const text = e.message.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n').trim()
169 if (row !== undefined && e.agentId === undefined && OWN_WORDS.has(e.origin.kind)) appendedRows.set(textKey(text), row)
170
171 return stored
172 })
173
174 on('session.start', async ($, e, next) => {
175 await $.command.register({
176 name: 'language-coach',
177 description: `Your ${settings.targetLanguage} coaching history; /language-coach off or on pauses or resumes it`,
178 argumentHint: '[on|off]',
179 immediate: true,
180 })
181
182 const entries = asEntries(await $.store.get(HISTORY_KEY))
183 const days = asDaily(await $.store.get(DAILY_KEY))
184 const run = countOf(await $.store.get(STREAK_KEY))
185 const rows = rememberedByKey(asRemembered(await $.store.get(REMEMBERED_KEY)))
186 await update($, history, () => entries)
187 await update($, daily, () => days)
188 await update($, streak, () => run)
189 await update($, remembered, () => rows)
190 await exportStatus($, settings)
191
192 return next(e)
193 })
194
195 on('prompt.submit', async ($, e, next) => {
196 const entered = await next(e)
197 if (!settings.isEnabled || entered.drop !== undefined || !OWN_WORDS.has(e.origin.kind)) return entered
198
199 const prompt = entered.text.trim()
200 if (!isCoachable(prompt, settings) || (await read($, isPaused))) return entered
201
202 const text = textKey(prompt)
203 const key = appendedRows.get(text) ?? text
204 appendedRows.delete(text)
205 // Fixes remembered from an earlier review of the same text must not stand in for the new one.
206 await $.state.set({ ...coaching, id: key }, { fixes: [] })
207 // A timer runs the review in a dispatch of its own, so interrupting the turn does not abort it.
208 $.clock.after(0, () => void coach($, settings, prompt, key))
209
210 return entered
211 })
212
213 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
214 const drawn = await next(e)
215 if (e.requestId === 'placeholder') return drawn
216
217 const keys = [rowIdOf(e.requestId), textKey(e.props.text)].filter((key): key is string => key !== undefined)
218 const fixes = await fixesFor($, keys)
219 if (fixes.length === 0) return drawn
220
221 const { Box, Text } = $.ui.resolve(e)
222 const layout = layoutOf(e.surface, e.viewport, e.viewport?.columns ?? 80, settings.explanations)
223 const lead = (
224 <Text dimColor hover={{ color: 'suggestion', dimColor: false }}>
225 {'✎ '}
226 </Text>
227 )
228
229 return (
230 <Box flexDirection="column">
231 {drawn}
232 <Box flexDirection="column" paddingLeft={2}>
233 {fixes.map((fix, index) => fixRow({ Box, Text }, fix, `fix-${index}`, layout, lead, tagOf(fix)))}
234 </Box>
235 </Box>
236 )
237 })
238
239 on('command.run', { command: 'language-coach' }, async ($, e) => {
240 const verb = e.args.trim().toLowerCase()
241 if (verb === 'off' || verb === 'on') {
242 await update($, isPaused, () => verb === 'off')
243 await exportStatus($, settings)
244 $.ui.toast(verb === 'off' ? 'Coaching paused for this session.' : 'Coaching resumed.')
245
246 return {}
247 }
248
249 await $.ui.open({ id: PANE, title: `${settings.targetLanguage} coach` })
250
251 return {}
252 })
253
254 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255 const { Box, Button, Text } = $.ui.resolve(e)
256 const entries = await read($, history)
257 const paused = await read($, isPaused)
258 const bars = trendOf(await read($, daily), await $.clock.now())
259 const hasTrend = bars.some(bar => bar.rate !== undefined)
260 const summary = summarize(entries)
261 const recent = entries.flatMap(entry => entry.fixes).slice(-RECENT_FIXES).reverse()
262 const state = !settings.isEnabled ? 'off in plugin options' : paused ? 'paused' : 'on'
263 const layout = layoutOf(e.surface, e.viewport, e.props.bodyColumns, settings.explanations)
264
265 const chart = () => {
266 if (e.surface === 'terminal') {
267 const { Raster } = $.ui.resolve(e)
268
269 return <Raster key="trend" columns={bars.length} rows={1} cells={rasterCells(bars)} />
270 }
271 if (e.surface === 'desktop') {
272 const { Svg } = $.ui.resolve(e)
273
274 return <Svg source={trendSvg(bars)} alt={trendSummary(bars)} isInteractive />
275 }
276
277 return (
278 <Text>
279 {glyphsOf(bars).map(({ glyph, color }) => (
280 <Text color={color}>{glyph}</Text>
281 ))}
282 </Text>
283 )
284 }
285
286 return (
287 <Box flexDirection="column">
288 <Text>
289 <Text bold>{settings.targetLanguage} coach</Text>
290 <Text dimColor>
291 {' · '}
292 {state} · {summary.corrections} corrections across {summary.prompts} prompts
293 </Text>
294 </Text>
295 {summary.byCategory.length > 0 && (
296 <Text dimColor wrap="wrap">
297 {summary.byCategory.map(({ category, count }) => `${category} ${count}`).join(' · ')}
298 </Text>
299 )}
300 {hasTrend && (
301 <Box flexDirection="column" marginTop={1}>
302 <Text bold>Trend</Text>
303 {chart()}
304 <Text dimColor wrap="wrap">
305 {trendSummary(bars)}
306 </Text>
307 </Box>
308 )}
309 {summary.corrections === 0 && (
310 <Box marginTop={1}>
311 <Text dimColor wrap="wrap">
312 No corrections yet. Fixes show up under each prompt you write in {settings.targetLanguage}.
313 </Text>
314 </Box>
315 )}
316 {summary.recurring.length > 0 && (
317 <Box flexDirection="column" marginTop={1}>
318 <Text bold>Recurring</Text>
319 {summary.recurring.map((pair, index) =>
320 fixRow(
321 { Box, Text },
322 { original: pair.original, fix: pair.fix, reason: '', category: 'phrasing', ...(pair.explanation !== undefined && { explanation: pair.explanation }) },
323 `recurring-${index}`,
324 layout,
325 <Text dimColor>{`${pair.count}× `}</Text>,
326 '',
327 ),
328 )}
329 </Box>
330 )}
331 {recent.length > 0 && (
332 <Box flexDirection="column" marginTop={1}>
333 <Text bold>Recent</Text>
334 {recent.map((fix, index) => fixRow({ Box, Text }, fix, `recent-${index}`, layout, '', tagOf(fix)))}
335 </Box>
336 )}
337 <Box marginTop={1} gap={1}>
338 <Button
339 key="pause"
340 label={paused ? 'Resume' : 'Pause'}
341 onPress={async () => {
342 await update($, isPaused, value => !value)
343 await exportStatus($, settings)
344 }}
345 />
346 <Button
347 key="clear"
348 label="Clear history"
349 onPress={async () => {
350 await Promise.all([HISTORY_KEY, REMEMBERED_KEY].map(key => $.store.set(key, [])))
351 await $.store.set(DAILY_KEY, {})
352 await $.store.set(STREAK_KEY, 0)
353 await update($, history, () => [])
354 await update($, remembered, () => ({}))
355 await update($, daily, () => ({}))
356 await update($, streak, () => 0)
357 await exportStatus($, settings)
358 }}
359 />
360 </Box>
361 </Box>
362 )
363 })
364}
365hooks/coach.ts 164 lines1import type { ModelCompleteRequest, ModelEffort, PluginOptions } from 'claude-code'
2
3import type { Category, Fix } from '../types'
4import { SYSTEM, USER_TEMPLATE } from './prompt'
5
6export type Explanations = 'popup' | 'inline' | 'off'
7
8export type Settings = {
9 targetLanguage: string
10 nativeLanguage: string
11 model: string
12 effort: ModelEffort | undefined
13 minLength: number
14 maxLength: number
15 explanations: Explanations
16 isEnabled: boolean
17}
18
19const CATEGORIES: readonly Category[] = ['typo', 'grammar', 'transfer', 'word-choice', 'phrasing']
20const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
21const EXPLANATIONS: readonly Explanations[] = ['popup', 'inline', 'off']
22const MAX_FIXES = 5
23const REPLY_TOKENS = 600
24const REPLY_TIMEOUT_MS = 30_000
25const ENGINE_FRAMED = /^<(bash-input|bash-stdout|bash-stderr|command-name|command-message|local-command)/
26
27const text = (value: unknown, fallback: string) =>
28 typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
29
30const count = (value: unknown, fallback: number) =>
31 typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
32
33export const settingsOf = (options: PluginOptions): Settings => ({
34 targetLanguage: text(options.targetLanguage, 'English'),
35 nativeLanguage: text(options.nativeLanguage, 'Brazilian Portuguese'),
36 model: text(options.model, 'haiku'),
37 effort: options.effort === undefined ? 'low' : EFFORTS.find(level => level === options.effort),
38 minLength: count(options.minLength, 8),
39 maxLength: count(options.maxLength, 2000),
40 explanations: EXPLANATIONS.find(mode => mode === options.explanations) ?? 'popup',
41 isEnabled: options.enabled !== false,
42})
43
44// A prompt row's render id is its stored uuid with the last group zeroed, so the first four groups name the row.
45const ROW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-/
46
47export const rowIdOf = (id: string) => (ROW_ID.test(id) ? `row:${id.slice(0, 23)}` : undefined)
48
49export const textKey = (prompt: string) => {
50 let hash = 0x811c9dc5
51 for (const char of prompt.trim().replace(/\s+/g, ' ')) {
52 hash = Math.imul(hash ^ char.codePointAt(0)!, 0x01000193)
53 }
54
55 return (hash >>> 0).toString(16)
56}
57
58export const isCoachable = (prompt: string, settings: Settings) =>
59 prompt.length >= settings.minLength &&
60 prompt.length <= settings.maxLength &&
61 !prompt.startsWith('/') &&
62 !prompt.startsWith('!') &&
63 !ENGINE_FRAMED.test(prompt)
64
65export const fill = (template: string, values: Readonly<Record<string, string>>) =>
66 template.replace(/\{\{([A-Z_]+)\}\}/g, (placeholder, name: string) => values[name] ?? placeholder)
67
68export const requestFor = (prompt: string, settings: Settings): ModelCompleteRequest => {
69 const values = {
70 PROMPT: prompt,
71 TARGET_LANGUAGE: settings.targetLanguage,
72 NATIVE_LANGUAGE: settings.nativeLanguage,
73 }
74
75 return {
76 model: settings.model,
77 ...(settings.effort !== undefined && { effort: settings.effort }),
78 system: fill(SYSTEM, values),
79 prompt: fill(USER_TEMPLATE, values),
80 maxTokens: REPLY_TOKENS,
81 timeoutMs: REPLY_TIMEOUT_MS,
82 }
83}
84
85export const jsonObjects = (reply: string): unknown[] => {
86 const objects: unknown[] = []
87 let start = -1
88 let depth = 0
89 let isInString = false
90 let isEscaped = false
91
92 for (let at = 0; at < reply.length; at++) {
93 const char = reply[at]
94
95 if (isInString) {
96 if (isEscaped) isEscaped = false
97 else if (char === '\\') isEscaped = true
98 else if (char === '"') isInString = false
99 continue
100 }
101
102 if (char === '"' && depth > 0) isInString = true
103 else if (char === '{' && depth++ === 0) start = at
104 else if (char === '}' && depth > 0 && --depth === 0) {
105 try {
106 objects.push(JSON.parse(reply.slice(start, at + 1)))
107 } catch {
108 // A malformed object is skipped; a later one may still be the answer.
109 }
110 }
111 }
112
113 return objects
114}
115
116const hasFixes = (value: unknown): value is { fixes: unknown[] } =>
117 typeof value === 'object' && value !== null && 'fixes' in value && Array.isArray(value.fixes)
118
119const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
120const LIMITS = { original: 200, fix: 200, reason: 160, explanation: 400 }
121
122const clean = (value: unknown, limit: number) =>
123 typeof value === 'string' ? value.replace(CONTROL, '').replace(/\s+/g, ' ').trim().slice(0, limit) : ''
124
125const skeleton = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
126
127const categoryOf = (value: unknown): Category =>
128 CATEGORIES.find(category => category === value) ?? 'phrasing'
129
130export const parseFixes = (reply: string, prompt: string): Fix[] | undefined => {
131 const answer = jsonObjects(reply).filter(hasFixes).at(-1)
132 if (answer === undefined) return undefined
133
134 const lowered = prompt.toLowerCase()
135 const taken: { from: number; to: number }[] = []
136 const fixes: Fix[] = []
137
138 for (const candidate of answer.fixes) {
139 if (typeof candidate !== 'object' || candidate === null) continue
140 const fields = candidate as Record<string, unknown>
141 const original = clean(fields.original, LIMITS.original)
142 const replacement = clean(fields.fix, LIMITS.fix)
143 if (original === '' || replacement === '') continue
144
145 const isCosmetic = skeleton(original) === skeleton(replacement)
146 const from = lowered.indexOf(original.toLowerCase())
147 const to = from + original.length
148 const overlaps = taken.some(span => from < span.to && span.from < to)
149 if (isCosmetic || from === -1 || overlaps) continue
150
151 taken.push({ from, to })
152 const explanation = clean(fields.explanation, LIMITS.explanation)
153 fixes.push({
154 original,
155 fix: replacement,
156 reason: clean(fields.reason, LIMITS.reason),
157 category: categoryOf(fields.category),
158 ...(explanation !== '' && { explanation }),
159 })
160 }
161
162 return fixes.slice(0, MAX_FIXES)
163}
164hooks/history.ts 120 lines1import type { Category, Daily, Day, Entry, Fix, Remembered } from '../types'
2
3export const HISTORY_LIMIT = 500
4
5// $.store holds at most 4 MiB of JSON for the whole plugin; history and the cache each get well under half.
6export const BYTE_BUDGET = 1_500_000
7
8export const withinBudget = <T>(rows: readonly T[], budget = BYTE_BUDGET): T[] => {
9 const sizes = rows.map(row => JSON.stringify(row).length + 1)
10 let total = sizes.reduce((sum, size) => sum + size, 2)
11 let first = 0
12 while (total > budget && first < rows.length) total -= sizes[first++]!
13
14 return rows.slice(first)
15}
16
17export type Recurring = { original: string; fix: string; count: number; explanation?: string }
18
19export type Summary = {
20 prompts: number
21 corrections: number
22 byCategory: { category: Category; count: number }[]
23 recurring: Recurring[]
24}
25
26export const appendEntry = (entries: readonly Entry[], entry: Entry) =>
27 withinBudget([...entries, entry].slice(-HISTORY_LIMIT))
28
29export const asEntries = (stored: unknown): Entry[] =>
30 Array.isArray(stored) ? stored.filter((entry): entry is Entry => Array.isArray(entry?.fixes)) : []
31
32export const summarize = (entries: readonly Entry[], recurringLimit = 8): Summary => {
33 const byCategory = new Map<Category, number>()
34 const pairs = new Map<string, Recurring>()
35 let corrections = 0
36
37 for (const entry of entries) {
38 for (const fix of entry.fixes) {
39 corrections++
40 byCategory.set(fix.category, (byCategory.get(fix.category) ?? 0) + 1)
41
42 const key = `${fix.original.toLowerCase()}\u0000${fix.fix.toLowerCase()}`
43 const pair = pairs.get(key)
44 if (pair) pair.count++
45 else pairs.set(key, { original: fix.original, fix: fix.fix, count: 1 })
46 if (fix.explanation !== undefined) pairs.get(key)!.explanation = fix.explanation
47 }
48 }
49
50 return {
51 prompts: entries.length,
52 corrections,
53 byCategory: [...byCategory].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count),
54 recurring: [...pairs.values()]
55 .filter(pair => pair.count > 1)
56 .sort((a, b) => b.count - a.count)
57 .slice(0, recurringLimit),
58 }
59}
60
61const DAY_MS = 86_400_000
62const DAILY_LIMIT = 90
63
64const pad = (value: number) => String(value).padStart(2, '0')
65
66export const dayKey = (at: number) => {
67 const date = new Date(at)
68
69 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
70}
71
72export const lastDays = (now: number, count: number) =>
73 Array.from({ length: count }, (_, index) => dayKey(now - (count - 1 - index) * DAY_MS))
74
75export const asDaily = (stored: unknown): Daily => {
76 if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
77
78 return Object.fromEntries(
79 Object.entries(stored).filter(
80 (entry): entry is [string, Day] => typeof entry[1]?.prompts === 'number' && typeof entry[1]?.fixes === 'number',
81 ),
82 )
83}
84
85export const recordReview = (daily: Daily, day: string, fixes: number): Daily => {
86 const today = daily[day] ?? { prompts: 0, fixes: 0 }
87 const updated = { ...daily, [day]: { prompts: today.prompts + 1, fixes: today.fixes + fixes } }
88 const kept = Object.keys(updated).sort().slice(-DAILY_LIMIT)
89
90 return Object.fromEntries(kept.map(key => [key, updated[key]!]))
91}
92
93export const nextStreak = (streak: number, fixes: number) => (fixes === 0 ? streak + 1 : 0)
94
95export type CoachState = 'on' | 'paused' | 'off'
96
97export const statusText = (state: CoachState, today: Day | undefined, streak: number) => {
98 if (state === 'off') return undefined
99 if (state === 'paused') return '✎ coach paused'
100
101 const fixes = today?.fixes ?? 0
102
103 return `✎ ${fixes} ${fixes === 1 ? 'fix' : 'fixes'} today · ${streak} clean in a row`
104}
105
106const REMEMBERED_LIMIT = 300
107
108export const asRemembered = (stored: unknown): Remembered[] =>
109 Array.isArray(stored)
110 ? stored.filter((row): row is Remembered => typeof row?.key === 'string' && Array.isArray(row?.fixes))
111 : []
112
113export const remember = (rows: readonly Remembered[], key: string, fixes: Fix[]) =>
114 withinBudget([...rows.filter(row => row.key !== key), { key, fixes }].slice(-REMEMBERED_LIMIT))
115
116export const forget = (rows: readonly Remembered[], key: string) => rows.filter(row => row.key !== key)
117
118export const rememberedByKey = (rows: readonly Remembered[]): Record<string, Fix[]> =>
119 Object.fromEntries(rows.map(row => [row.key, row.fixes]))
120hooks/trend.ts 81 lines1import type { Daily } from '../types'
2import { lastDays } from './history'
3
4export const TREND_DAYS = 30
5
6export type TrendBar = { day: string; prompts: number; rate: number | undefined }
7
8const LEVELS = '▁▂▃▄▅▆▇█'
9const NO_PROMPTS = '·'
10const DEFAULT_COLOR = 0x01000000
11const GOOD = 0x3fb950
12const FAIR = 0xd29922
13const POOR = 0xf85149
14const EMPTY = 0x8b949e
15
16export const trendOf = (daily: Daily, now: number): TrendBar[] =>
17 lastDays(now, TREND_DAYS).map(day => {
18 const stats = daily[day]
19
20 return { day, prompts: stats?.prompts ?? 0, rate: stats && stats.prompts > 0 ? stats.fixes / stats.prompts : undefined }
21 })
22
23const colorOf = (rate: number | undefined) =>
24 rate === undefined ? EMPTY : rate < 0.25 ? GOOD : rate < 0.75 ? FAIR : POOR
25
26export const hexOf = (rate: number | undefined) => `#${colorOf(rate).toString(16).padStart(6, '0')}`
27
28const scaleOf = (bars: readonly TrendBar[]) => Math.max(1, ...bars.map(bar => bar.rate ?? 0))
29
30export const glyphOf = (rate: number | undefined, scale: number) => {
31 if (rate === undefined) return NO_PROMPTS
32 const level = rate === 0 ? 0 : Math.min(LEVELS.length - 1, Math.max(1, Math.ceil((rate / scale) * (LEVELS.length - 1))))
33
34 return LEVELS[level]!
35}
36
37export const glyphsOf = (bars: readonly TrendBar[]) => {
38 const scale = scaleOf(bars)
39
40 return bars.map(bar => ({ glyph: glyphOf(bar.rate, scale), color: hexOf(bar.rate) }))
41}
42
43export const rasterCells = (bars: readonly TrendBar[]) => {
44 const scale = scaleOf(bars)
45 const words = new Uint32Array(bars.length * 3)
46
47 bars.forEach((bar, index) => {
48 words[index * 3] = glyphOf(bar.rate, scale).codePointAt(0)!
49 words[index * 3 + 1] = colorOf(bar.rate)
50 words[index * 3 + 2] = DEFAULT_COLOR
51 })
52
53 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64: () => string }).toBase64()
54}
55
56export const trendSvg = (bars: readonly TrendBar[]) => {
57 const scale = scaleOf(bars)
58 const width = 10
59 const height = 40
60 const shapes = bars.map((bar, index) => {
61 const x = index * width + 1
62 const tip = `${bar.day}: ${bar.rate === undefined ? 'no prompts' : `${bar.rate.toFixed(2)} fixes per prompt (${bar.prompts} prompts)`}`
63 if (bar.rate === undefined) {
64 return `<circle cx="${x + 4}" cy="${height - 2}" r="1.5" fill="${hexOf(undefined)}"><title>${tip}</title></circle>`
65 }
66 const barHeight = Math.max(2, Math.round((bar.rate / scale) * (height - 4)))
67
68 return `<rect x="${x}" y="${height - barHeight}" width="${width - 2}" height="${barHeight}" rx="1.5" fill="${hexOf(bar.rate)}"><title>${tip}</title></rect>`
69 })
70
71 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${bars.length * width} ${height}" width="${bars.length * width}" height="${height}">${shapes.join('')}</svg>`
72}
73
74export const trendSummary = (bars: readonly TrendBar[]) => {
75 const active = bars.filter(bar => bar.rate !== undefined)
76 const prompts = active.reduce((sum, bar) => sum + bar.prompts, 0)
77 const average = prompts === 0 ? 0 : active.reduce((sum, bar) => sum + (bar.rate ?? 0) * bar.prompts, 0) / prompts
78
79 return `fixes per prompt, last ${bars.length} days · average ${average.toFixed(2)} over ${prompts} prompts`
80}
81hooks/wrap.ts 22 lines1export const linesWhenWrapped = (text: string, width: number) => {
2 const room = Math.max(1, width)
3 let lines = 1
4 let used = 0
5
6 for (const word of text.split(/\s+/).filter(Boolean)) {
7 const length = [...word].length
8 const needed = used === 0 ? length : used + 1 + length
9
10 if (needed <= room) {
11 used = needed
12 continue
13 }
14
15 lines += used === 0 ? 0 : 1
16 lines += Math.floor(Math.max(0, length - 1) / room)
17 used = length % room || room
18 }
19
20 return lines
21}
22hooks/prompt.ts 49 lines1export const SYSTEM = `You are a {{TARGET_LANGUAGE}} writing coach inside a coding tool. The user is a native {{NATIVE_LANGUAGE}} speaker who writes prompts to a coding agent in {{TARGET_LANGUAGE}} to practise the language. Your corrections appear under every prompt they send, so a pedantic or wrong correction costs more than a missed one. Real errors, however, must be caught: that is why the user installed you.
2
3Calibrate like a careful native {{TARGET_LANGUAGE}}-speaking senior engineer reading a colleague's Slack message: flag what that person would actually correct, and nothing they would let pass.
4
5FLAG (these count as real errors, not preferences):
6- misspellings and typos, including real words used by mistake (homophones, near-spellings)
7- wrong word or wrong word form
8- grammar: agreement, verb forms and tenses, missing or wrong articles and determiners, plurals and countability, comparatives, word order, duplicated words
9- {{NATIVE_LANGUAGE}} transfer errors: false friends (a word that looks like a {{NATIVE_LANGUAGE}} word but means something else in {{TARGET_LANGUAGE}}, judged by what the user clearly means), literal calques (including a verb+noun pair translated word for word), prepositions that a native would not use with that noun or verb, verb patterns copied from {{NATIVE_LANGUAGE}}
10- phrasing a native would clearly rewrite because it sounds foreign, not merely because it could be smoother
11
12Silently read the prompt sentence by sentence and check every article, preposition, verb form and false-friend candidate; these are the errors {{NATIVE_LANGUAGE}} speakers make most and miss most.
13
14DO NOT FLAG:
15- letter case of any kind (lowercase sentence starts, lowercase names), informal tone, slang, fragments, dropped subjects, terse imperatives, missing final punctuation, run-on chat sentences
16- technical jargon, abbreviations, product and tool names, code, commands, CLI flags, file paths, identifiers, URLs, versions, error codes, anything in backticks
17- anything inside quotation marks: it cites someone else's words or a literal term, so leave it alone even if it contains errors
18- {{NATIVE_LANGUAGE}} or domain terms used on purpose (document types, statuses, institutions)
19- correct text that could merely be "better", valid stylistic choices, regional spelling variants
20- the content itself: never suggest a different technical approach, tool, scope or request
21
22Before emitting a fix, vet it silently against four checks and drop it if any fails:
231. A native editor would mark the original as an error, not a matter of taste. Text that a native would write is never a transfer error, even if it resembles {{NATIVE_LANGUAGE}}.
242. The fix keeps the user's meaning and tone.
253. The original is ordinary prose, outside code, names and quotation marks.
264. It changes language only, not content.
27
28If the prompt is not mainly written in {{TARGET_LANGUAGE}}, return {"fixes":[]}.
29
30OUTPUT: reply with one raw JSON object and nothing else. No reasoning, no markdown code fences, no text before or after.
31{"fixes":[{"original":"...","fix":"...","reason":"...","category":"...","explanation":"..."}]}
32- original: exact substring copied verbatim from the prompt, only the few words that contain the error
33- fix: the single corrected replacement for that substring (never alternatives)
34- reason: written in {{TARGET_LANGUAGE}}, at most 12 words; says only what is wrong here, not the rule. For a transfer error, name the {{NATIVE_LANGUAGE}} word or pattern it copies
35- category: exactly one of typo | grammar | transfer | word-choice | phrasing. Any error that copies {{NATIVE_LANGUAGE}} (false friend, calque, preposition, verb pattern or verb+noun pair) is transfer, never word-choice or grammar, even when it is also a wrong word or a grammar slip
36- explanation: written in {{TARGET_LANGUAGE}}, 1-2 short sentences, under 30 words. It must teach what the reason does not say:
37 - give the reusable pattern: how this word or structure is normally used, with a tiny example of the correct form; never restate the reason in other words
38 - for two similar words mixed up, give a typical phrase with the right word instead of defining both again
39 - use plain everyday words, not grammar terms such as 'transitive', 'modal', 'auxiliary' or 'collective noun'
40 - state only what you are certain is true; never invent or overgeneralise a rule. If unsure of the rule, give just the correct pattern with an example
41 - put quoted words in single quotes; name {{NATIVE_LANGUAGE}} only for transfer errors
42- One fix per error, at most 5, most important first; every fix fills all five fields and fix differs from original. Clean prompt: {"fixes":[]}`
43
44export const USER_TEMPLATE = `<prompt>
45{{PROMPT}}
46</prompt>
47
48Review the prompt above (written in {{TARGET_LANGUAGE}} by a native {{NATIVE_LANGUAGE}} speaker). Your reply must start with { and end with }, with no code fences.`
49types/index.d.ts 27 lines1export type Category = 'typo' | 'grammar' | 'transfer' | 'word-choice' | 'phrasing'
2
3export type Fix = { original: string; fix: string; reason: string; category: Category; explanation?: string }
4
5export type Coaching = { fixes: Fix[] }
6
7export type Entry = { at: number; language: string; fixes: Fix[] }
8
9export type Day = { prompts: number; fixes: number }
10
11export type Daily = Record<string, Day>
12
13export type Remembered = { key: string; fixes: Fix[] }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'language-coach': {
18 coaching: StateFamily<Coaching>
19 history: Entry[]
20 daily: Daily
21 remembered: Record<string, Fix[]>
22 streak: number
23 isPaused: boolean
24 }
25 }
26}
27