Kyle's personal Claude plugin: a home for one-offs that don't warrant their own plugin. Ships the work-notes-vault skill (his private Obsidian work vault and…

Kyle's personal Claude Code plugin — a home for one-off skills that don't warrant a plugin of their own.
/plugin install avegancafe --marketplace avegancafe/avegancafe-marketplace
| Skill | Purpose |
|---|---|
work-notes-vault | Kyle's private Obsidian work vault, avegancafe/notes--work (expected at ~/workspace/notes--work/): locating/refreshing it, its layout, and its weekly-synced Granola meeting archive (granola/notes/, .md + raw .json) — schema, rg/jq mining recipes, freshness limits, and what not to touch. Lets Claude "mine my Granola meetings for X" from any working directory; the vault's own .claude/CLAUDE.md stays the authority on its conventions. No meeting content lives here — only the archive's structure. |
| Mod | Purpose | ||
|---|---|---|---|
session-title | Keeps the session title in `feat\ | fix\ | chore(Scope): imperative summary form (e.g. feat(ClaudePlugins): auto-name sessions by convention). Haiku titles the first prompt synchronously; after each main-thread turn a background Haiku pass sharpens the title from the conversation so far, and the result lands on your next prompt (titles can only be set as a prompt is submitted). Renaming the session by hand pauses it. /session-title shows the current title; /session-title lock / unlock` toggles it. |
hooks/register.ts 126 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { TitleState } from '../types'
5
6const COMMAND = 'session-title'
7const MAX_LEN = 72
8
9const state = atom({ plugin: 'avegancafe', key: 'sessionTitle' } as const, { locked: false } as TitleState)
10
11export const TITLE_RE = /^(fix|chore|feat)\([A-Z][A-Za-z0-9]*\): [a-z0-9].*\S$/
12
13const RULES = `You name coding sessions. Reply with ONE line and nothing else, in exactly this form:
14
15<type>(<Scope>): <description>
16
17- <type> is one of: feat (new capability), fix (something broken), chore (maintenance, config, docs, refactors, investigations, tooling).
18- <Scope> is ONE PascalCase word naming the high-level area: a product area, system or repo (e.g. Auth, Billing, ClaudePlugins, Beads, CI, DevEnv). No spaces, no dots, no slashes.
19- <description> is an imperative, lowercase phrase that says what the session is doing ("add retry to export uploads", not "added" or "adding"). No trailing period. Keep the whole line under ${MAX_LEN} characters.
20
21If a current title is given, keep it unless the work has clearly shifted or become more specific; when it has, sharpen it rather than rewriting from scratch.`
22
23/** Normalizes a model reply into a valid title, or undefined if it can't. */
24export const clean = (raw: string): string | undefined => {
25 const line = raw
26 .split('\n')
27 .map(l => l.trim().replace(/^[`"'*]+|[`"'*.]+$/g, ''))
28 .find(l => l.length > 0)
29 if (!line) return undefined
30 const m = /^(fix|chore|feat)\s*\(\s*([A-Za-z0-9]+)\s*\)\s*:\s*(.+)$/i.exec(line)
31 const [, type = '', scope = '', desc = ''] = m ?? []
32 if (!type || !scope || !desc) return undefined
33 let title = `${type.toLowerCase()}(${scope.charAt(0).toUpperCase()}${scope.slice(1)}): ${desc.charAt(0).toLowerCase()}${desc.slice(1)}`
34 if (title.length > MAX_LEN) title = title.slice(0, title.lastIndexOf(' ', MAX_LEN))
35 return TITLE_RE.test(title) ? title : undefined
36}
37
38const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
39
40/** A compact digest of the conversation: every user ask plus the latest answer. */
41const digest = async ($: EngineInterface) => {
42 const messages: readonly SessionMessage[] = await $.session.messages()
43 // Skip harness-injected user rows (system reminders, command wrappers).
44 const asks = messages
45 .filter(m => m.role === 'user' && m.text.trim() && !m.text.trimStart().startsWith('<'))
46 .map(m => clip(m.text.trim(), 500))
47 const lastAnswer = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim())
48 const recent = asks.slice(1).slice(-6)
49 return [
50 asks[0] && `First request:\n${asks[0]}`,
51 recent.length > 0 && `Later requests:\n${recent.map(a => `- ${a}`).join('\n')}`,
52 lastAnswer && `Latest assistant reply (excerpt):\n${clip(lastAnswer.text.trim(), 800)}`,
53 ]
54 .filter(Boolean)
55 .join('\n\n')
56}
57
58const generate = async ($: EngineInterface, context: string, current?: string) => {
59 const prompt = `${current ? `Current title: ${current}\n\n` : ''}${context}\n\nTitle:`
60 const r = await $.model.complete({ model: 'haiku', system: RULES, prompt, maxTokens: 60, effort: 'low' })
61 return r.isAnswered ? clean(r.text) : undefined
62}
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: COMMAND,
68 description: 'Show the auto session title, or `lock` / `unlock` it',
69 })
70 return next(e)
71 })
72
73 on('command.run', { command: COMMAND }, async ($, e) => {
74 const arg = e.args.trim().toLowerCase()
75 if (arg === 'lock' || arg === 'unlock') {
76 await update($, state, s => ({ ...s, locked: arg === 'lock' }))
77 return { text: arg === 'lock' ? 'Session title locked.' : 'Session title unlocked; it updates on your next prompt.' }
78 }
79 const s = await read($, state)
80 return { text: `${s.title ?? '(no title yet)'}${s.locked ? ' (locked)' : ''}` }
81 })
82
83 // The title can only be set as a prompt enters, so this applies whatever the
84 // last refinement produced; the very first prompt is titled synchronously.
85 on('classic.UserPromptSubmit', async ($, e, next) => {
86 const result = await next(e)
87 if (result.block) return result
88
89 const s = await read($, state)
90 if (s.locked) return result
91
92 const current = e.session_title
93 if (s.title && current && current !== s.title) {
94 // Renamed by hand since we last set it: leave it alone.
95 await update($, state, x => ({ ...x, locked: true }))
96 $.ui.toast(`Session renamed by hand; auto-title paused (/${COMMAND} unlock to resume)`)
97 return result
98 }
99
100 let title = s.title
101 if (!title && current && TITLE_RE.test(current)) title = current // resumed session
102 if (!title) {
103 const history = (await $.session.turns()) > 1 ? await digest($) : ''
104 title = await generate($, `${history ? `${history}\n\n` : ''}New request:\n${clip(e.prompt, 1500)}`)
105 }
106 if (!title) return result
107
108 if (title !== s.title) await update($, state, x => ({ ...x, title }))
109 return title === current ? result : { ...result, sessionTitle: title }
110 })
111
112 // After each main-thread turn, refine the title in the background so the next
113 // prompt picks it up without waiting on a model call.
114 on('turn.complete', async ($, e, next) => {
115 const result = await next(e)
116 if (e.agentId !== undefined || e.isAborted) return result
117 const s = await read($, state)
118 if (s.locked) return result
119 void (async () => {
120 const title = await generate($, await digest($), s.title)
121 if (title && !(await read($, state)).locked) await update($, state, x => ({ ...x, title }))
122 })().catch(() => {})
123 return result
124 })
125}
126types/index.d.ts 12 lines1/**
2 * `title` is the last title this mod produced; `locked` stops it touching the
3 * title (set when someone renamed the session by hand, or via the command).
4 */
5export type TitleState = { title?: string; locked: boolean }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'avegancafe': { sessionTitle: TitleState }
10 }
11}
12