Lists skills you have not used lately in this project by name only, so their descriptions stop riding along on every request; the Skill tool and /name still…

hooks/register.ts 209 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Every request carries the skill listing: each skill's name and its whole
4// description, often several hundred characters each. This keeps the skills
5// you used lately in this project listed in full and lists the rest by name
6// only; the Skill tool still loads any of them, and typing /name still works.
7// The choice is made once per session (a listing that changed mid-session
8// would spend the prompt cache) and applies from the next session after a change.
9
10// A skill used, or first seen, in any of the last RECENT sessions of this project stays listed.
11export const RECENT = 5
12
13type Project = {
14 /** How many sessions the mod has seen start in this project. */
15 sessions: number
16 /** The session number each skill was last used in. */
17 lastUsed: Record<string, number>
18 /** The session number each skill was first listed in; 0 for skills there before the mod. */
19 firstSeen: Record<string, number>
20}
21
22type Memory = {
23 projects: Record<string, Project>
24 /** Skills the person asked to keep listed in full, in every project. */
25 keep: string[]
26 isOff: boolean
27}
28
29const EMPTY: Memory = { projects: {}, keep: [], isOff: false }
30const EMPTY_PROJECT: Project = { sessions: 0, lastUsed: {}, firstSeen: {} }
31
32async function recall($: EngineInterface): Promise<Memory> {
33 const saved = (await $.store.get('memory')) as Partial<Memory> | undefined
34 return { ...EMPTY, ...saved }
35}
36
37async function remember($: EngineInterface, memory: Memory) {
38 await $.store.set('memory', memory)
39}
40
41// `anthropic-skills:docs` and `docs` name the same skill; so do `apps/web:deploy` and `deploy`.
42const bare = (skill: string) => skill.slice(skill.lastIndexOf(':') + 1)
43
44const lastUse = (skill: string, project: Project): number | undefined => {
45 const uses = [project.lastUsed[skill], project.lastUsed[bare(skill)]].filter((n): n is number => n !== undefined)
46 return uses.length ? Math.max(...uses) : undefined
47}
48
49export const shouldHide = (skill: string, project: Project, memory: Pick<Memory, 'keep' | 'isOff'>): boolean => {
50 if (memory.isOff || memory.keep.includes(skill) || memory.keep.includes(bare(skill))) return false
51 const recent = Math.max(lastUse(skill, project) ?? 0, project.firstSeen[skill] ?? 0)
52 return recent === 0 || recent <= project.sessions - RECENT
53}
54
55export type Entry = { name: string; text: string }
56
57// The listing as the engine writes it: a header, then one `- name: description`
58// entry per skill, a description sometimes running over several lines.
59export const parseListing = (text: string): { head: string[]; entries: Entry[]; tail: string[] } => {
60 const head: string[] = []
61 const entries: Entry[] = []
62 for (const line of text.split('\n')) {
63 const match = /^- (\S+?): /.exec(line)
64 if (match) entries.push({ name: match[1]!, text: line })
65 else if (entries.length) entries[entries.length - 1]!.text += `\n${line}`
66 else head.push(line)
67 }
68 // Trailing blank lines belong to the listing, not the last description.
69 const tail: string[] = []
70 const last = entries.at(-1)
71 if (last) {
72 const lines = last.text.split('\n')
73 while (lines.length > 1 && lines.at(-1)!.trim() === '') tail.unshift(lines.pop()!)
74 last.text = lines.join('\n')
75 }
76 return { head, entries, tail }
77}
78
79export const NAMES_ONLY = 'These skills are listed by name only; when one fits the task, load it with the Skill tool as usual:'
80
81export const rewriteListing = (text: string, hide: (skill: string) => boolean): { text: string; hidden: string[] } => {
82 const { head, entries, tail } = parseListing(text)
83 const hidden = entries.filter(e => hide(e.name)).map(e => e.name)
84 if (!hidden.length) return { text, hidden }
85 const kept = entries.filter(e => !hide(e.name)).map(e => e.text)
86 const names = `${NAMES_ONLY} ${hidden.join(', ')}`
87 return { text: [...head, ...kept, '', names, ...tail].join('\n'), hidden }
88}
89
90async function recordUse($: EngineInterface, cwd: string, skill: string) {
91 if (!cwd) return
92 const memory = await recall($)
93 const saved = { ...EMPTY_PROJECT, ...memory.projects[cwd] }
94 if (saved.lastUsed[skill] === saved.sessions) return
95 saved.lastUsed[skill] = saved.sessions
96 memory.projects[cwd] = saved
97 await remember($, memory)
98}
99
100const plural =(n: number, word: string) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
101
102export const register: Register = on => {
103 // This session's project, its frozen view of usage, and what the listing lost.
104 let cwd = ''
105 let project: Project = EMPTY_PROJECT
106 let settings: Pick<Memory, 'keep' | 'isOff'> = EMPTY
107 const hidden = new Set<string>()
108 // Characters removed from each distinct listing text, so a listing rendered again is not counted twice.
109 const removed = new Map<string, number>()
110
111 const removedChars = () => [...removed.values()].reduce((a, b) => a + b, 0)
112
113 on('session.start', async ($, e, next) => {
114 const memory = await recall($)
115 cwd = e.cwd
116 const saved = memory.projects[cwd]
117 const current: Project = { ...EMPTY_PROJECT, ...saved, sessions: (saved?.sessions ?? 0) + 1 }
118 memory.projects[cwd] = current
119 await remember($, memory)
120 // Frozen for the session: usage from now on shows from the next one.
121 project = structuredClone(current)
122 settings = { keep: memory.keep, isOff: memory.isOff }
123 await $.command.register({
124 name: 'skill-diet',
125 description: 'Skill diet: /skill-diet · keep <skill> · unkeep <skill> · on · off',
126 })
127 return next(e)
128 })
129
130 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
131 const result = await next(e)
132 if (result.text === null || !cwd) return result
133
134 // Skills listed for the first time in this project get the same grace as a used one.
135 const names = parseListing(result.text).entries.map(entry => entry.name)
136 const unseen = names.filter(name => project.firstSeen[name] === undefined)
137 if (unseen.length) {
138 const memory = await recall($)
139 const saved = { ...EMPTY_PROJECT, ...memory.projects[cwd] }
140 // The first listing the mod sees in this project holds skills that were there before it: they count as old.
141 const seen = Object.keys(saved.firstSeen).length === 0 ? 0 : project.sessions
142 for (const name of unseen) {
143 project.firstSeen[name] = seen
144 saved.firstSeen[name] ??= seen
145 }
146 memory.projects[cwd] = saved
147 await remember($, memory)
148 }
149
150 const rewritten = rewriteListing(result.text, name => shouldHide(name, project, settings))
151 for (const name of rewritten.hidden) hidden.add(name)
152 removed.set(result.text, result.text.length - rewritten.text.length)
153 $.ui.status(hidden.size ? `${plural(hidden.size, 'skill')} by name only, ${plural(removedChars(), 'character')} off` : undefined)
154 return { text: rewritten.text }
155 })
156
157 // Called through the Skill tool, typed as /name, or preloaded into a subagent: all count as a use.
158 // Claude Code's own security plugin can keep skill.prompt from user plugins, so the first two are read where they start.
159 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
160 if (e.tool === 'Skill' && typeof e.skill === 'string') await recordUse($, cwd, e.skill)
161 return next(e)
162 })
163
164 on('prompt.submit', async ($, e, next) => {
165 const typed = /^\/(\S+)/.exec(e.text.trim())?.[1]
166 if (typed && (project.firstSeen[typed] !== undefined || Object.keys(project.firstSeen).some(name => bare(name) === typed))) {
167 await recordUse($, cwd, typed)
168 }
169 return next(e)
170 })
171
172 on('skill.prompt', async ($, e, next) => {
173 await recordUse($, cwd, e.skill)
174 return next(e)
175 })
176
177 // Answered with a toast and no text, so the command adds nothing to the conversation.
178 on('command.run', { command: 'skill-diet' }, async ($, e) => {
179 const [verb, skill] = e.args.trim().split(/\s+/)
180 const memory = await recall($)
181
182 if ((verb === 'keep' || verb === 'unkeep') && skill) {
183 memory.keep = verb === 'keep' ? [...new Set([...memory.keep, skill])] : memory.keep.filter(s => s !== skill)
184 await remember($, memory)
185 $.ui.toast(`Skill diet: ${skill} ${verb === 'keep' ? 'stays listed in full' : 'may be listed by name only'} from the next session`)
186 return {}
187 }
188 if (verb === 'on' || verb === 'off') {
189 memory.isOff = verb === 'off'
190 await remember($, memory)
191 $.ui.toast(`Skill diet ${verb} from the next session`)
192 return {}
193 }
194
195 const chars = removedChars()
196 const names = [...hidden].sort()
197 const shown = names.slice(0, 6).join(', ') + (names.length > 6 ? ` +${names.length - 6} more` : '')
198 $.ui.toast(
199 memory.isOff
200 ? 'Skill diet is off. /skill-diet on to turn it back on.'
201 : hidden.size === 0
202 ? 'Skill diet: every skill is listed in full this session.'
203 : `${plural(hidden.size, 'skill')} by name only, ${plural(chars, 'character')} off the skill listing. ${shown}`,
204 { timeoutMs: 15_000 },
205 )
206 return {}
207 })
208}
209