SLOPSHOPPER

language-coach

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…

newpanerowscommandtoastprompt
★ 3v0.3.0MITupdated 2026-10-06sergiojrdotnet/claude-language-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · language-coach
│ ┃ English coach ✕ › fix the failing auth test and add an audit log call │ ┃ English coach · on · 0 corrections across 0 │ ┃ prompts ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No corrections yet. Fixes show up under each ⏺ Update(src/auth.ts) │ ┃ prompt you write in English. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Pause ] [ Clear history ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /language-coach │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · English coach
English coach · on · 0 corrections across 0 prompts No corrections yet. Fixes show up under each prompt you write in English. [ Pause ] [ Clear history ]
README

<img src="Logo.png" alt="Language Coach, a Claude Code plugin" width="640">

Language Coach for Claude Code

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

Fixes under a prompt: the wrong words in red, the fixes in green, with the category and a short reason

Each fix also comes with a short explanation of the rule behind it:

  • Fullscreen terminal or desktop app: hover over the fix to see the explanation in a card.
  • Other surfaces: the explanation prints as a dim ↳ line under the fix.

To always print it inline, or to hide it, change the Explanations setting (see Settings).

Hovering a fix opens a card that explains the rule behind it

Fixes stay attached to their prompts after --resume.

Install

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.

Use

You doYou get
Type a promptAbout 1–2 s later, any fixes appear under your prompt. A clean prompt shows nothing.
/language-coachA 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 onPause or resume the coach for the current session.

The /language-coach pane beside the session: totals by category, the 30-day trend, recurring mistakes and recent fixes

Prompts are skipped when they:

  • are not mainly in your target language
  • start with / or !
  • are shorter than the minimum length
  • are longer than the maximum length (usually pasted logs)

Prompts that you did not type are also skipped: task notifications, scheduled runs and messages from other sessions.

Settings

Change them under /plugin → Installed → language-coach → Configure options:

SettingDefaultWhat it does
Language you practiseEnglishThe language your prompts are coached in.
Your native languageBrazilian PortugueseHelps the coach spot transfer errors (false friends, calques, prepositions).
Coach modelhaikuhaiku, sonnet or opus. Haiku is fast and cheap.
Coach effortlowHow 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 length8Shorter prompts are not coached.
Maximum prompt length2000Longer prompts are not coached.
Explanationspopuppopup 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 promptsonTurns the coach off without uninstalling it.

Status line

The coach exports its counters as environment variables. Your status line command inherits them, so you can show them in your own layout:

VariableExampleWhat it holds
LANGUAGE_COACH_STATUS✎ 2 fixes today · 5 clean in a rowA ready-made line. ✎ coach paused while paused, unset when the coach is off.
LANGUAGE_COACH_STATEonon, paused (/language-coach off) or off (Coach my prompts turned off).
LANGUAGE_COACH_REVIEWS_TODAY7Prompts reviewed today.
LANGUAGE_COACH_FIXES_TODAY2Fixes found today.
LANGUAGE_COACH_STREAK5Clean 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.

How it works

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 ◄───────┘
  • Separate review call: the review is a one-shot $.model.complete call. It has no tools and no conversation history, and it uses the credentials of your Claude Code session.
  • Display only: fixes go into session state and are drawn by a ui.render hook on your prompt's row. The stored message and the model's context are never changed.
  • History: stored with the mod's $.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.

Cost and privacy

  • Tokens per review: about 2k input tokens (roughly half of it Claude Code's fixed identity block, the rest the coach prompt) and about 85 output tokens on Haiku.
  • Plan usage: reviews count against your plan or API usage like any other request.
  • Where prompts go: your prompts go to Anthropic through your own Claude Code login, the same destination as the prompt itself. Nothing is sent anywhere else.

Development

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.

Source 7 files
hooks/register.tsx 365 lines
1import { 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}
365
hooks/coach.ts 164 lines
1import 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}
164
hooks/history.ts 120 lines
1import 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]))
120
hooks/trend.ts 81 lines
1import 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}
81
hooks/wrap.ts 22 lines
1export 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}
22
hooks/prompt.ts 49 lines
1export 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.`
49
types/index.d.ts 27 lines
1export 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