SLOPSHOPPER

memory-hints

Points Claude to the saved auto-memory notes that match each prompt you type, so a note is read before the work it applies to instead of sitting unread behind…

newtoastprompt
★ 2v0.1.2MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/memory-hints
A shopper browsing a rack in a slop shop
README

memory-hints

Claude Code's auto memory loads only the MEMORY.md index into each session: one line per note. The notes themselves are read only when Claude decides to open one, and a one-line index entry is easy to pass over. In my sessions Claude opened 15 distinct topic notes in a month, out of more than 200.

This plugin looks at each prompt you type and finds the saved notes that match it. It adds their paths to the prompt's context and asks Claude to read a note before acting when its description fits, and to say in one line, as (memory: <name>), when a note changes what it does.

How it works

  • The memory folder is the one the session's context breakdown lists for the MEMORY.md index. The plugin reads the name and description frontmatter of every other .md file there, and reads a file again only when it changes.
  • A note matches when it shares at least 2 words with your prompt. Each shared word scores by how rare it is among your notes, so a word most notes contain counts for little. At most 2 notes above the minimum score are hinted per prompt, and each note at most once per session.
  • Pasted content and fenced code blocks are left out of the match, since logs and exports name many records and fields.
  • Slash commands, claude -p and SDK prompts, and prompts that a task, a scheduled trigger or the engine sent are not matched. /clear starts the once-per-session count over.

On a month of my own prompts at the default score of 8, 3% of prompts got a hint in one memory folder and 24% in another, and the first hinted note fit the request in 24 of 30 sampled prompts.

Options

minScore (default 8): how strongly a note must match. Raise it for fewer, surer hints.

echo '{"minScore": "10"}' | claude plugin configure memory-hints@lbzepoqo --values-stdin

Cost

No model calls. Each hinted note adds about 50 tokens to the prompt, plus the Read when Claude opens it.

Install

claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install memory-hints@lbzepoqo

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.ts 99 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { hintText, indexNote, match, parseNote, type NoteTerms } from './match'
5
6const folder = atom({ plugin: 'memory-hints', key: 'folder' } as const, '')
7const hinted = atom({ plugin: 'memory-hints', key: 'hinted' } as const, [] as string[])
8
9const LIMIT = 2
10const MIN_TERMS = 2
11
12export function scoreOption(value: unknown, fallback: number): number {
13  const parsed = typeof value === 'number' ? value : Number(value)
14  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
15}
16
17// Prompts without an auto-memory index after which a session stops asking for the breakdown.
18const MAX_MISSES = 3
19
20// The folder of the session's auto-memory index, from the context breakdown.
21async function memoryFolder($: EngineInterface): Promise<string> {
22  const known = await read($, folder)
23  if (known) return known
24  const usage = await $.session.usage({ breakdown: 'summary' })
25  const index = usage.context.breakdown?.memoryFiles.find(file => file.type === 'AutoMem' && /(^|[\\/])MEMORY\.md$/.test(file.path))?.path
26  // Not cached when absent, so a breakdown taken before the index loaded is tried again on the next prompt.
27  if (!index) return ''
28  const found = index.replace(/[\\/][^\\/]*$/, '')
29  await update($, folder, () => found)
30  return found
31}
32
33type NoteCache = Map<string, { mtimeMs: number; entry: NoteTerms | undefined }>
34
35// The notes in the folder. A linked note's time comes from the file it leads to.
36async function notesIn($: EngineInterface, dir: string, cache: NoteCache): Promise<NoteTerms[]> {
37  const files = (await $.fs.list(dir)).filter(file => file.name.endsWith('.md') && file.name !== 'MEMORY.md')
38  const notes = await Promise.all(files.map(async file => {
39    const path = `${dir}/${file.name}`
40    const mtimeMs = file.isLink ? await $.fs.stat(path).then(stat => stat.kind === 'file' ? stat.mtimeMs : -1, () => -1) : file.kind === 'file' ? file.mtimeMs : -1
41    if (mtimeMs < 0) return undefined
42    let cached = cache.get(path)
43    if (cached === undefined || cached.mtimeMs !== mtimeMs) {
44      // An unreadable note is skipped until it changes, so it does not hide the others.
45      const text = await $.fs.read(path).catch(() => undefined)
46      const note = text === undefined ? undefined : parseNote(path, text)
47      cached = { mtimeMs, entry: note && indexNote(note) }
48      cache.set(path, cached)
49    }
50    return cached.entry
51  }))
52  return notes.filter(note => note !== undefined)
53}
54
55export const register: Register = (on, options) => {
56  const minScore = scoreOption(options.minScore, 8)
57  // Parsed notes by path, kept across prompts and read again when the file's mtime changes.
58  const cache: NoteCache = new Map()
59  let misses = 0
60
61  on('prompt.submit', async ($, e, next) => {
62    // Not 'sdk': a -p run or an eval batch would get hints nobody sees.
63    const isUserRequest = ['composer', 'bridge'].includes(e.origin.kind) && e.command === undefined && e.isMeta === undefined
64    if (!isUserRequest || misses >= MAX_MISSES) return next(e)
65    try {
66      const dir = await memoryFolder($)
67      if (!dir) {
68        misses++
69        return next(e)
70      }
71      // Matched against every note, so a word's rarity does not change as notes are hinted.
72      const notes = await notesIn($, dir, cache)
73      const candidates = match(e.text, notes, { minScore, minTerms: MIN_TERMS, limit: notes.length })
74      if (candidates.length === 0) return next(e)
75      // Picked inside the update, so two prompts in flight never hint the same note.
76      let hits: typeof candidates = []
77      await update($, hinted, previous => {
78        hits = candidates.filter(hit => !previous.includes(hit.note.path)).slice(0, LIMIT)
79        return [...previous, ...hits.map(hit => hit.note.path)]
80      })
81      if (hits.length === 0) return next(e)
82      return next({ ...e, context: [...(e.context ?? []), hintText(hits)] })
83    } catch (error) {
84      // A folder that moved is looked up again on the next prompt.
85      await update($, folder, () => '').catch(() => undefined)
86      $.ui.toast(`memory-hints: ${error instanceof Error ? error.message : String(error)}`)
87      return next(e)
88    }
89  })
90
91  // /clear ends the session but not the process, and no session.start follows it.
92  on('session.end', async ($, e, next) => {
93    misses = 0
94    await update($, hinted, () => []).catch(() => undefined)
95    await update($, folder, () => '').catch(() => undefined)
96    return next(e)
97  })
98}
99
hooks/match.ts 87 lines
1// Pure matching code, kept free of imports so it runs outside the engine too.
2
3export type Note = { path: string; name: string; description: string }
4export type NoteTerms = { note: Note; terms: Set<string> }
5export type Hit = { note: Note; score: number; matched: string[] }
6
7const STOPWORDS = new Set(`
8a about above after again against all also am an and any are as at be because been before being below
9between both but by can could did do does doing done down during each few for from further had has have
10having he her here hers him his how i if in into is it its itself just let me more most my no nor not now
11of off on once only or other our out over own same she should so some such than that the their them then
12there these they this those through to too under until up us very was we were what when where which while
13who whom why will with would you your yours yes ok okay please thanks thank hi hey go going get got make
14made use used using want need like see look check now next new also still one two way thing things
15lets let's done work working sure think know yeah
16`.split(/\s+/).filter(Boolean))
17
18// A crude stem: enough to meet "commits" with "commit", "pasted" with "paste" and "settings" with "setting".
19function stem(word: string): string {
20  let base = word
21  if (base.length > 4 && base.endsWith('es') && !base.endsWith('ses')) base = base.slice(0, -2)
22  else if (base.length > 3 && base.endsWith('s') && !base.endsWith('ss')) base = base.slice(0, -1)
23  if (base.length > 5 && base.endsWith('ing')) base = base.slice(0, -3)
24  else if (base.length > 4 && base.endsWith('ed')) base = base.slice(0, -2)
25  return base.length > 3 && base.endsWith('e') ? base.slice(0, -1) : base
26}
27
28const STOPWORD_STEMS = new Set([...STOPWORDS].map(stem))
29
30export function terms(text: string): Set<string> {
31  const words = text.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length > 2 && !STOPWORDS.has(word) && !/^\d+$/.test(word))
32  return new Set(words.map(stem).filter(word => !STOPWORD_STEMS.has(word)))
33}
34
35// The `name` and `description` of a note's frontmatter, or undefined when it has none.
36export function parseNote(path: string, text: string): Note | undefined {
37  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1]
38  if (block === undefined) return undefined
39  const lines = block.split(/\r?\n/)
40  // A value runs on over the indented lines after its key; a `>` or `|` marker opens such a block.
41  const field = (key: string) => {
42    const at = lines.findIndex(line => line.startsWith(`${key}:`))
43    if (at < 0) return ''
44    const first = lines[at]!.slice(key.length + 1).trim()
45    const parts = /^[>|][+-]?\d*$/.test(first) ? [] : [first]
46    for (let i = at + 1; i < lines.length && /^(\s|$)/.test(lines[i]!); i++) parts.push(lines[i]!.trim())
47    return parts.filter(Boolean).join(' ').replace(/^(["'])(.*)\1$/, '$2')
48  }
49  const name = field('name')
50  const description = field('description')
51  return name || description ? { path, name, description } : undefined
52}
53
54export function indexNote(note: Note): NoteTerms {
55  return { note, terms: terms(`${note.name.replace(/[-_]/g, ' ')} ${note.description}`) }
56}
57
58// Only the words the person typed: pasted content and fenced blocks (logs, exports) name many
59// records and fields and would match notes on vocabulary alone.
60export function ownWords(prompt: string): string {
61  return prompt.replace(/<pasted_content( id="[^"]*")?>[\s\S]*?<\/pasted_content\1>/g, ' ').replace(/```[\s\S]*?```/g, ' ')
62}
63
64// The notes whose name and description share the rarest words with the prompt. A word scores the
65// log of how rare it is among the notes, so a word every note holds counts for nothing.
66export function match(prompt: string, notes: readonly NoteTerms[], options: { minScore: number; minTerms: number; limit: number }): Hit[] {
67  const wanted = terms(ownWords(prompt))
68  if (wanted.size === 0 || notes.length === 0) return []
69  const counts = new Map<string, number>()
70  for (const { terms: noteTerms } of notes) for (const term of noteTerms) if (wanted.has(term)) counts.set(term, (counts.get(term) ?? 0) + 1)
71  const hits: Hit[] = []
72  for (const { note, terms: noteTerms } of notes) {
73    const matched = [...wanted].filter(term => noteTerms.has(term))
74    const score = matched.reduce((sum, term) => sum + Math.log(notes.length / (counts.get(term) ?? 1)), 0)
75    if (matched.length >= options.minTerms && score >= options.minScore) hits.push({ note, score, matched })
76  }
77  return hits.sort((left, right) => right.score - left.score).slice(0, options.limit)
78}
79
80export function hintText(hits: readonly Hit[]): string {
81  return [
82    'memory-hints: these saved memory notes match words in this request. Before acting, Read each note whose description fits the request; skip the ones that do not.',
83    'When a note changes what you do, say so in one line: (memory: <name>).',
84    ...hits.map(({ note }) => `- ${note.path} (${note.name}): ${note.description}`),
85  ].join('\n')
86}
87
types/index.d.ts 11 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'memory-hints': {
4      // The session's auto-memory folder, '' until the context breakdown names it.
5      folder: string
6      // Notes already hinted this session, by path.
7      hinted: string[]
8    }
9  }
10}
11