Matches each prompt against a folder of lesson notes (file slug + title) and attaches the top related files as context Claude reads; /lessons shows the index

/plugin install lessons-recall --marketplace steven0224-glitch/chan-mods
Older CLIs: claude plugin marketplace add steven0224-glitch/chan-mods then claude plugin install lessons-recall@chan-mods.
Keeps a folder of one-lesson-per-file notes in play: when a prompt shares rare words with a lesson's file name or title, the lesson path is attached to the prompt as context Claude reads before working.
log n.task-notification, peer or plugin origins.교훈 N건 첨부: … shows what was attached. /lessons prints the index size, the folder, the last read error if any, and the last attached lessons with the matched words.The attached context is a short list, not the file body:
[lessons-recall] 이 프롬프트와 단어가 겹치는 교훈 파일 — 관련 있으면 작업 전에 읽을 것(무관하면 무시):
- ~/.claude/lessons/2026-08-06-graphify-writes-output-next-to-root-not-cwd.md — graphify 재빌드 출력은 root 옆에
| option | default | meaning |
|---|---|---|
dir | ~/.claude/lessons | folder of lesson notes; ~ is the home directory |
"pluginConfigs": { "lessons-recall@chan-mods": { "options": { "dir": "~/notes/lessons" } } }
hooks/register.ts 77 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { buildIndex, contextOf, lessonOf, recall } from './match.ts'
4import type { Hit, Index } from './match.ts'
5
6// Prompts that are not the person's own words: notifications, other sessions, plugins
7const SKIP_ORIGINS = ['task-notification', 'peer', 'plugin']
8
9const DEFAULT_DIR = '~/.claude/lessons'
10
11let index: Index = { lessons: [], idf: new Map() }
12let lastHits: Hit[] = []
13let lastError = ''
14
15// `~` means the home directory, as in the shell
16async function resolveDir($: EngineInterface, dir: string): Promise<string | null> {
17 if (!dir.startsWith('~')) return dir
18 const home = await $.env.get('HOME')
19 return home ? home + dir.slice(1) : null
20}
21
22// ~/.claude/lessons is a symlink into OneDrive; a dataless file blocks its read, so this only ever runs off a timer
23async function loadIndex($: EngineInterface, configured: string) {
24 const dir = await resolveDir($, configured)
25 if (!dir) return
26 try {
27 if (!(await $.fs.exists(dir))) return
28 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
29 const lessons = await Promise.all(
30 files.map(async f => {
31 try {
32 return lessonOf(f.name, await $.fs.read(`${dir}/${f.name}`))
33 } catch {
34 return null
35 }
36 }),
37 )
38 index = buildIndex(lessons.filter(l => l !== null))
39 lastError = ''
40 } catch (err) {
41 lastError = String(err)
42 $.ui.log('lessons 색인 실패: ' + lastError, { to: 'debug' })
43 }
44}
45
46export const register: Register = (on, options) => {
47 const dir = typeof options.dir === 'string' && options.dir.trim() ? options.dir.trim() : DEFAULT_DIR
48
49 on('session.start', async ($, e, next) => {
50 $.clock.after(0, () => loadIndex($, dir))
51 // New lessons land during the day; refresh every half hour
52 $.clock.every(1_800_000, () => loadIndex($, dir))
53 try {
54 await $.command.register({ name: 'lessons', description: 'lessons-recall 색인 크기와 마지막으로 첨부한 교훈 보기', immediate: true })
55 } catch (err) {
56 $.ui.log('/lessons 등록 실패: ' + String(err))
57 }
58 return next(e)
59 })
60
61 on('command.run', { command: 'lessons' }, async () => {
62 const head = `색인 ${index.lessons.length}건 (${dir})${lastError ? ' · 마지막 읽기 실패: ' + lastError : ''}`
63 if (lastHits.length === 0) return { text: head + '\n이 세션에서 첨부한 교훈 없음' }
64 const rows = lastHits.map(h => `- ${h.file} — ${h.title} (${h.matched.join(', ')})`)
65 return { text: [head, '마지막 첨부:', ...rows].join('\n') }
66 })
67
68 on('prompt.submit', async ($, e, next) => {
69 if (e.command || e.isMeta || SKIP_ORIGINS.includes(e.origin.kind) || e.text.trim().length < 6) return next(e)
70 const hits = recall(index, e.text)
71 if (hits.length === 0) return next(e)
72 lastHits = hits
73 $.ui.log(`교훈 ${hits.length}건 첨부: ` + hits.map(h => h.file.replace(/^\d{4}-\d{2}-\d{2}-/, '').slice(0, 40)).join(' · '))
74 return next({ ...e, context: [...(e.context ?? []), contextOf(hits)] })
75 })
76}
77hooks/match.ts 69 lines1export type Lesson = { file: string; title: string; keys: string[] }
2export type Index = { lessons: Lesson[]; idf: Map<string, number> }
3export type Hit = { file: string; title: string; score: number; matched: string[] }
4
5const STOP = new Set([
6 'the', 'and', 'for', 'with', 'not', 'use', 'from', 'into', 'when', 'than', 'that', 'this', 'are', 'was', 'its', 'via', 'out', 'all', 'new', 'one', 'two',
7 // Nearly every lesson in this harness is about Claude: no signal
8 'claude', 'code',
9 '그리고', '하는', '있는', '없는', '위해', '경우', '대신', '먼저', '이후', '기록', '적용', '때문', '사용', '확인', '문제', '방법', '해결', '진행', '작업', '이번', '다음',
10])
11const JOSA2 = ['에서', '으로', '부터', '까지', '처럼', '보다', '에게', '이랑', '하고', '해서', '하면', '했다', '한다']
12const JOSA1 = ['은', '는', '이', '가', '을', '를', '의', '에', '도', '만', '로', '와', '과', '랑']
13const HANGUL = /[가-힣]/
14
15// Lowercase, split on anything that is not a letter or digit, strip Korean particles, drop short and stop words
16export function tokens(text: string): string[] {
17 const out: string[] = []
18 for (let w of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
19 if (!w) continue
20 if (HANGUL.test(w)) {
21 const two = JOSA2.find(j => w.length >= 4 && w.endsWith(j))
22 if (two) w = w.slice(0, -two.length)
23 else if (w.length >= 3 && JOSA1.some(j => w.endsWith(j))) w = w.slice(0, -1)
24 if (w.length < 2) continue
25 } else if (w.length < 3 || /^\d+$/.test(w)) continue
26 if (!STOP.has(w)) out.push(w)
27 }
28 return out
29}
30
31// One lesson: the slug without its date, and the first non-blank line as the title
32export function lessonOf(file: string, text: string): Lesson {
33 const first = text.split('\n').find(l => l.trim()) ?? ''
34 const title = first.replace(/^#+\s*/, '').trim().slice(0, 120)
35 const slug = file.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
36 return { file, title, keys: [...new Set([...tokens(slug), ...tokens(title)])] }
37}
38
39export function buildIndex(lessons: Lesson[]): Index {
40 const df = new Map<string, number>()
41 for (const l of lessons) for (const k of l.keys) df.set(k, (df.get(k) ?? 0) + 1)
42 const n = Math.max(2, lessons.length)
43 // Rarity scaled to 0..1 by log n, so the thresholds below hold for 5 lessons as for 500
44 const idf = new Map([...df].map(([k, d]) => [k, Math.log(n / d) / Math.log(n)] as [string, number]))
45 return { lessons, idf }
46}
47
48// shortcut: plain token overlap weighted by rarity; a model-graded match would catch paraphrases, at a cost per prompt
49export function recall(index: Index, prompt: string, limit = 3): Hit[] {
50 const words = new Set(tokens(prompt))
51 const flat = prompt.toLowerCase()
52 const hits: Hit[] = []
53 for (const l of index.lessons) {
54 const matched = l.keys.filter(k => words.has(k) || (HANGUL.test(k) && k.length >= 3 && flat.includes(k)))
55 const score = matched.reduce((s, k) => s + (index.idf.get(k) ?? 0), 0)
56 // Two distinct shared words, or one rare long one, so a single common word never triggers
57 const rareSolo = matched.length === 1 && matched[0].length >= 6 && (index.idf.get(matched[0]) ?? 0) >= 0.9
58 if ((matched.length >= 2 && score >= 1.0) || rareSolo) hits.push({ file: l.file, title: l.title, score, matched })
59 }
60 return hits.sort((a, b) => b.score - a.score).slice(0, limit)
61}
62
63export function contextOf(hits: Hit[]): string {
64 return [
65 '[lessons-recall] 이 프롬프트와 단어가 겹치는 교훈 파일 — 관련 있으면 작업 전에 읽을 것(무관하면 무시):',
66 ...hits.map(h => `- ~/.claude/lessons/${h.file} — ${h.title}`),
67 ].join('\n')
68}
69