When you correct Claude, offers to save the lesson as one feedback memory file plus a MEMORY.md pointer line, after checking it is not already in memory

Small add-ons for Claude Code. They work on Mac and Windows, in the terminal, in VS Code and in the desktop app.
When you correct Claude ("no, don't...", "I said shorter"), a box shows above the prompt when the reply ends:
● Save this lesson to memory? "Write the lesson in one line." → feedback_write_the_lesson.md [Save] [Skip]
Press Save to keep the lesson. Every new chat in that project then knows it.
feedback_<name>.md file to the project's Claude Code memory folder (~/.claude/projects/<project>/memory/). It also adds one link line to that folder's MEMORY.md. It never edits CLAUDE.md.MEMORY.md would lose a line./lesson to turn it off or back on.Cost: a prompt with correction words costs one small model call. A real correction costs one Sonnet call to write the lesson and one Haiku call to check for a duplicate.
You need Claude Code 2.1.287 or newer. Check with claude --version and update with claude update.
Type these at the Claude Code prompt:
/plugin marketplace add chrisatswift/claude-mods
/plugin install lesson-save@chris-mods
/reload-plugins
Optional: set your name so saved lessons say "Wade" instead of "the user":
/plugin configure lesson-save@chris-mods
The same screen has a Memory folder setting. Leave it empty to use each project's own memory folder. Fill it in to save every lesson to one fixed folder.
/plugin marketplace update chris-mods
/reload-pluginshooks/register.tsx 393 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Lesson, Offer } from '../types'
5
6// When the user corrects Claude, offer to keep the lesson as Claude Code memory:
7// one feedback_*.md file in the project's memory folder plus one pointer line
8// in MEMORY.md. Never touches CLAUDE.md. Uses $.fs only, so it runs on Windows.
9
10const offerAtom = atom({ plugin: 'lesson-save', key: 'offer' } as const, null)
11
12const SAVED_MS = 8000
13const ACCENT = '#c2410c'
14const GOOD = '#15803d'
15const BAD = '#b91c1c'
16
17// Cheap first pass: only a prompt with one of these costs a model call.
18const HINTS = [
19 /\bno\b/i,
20 /\bdon'?t\b/i,
21 /\bdo not\b/i,
22 /\bnever\b/i,
23 /\bstop\b/i,
24 /\binstead\b/i,
25 /\bi (said|told you|asked)\b/i,
26 /\bwrong\b/i,
27 /\btoo (long|short|much|many|wordy)\b/i,
28 /\bnot (like )?that\b/i,
29 /\balways\b/i,
30 /\bavoid\b/i,
31 /\bwhy did you\b/i,
32 /\bagain\b/i,
33]
34
35// Module variables start over on a reload; the offer itself lives in the atom.
36let home = ''
37let cwd = ''
38let configuredDir = ''
39let who = 'the user'
40let enabled = true
41let gen = 0
42let turnDone = true
43let pending: Offer | null = null
44let nextId = 1
45
46const slash = (p: string) => p.replace(/\\/g, '/')
47
48// Claude Code keeps a project's memory under ~/.claude/projects/<cwd with every
49// non-alphanumeric character turned into a dash>/memory.
50export const projectSlug = (dir: string) => dir.replace(/[^a-zA-Z0-9]/g, '-')
51const memoryDir = () => slash(configuredDir.trim() || `${home}/.claude/projects/${projectSlug(cwd)}/memory`).replace(/\/+$/, '')
52
53export const looksLikeCorrection = (text: string) => {
54 const t = text.trim()
55 if (!t || t.startsWith('/') || t.length > 1500) return false
56 return HINTS.some(re => re.test(t))
57}
58
59export const toSlug = (raw: string) =>
60 raw
61 .toLowerCase()
62 .replace(/^feedback[-_]/, '')
63 .replace(/[^a-z0-9]+/g, '-')
64 .replace(/^-+|-+$/g, '')
65 .split('-')
66 .slice(0, 6)
67 .join('-')
68 .slice(0, 60)
69
70export const fileOf = (slug: string) => `feedback_${slug.replace(/-/g, '_')}.md`
71
72const oneLine = (s: unknown) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '')
73
74const draftPrompt = (correction: string, lastReply: string) => `${who} corrected an AI assistant in a Claude Code chat. Decide if the correction holds a lesson for future chats. If it does, write it as one memory note.
75
76The assistant's last reply (first 1,500 characters):
77<reply>
78${lastReply}
79</reply>
80
81${who}'s correction:
82<correction>
83${correction}
84</correction>
85
86Rules:
87- Use only what the correction and reply say. Invent no facts, names or numbers.
88- rule: the lesson in plain imperative English, one or two short sentences.
89- how: when and where it applies, one sentence.
90- description: one line under 120 characters, used to decide relevance later.
91- title: 2 to 6 words.
92- slug: 3 to 6 kebab-case words.
93- Refer to the person as ${who}, with they/them if a pronoun is needed.
94
95Reply with JSON only: {"slug": "...", "title": "...", "description": "...", "rule": "...", "how": "..."}
96If it is a one-off fix for this task only, reply {"slug": "NONE"}.`
97
98export function parseDraft(text: string): Lesson | null {
99 const m = /\{[\s\S]*\}/.exec(text)
100 if (!m) return null
101 let j: Record<string, unknown>
102 try {
103 j = JSON.parse(m[0]) as Record<string, unknown>
104 } catch {
105 return null
106 }
107 const slug = toSlug(oneLine(j.slug))
108 if (!slug || slug === 'none') return null
109 const lesson = {
110 slug,
111 title: oneLine(j.title),
112 description: oneLine(j.description),
113 rule: oneLine(j.rule),
114 how: oneLine(j.how),
115 }
116 return lesson.title && lesson.description && lesson.rule ? lesson : null
117}
118
119export function noteText(o: Offer, isoNow: string, by: string) {
120 const quote = o.correction.replace(/\s+/g, ' ').trim().slice(0, 200).replace(/"/g, "'")
121 const name = by ? by[0]!.toUpperCase() + by.slice(1) : 'User'
122 return `---
123name: feedback-${o.slug}
124description: "${o.description.replace(/"/g, "'")}"
125metadata:
126 type: feedback
127 disposition: prose
128 source: lesson-save mod
129 modified: ${isoNow}
130---
131
132${o.rule}
133
134**Why:** ${name}, ${isoNow.slice(0, 10)}: "${quote}"
135
136**How to apply:** ${o.how || o.rule}
137`
138}
139
140// Adds the pointer before a "**Cold tier**" line or a "## Projects" heading if
141// the index has one, else at the end. Keeps every other line.
142export function withPointer(index: string, line: string) {
143 const lines = index.split('\n')
144 let at = lines.findIndex(l => l.startsWith('**Cold tier**'))
145 if (at === -1) at = lines.findIndex(l => /^## Projects/.test(l))
146 if (at === -1) return `${index.replace(/\s+$/, '')}\n${line}\n`
147 while (at > 0 && !lines[at - 1]!.trim()) at -= 1
148 return [...lines.slice(0, at), line, ...lines.slice(at)].join('\n')
149}
150
151export type Known = { file: string; text: string }
152
153const STOP = new Set(['that', 'this', 'with', 'when', 'what', 'from', 'into', 'than', 'then', 'them', 'they', 'their', 'there', 'have', 'does', 'dont', 'never', 'always', 'user', 'claude', 'turn', 'should', 'would', 'every', 'about', 'only'])
154const wordsOf = (s: string) => new Set(s.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter(w => w.length >= 4 && !STOP.has(w)))
155
156// Local, free first pass: the existing notes that share the most words with the lesson.
157export function topCandidates(lesson: Lesson, known: readonly Known[], max = 8) {
158 const want = wordsOf(`${lesson.slug.replace(/-/g, ' ')} ${lesson.title} ${lesson.description} ${lesson.rule}`)
159 return known
160 .map(k => {
161 const have = wordsOf(k.text)
162 let score = 0
163 for (const w of want) if (have.has(w)) score += 1
164 return { k, score }
165 })
166 .filter(x => x.score >= 2)
167 .sort((a, b) => b.score - a.score)
168 .slice(0, max)
169 .map(x => x.k)
170}
171
172// The name, description and first body line of every feedback and user note.
173async function catalog($: EngineInterface): Promise<Known[]> {
174 const dir = memoryDir()
175 const out: Known[] = []
176 for (const en of await $.fs.list(dir)) {
177 if (en.kind !== 'file' || !/^(feedback|user)_.*\.md$/.test(en.name)) continue
178 const raw = String(await $.fs.read(`${dir}/${en.name}`)).slice(0, 1500)
179 const desc = /^description:\s*"?(.+?)"?\s*$/m.exec(raw)?.[1] ?? ''
180 const body = raw.split(/^---\s*$/m)[2]?.trim().split('\n')[0] ?? ''
181 out.push({ file: en.name, text: `${en.name.replace(/[_.-]/g, ' ')} ${desc} ${body}` })
182 }
183 return out
184}
185
186// One small model call over the few candidates: which one, if any, already holds this lesson.
187async function coveredBy($: EngineInterface, lesson: Lesson): Promise<string> {
188 const picks = topCandidates(lesson, await catalog($))
189 if (!picks.length) return ''
190 const list = picks.map((k, i) => `${i + 1}. ${k.file}: ${k.text.slice(0, 300)}`).join('\n')
191 const r = await $.model.complete(
192 {
193 model: 'haiku',
194 prompt: `New lesson: ${lesson.rule}\n\nExisting memory notes:\n${list}\n\nDoes one existing note already hold the same lesson, even in other words? Reply with its number only, or 0 if none does.`,
195 maxTokens: 10,
196 },
197 {},
198 )
199 if (!r.isAnswered) return ''
200 const n = Number.parseInt(r.text.trim(), 10)
201 return n >= 1 && n <= picks.length ? picks[n - 1]!.file : ''
202}
203
204async function setOffer($: EngineInterface, o: Offer | null) {
205 await update($, offerAtom, () => o)
206}
207
208function lastAssistantText(messages: readonly SessionMessage[]) {
209 for (let i = messages.length - 1; i >= 0; i -= 1) {
210 const m = messages[i]!
211 if (m.role === 'assistant' && m.text.trim()) return m.text
212 }
213 return ''
214}
215
216async function show($: EngineInterface, o: Offer) {
217 if (enabled) await setOffer($, o)
218}
219
220// Runs in the background after a prompt: filter, classify, draft, check for a duplicate.
221async function check($: EngineInterface, text: string, mine: number) {
222 if (!looksLikeCorrection(text)) return
223 const lastReply = lastAssistantText(await $.session.messages())
224 if (!lastReply) return
225 const kind = await $.model.classify(text, ['correction', 'other'])
226 if (kind !== 'correction' || mine !== gen) return
227 const r = await $.model.complete({ model: 'sonnet', prompt: draftPrompt(text, lastReply.slice(0, 1500)), maxTokens: 400 }, {})
228 if (!r.isAnswered) throw new Error(`draft failed: ${r.reason}`)
229 const lesson = parseDraft(r.text)
230 if (!lesson || mine !== gen) return
231 let file = fileOf(lesson.slug)
232 let exists = await $.fs.exists(`${memoryDir()}/${file}`)
233 if (!exists) {
234 // A failed check must not hide the offer: worst case is a duplicate the user can skip.
235 const match = await coveredBy($, lesson).catch(() => '')
236 if (match) {
237 file = match
238 exists = true
239 }
240 }
241 if (mine !== gen) return
242 const o: Offer = { ...lesson, phase: exists ? 'exists' : 'offer', id: nextId++, correction: text.trim(), file, note: '' }
243 if (turnDone) await show($, o)
244 else pending = o
245}
246
247async function save($: EngineInterface, o: Offer) {
248 const dir = memoryDir()
249 const notePath = `${dir}/${o.file}`
250 const indexPath = `${dir}/MEMORY.md`
251 if (await $.fs.exists(notePath)) throw new Error(`${o.file} already exists`)
252
253 const note = noteText(o, new Date(await $.clock.now()).toISOString(), who)
254 await $.fs.write(notePath, note)
255 if (String(await $.fs.read(notePath)) !== note) throw new Error(`${o.file} did not read back as written`)
256
257 // A project with no memory yet gets a fresh index.
258 const old = (await $.fs.exists(indexPath)) ? String(await $.fs.read(indexPath)) : ''
259 const next = withPointer(old, `- [${o.title}](${o.file}) — ${o.description}`)
260 const kept = new Set(next.split('\n'))
261 if (old.split('\n').some(l => l.trim() && !kept.has(l))) throw new Error('MEMORY.md would lose lines; index not changed')
262 await $.fs.write(indexPath, next)
263 if (String(await $.fs.read(indexPath)) !== next) throw new Error('MEMORY.md did not read back as written')
264}
265
266async function hideLater($: EngineInterface, id: number) {
267 await $.clock.sleep(SAVED_MS)
268 const o = await read($, offerAtom)
269 if (o && o.id === id && o.phase === 'saved') await setOffer($, null)
270}
271
272async function onSave($: EngineInterface) {
273 const o = await read($, offerAtom)
274 if (!o || o.phase !== 'offer') return
275 try {
276 await save($, o)
277 const id = nextId++
278 await setOffer($, { ...o, phase: 'saved', id, note: '' })
279 void hideLater($, id).catch(() => undefined)
280 } catch (err) {
281 await setOffer($, { ...o, note: `Not saved: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}` })
282 }
283}
284
285export const register: Register = (on, options) => {
286 configuredDir = typeof options.memory_dir === 'string' ? options.memory_dir : ''
287 who = typeof options.name === 'string' && options.name.trim() ? options.name.trim() : 'the user'
288
289 on('session.start', async ($, e, next) => {
290 home = slash($.plugin.root).split('/.claude/')[0] ?? ''
291 cwd = e.cwd
292 await $.command.register({ name: 'lesson', description: 'Turn lesson-save on or off' })
293 enabled = (await $.store.get('enabled')) !== false
294 return next(e)
295 })
296
297 on('prompt.submit', async ($, e, next) => {
298 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
299 gen += 1
300 turnDone = false
301 pending = null
302 const mine = gen
303 // A new prompt makes an unanswered offer stale.
304 void (async () => {
305 const o = await read($, offerAtom)
306 if (o && o.phase !== 'saved') await setOffer($, null)
307 })().catch(() => undefined)
308 if (enabled) void check($, e.text, mine).catch(err => $.ui.log(`lesson-save: ${String(err)}`, { to: 'debug' }))
309 return next(e)
310 })
311
312 on('turn.complete', async ($, e, next) => {
313 const result = await next(e)
314 if (e.agentId === undefined) {
315 turnDone = true
316 if (pending) {
317 const o = pending
318 pending = null
319 void show($, o).catch(() => undefined)
320 }
321 }
322 return result
323 })
324
325 on('command.run', { command: 'lesson' }, async $ => {
326 enabled = !enabled
327 await $.store.set('enabled', enabled)
328 if (!enabled) {
329 pending = null
330 await setOffer($, null)
331 }
332 return { text: enabled ? 'lesson-save is on. Corrections get a "Save this lesson?" offer.' : 'lesson-save is off. Type /lesson to turn it back on.' }
333 })
334
335 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
336 if (!enabled || e.props.hasSurvey || e.props.isWorking) return next(e)
337 const o = await read($, offerAtom)
338 if (!o) return next(e)
339 const rest = await next(e)
340 const { Box, Text, Button } = $.ui.resolve(e)
341 const dismiss = () => setOffer($, null)
342
343 if (o.phase === 'saved') {
344 return (
345 <Box flexDirection="column">
346 {rest}
347 <Text wrap="truncate-middle">
348 <Text color={GOOD}>✓</Text>
349 <Text> Saved lesson to </Text>
350 <Text dimColor>{o.file}</Text>
351 <Text> and MEMORY.md</Text>
352 </Text>
353 </Box>
354 )
355 }
356
357 if (o.phase === 'exists') {
358 return (
359 <Box flexDirection="column">
360 {rest}
361 <Box flexDirection="row" alignItems="center" gap={1}>
362 <Text wrap="truncate-middle">
363 <Text color={ACCENT}>●</Text>
364 <Text> Already in memory: </Text>
365 <Text dimColor>{o.file}</Text>
366 </Text>
367 <Button key="dismiss" label="OK" role="dismiss" onPress={dismiss} />
368 </Box>
369 </Box>
370 )
371 }
372
373 return (
374 <Box flexDirection="column">
375 {rest}
376 <Box flexDirection="column">
377 <Text>
378 <Text color={ACCENT}>●</Text>
379 <Text weight="semibold"> Save this lesson to memory?</Text>
380 </Text>
381 <Text>"{o.rule}"</Text>
382 <Text dimColor>→ {o.file}</Text>
383 <Box flexDirection="row" gap={1}>
384 <Button key="save" label="Save" variant="primary" onPress={() => onSave($)} />
385 <Button key="skip" label="Skip" role="dismiss" onPress={dismiss} />
386 </Box>
387 {o.note ? <Text color={BAD}>{o.note}</Text> : null}
388 </Box>
389 </Box>
390 )
391 })
392}
393types/index.d.ts 32 lines1export type Lesson = {
2 /** kebab-case, no prefix: becomes feedback-<slug> and feedback_<slug>.md. */
3 slug: string
4 /** Short title for the MEMORY.md link. */
5 title: string
6 /** One line, used to decide relevance during recall. */
7 description: string
8 /** The lesson itself, one or two sentences. */
9 rule: string
10 /** When and where to apply it. */
11 how: string
12}
13
14export type Offer = Lesson & {
15 /** offer: Save / Skip. saved: a tick for a few seconds. exists: the lesson is already in memory. */
16 phase: 'offer' | 'saved' | 'exists'
17 /** New per offer, so an old hide timer never clears a newer one. */
18 id: number
19 /** The user's correction, as typed. */
20 correction: string
21 /** The memory file name, e.g. feedback_no_em_dashes.md, or the existing note that holds the lesson. */
22 file: string
23 /** An error line under the buttons; empty when fine. */
24 note: string
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'lesson-save': { offer: Offer | null }
30 }
31}
32