SLOPSHOPPER

lesson-save

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

newbandcommandpromptmodel
v0.3.0MITupdated 2026-10-22chrisatswift/claude-mods/lesson-save
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lesson-save
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lesson ⎿ lesson-save: lesson-save is off. Type /lesson to turn it back on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

Small add-ons for Claude Code. They work on Mac and Windows, in the terminal, in VS Code and in the desktop app.

lesson-save

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.

  • Save writes one 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.
  • It checks your existing notes first. If the lesson is already there, it shows Already in memory and saves nothing.
  • It never overwrites a file, and it stops if MEMORY.md would lose a line.
  • Nothing saves until you press Save. Type /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.

Install

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.

Update

/plugin marketplace update chris-mods
/reload-plugins
Source 2 files
hooks/register.tsx 393 lines
1import { 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}
393
types/index.d.ts 32 lines
1export 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