SLOPSHOPPER

skill-diet

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…

guardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-diet
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ skill-diet │ ⏺ Read(src/auth.ts) │ Skill diet: every skill is listed in full │ ⎿ Read 6 lines │ this session. │ ⏺ 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 › /skill-diet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 1 files
hooks/register.ts 209 lines
1import 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