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…

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.
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.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.
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
No model calls. Each hinted note adds about 50 tokens to the prompt, plus the Read when Claude opens it.
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install memory-hints@lbzepoqo
Restart Claude Code.
claude plugin validate .
claude plugin test .hooks/register.ts 99 lines1import { 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}
99hooks/match.ts 87 lines1// 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}
87types/index.d.ts 11 lines1declare 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