用 Haiku 按「前缀|MMDD|主体」规则自动维护会话标题,不占主模型上下文

English | 简体中文
After each turn, Haiku checks the session title against the rules in title-rules.md. Titles take the form 前缀|MMDD|主体 (prefix|month-day|subject), for example 探索|1013|ClaudeCode mods调研. The rules are written for Chinese titles; edit them to suit your own style.
<img src="docs/sidebar.en.svg" alt="Session titles in the sidebar" width="400">
<img src="docs/drift.en.svg" alt="How the title follows the session" width="720">
The screenshots are drawn from sample data by docs/render_screenshots.py.
$.store (~/.claude/plugins/store/); records not updated for 30 days are removed./plugin install session-title --marketplace tyler4400/tyler-claude-mods
Or:
claude plugin marketplace add tyler4400/tyler-claude-mods
claude plugin install session-title@tyler-claude-mods --scope user
The rules are in title-rules.md; change the prefix list to match your own habits.
hooks/register.ts 183 lines1import type { EngineInterface, Register } from 'claude-code'
2import { buildDigest, resolveTitle, subjectOf } from './title-logic'
3
4// 桌面 app 注入的会话管理 MCP server;终端里没有它,改走 UserPromptSubmit 的 sessionTitle
5const DESKTOP_SERVER = 'ccd_session_mgmt'
6const STORE_PREFIX = 'session:'
7const RECORD_TTL_MS = 30 * 24 * 60 * 60 * 1000
8
9type TitleRecord = {
10 /** 终端里拿不到当前标题,用最近一次写入的值代替 */
11 title?: string
12 /** 用户拒绝过改主体:之后只补前缀和日期 */
13 subjectLocked: boolean
14 /** 连只补前缀和日期也被拒绝:本会话不再尝试 */
15 gaveUp: boolean
16 /** 终端路径:等下一次提交 prompt 时通过 hook 写入 */
17 pendingHookTitle?: string
18 /** 最近一次检查的时间和结果,排查用(store 是 ~/.claude/plugins/store/ 下的 JSON) */
19 lastRun?: string
20 updatedAt: number
21}
22
23type Engine = EngineInterface
24
25let rules: string | undefined
26let isRetitling = false
27
28async function recordKey($: Engine): Promise<string> {
29 return STORE_PREFIX + (await $.session.id())
30}
31
32async function loadRecord($: Engine): Promise<TitleRecord | undefined> {
33 return (await $.store.get(await recordKey($))) as TitleRecord | undefined
34}
35
36async function saveRecord($: Engine, record: TitleRecord): Promise<void> {
37 await $.store.set(await recordKey($), { ...record, updatedAt: await $.clock.now() })
38}
39
40async function todayMMDD($: Engine): Promise<string> {
41 // mod 运行环境的时区不一定是本机时区,以本机 date 为准
42 const { stdout } = await $.process.run(['date', '+%m%d'])
43 return stdout.trim()
44}
45
46function textOf(result: { content: readonly { type: string }[] }): string {
47 return result.content.map((block) => ('text' in block ? String(block.text) : '')).join('\n')
48}
49
50/** undefined 表示不在桌面 app 里;null 表示桌面会话还没有标题 */
51async function readDesktopTitle($: Engine): Promise<string | null | undefined> {
52 try {
53 const result = await $.mcp.call(DESKTOP_SERVER, 'get_session', { session_id: 'self' })
54 if (result.isError) return undefined
55 const { title } = JSON.parse(textOf(result)) as { title?: unknown }
56 return typeof title === 'string' && title.trim() !== '' ? title : null
57 } catch {
58 return undefined
59 }
60}
61
62/** 用户手动起的标题会触发 app 的批准弹窗;读回标题判断是否被拒绝 */
63async function applyDesktopTitle($: Engine, title: string): Promise<boolean> {
64 try {
65 await $.mcp.call(DESKTOP_SERVER, 'set_session_title', { session_id: 'self', title })
66 } catch {
67 return false
68 }
69 return (await readDesktopTitle($)) === title
70}
71
72async function askModel(
73 $: Engine,
74 ctx: { today: string; current?: string; lockedSubject?: string },
75): Promise<{ text?: string; failure?: string }> {
76 rules ??= String(await $.fs.read($.plugin.root + '/title-rules.md'))
77 const digest = buildDigest(await $.session.messages())
78 const prompt = [
79 `今天:${ctx.today}`,
80 `当前标题:${ctx.current ?? '(无)'}`,
81 ctx.lockedSubject ? `主体已锁定:${ctx.lockedSubject}` : '',
82 '',
83 '对话(用户消息按顺序,助手只列最近几条):',
84 digest,
85 ].join('\n')
86 const reply = await $.model.complete({
87 model: 'haiku',
88 system: [{ text: rules, cache: true }],
89 prompt,
90 maxTokens: 160,
91 effort: 'low',
92 timeoutMs: 30_000,
93 })
94 return reply.isAnswered ? { text: reply.text } : { failure: reply.reason }
95}
96
97/**
98 * 每轮都让 Haiku 判断一次;标题仍贴合主线时它回 KEEP,什么都不动。
99 * 返回本次结果的简述,写进记录的 lastRun。
100 */
101async function retitle($: Engine, record: TitleRecord): Promise<string> {
102 if (record.gaveUp) return 'skip: gave up'
103
104 const [today, desktopTitle] = await Promise.all([todayMMDD($), readDesktopTitle($)])
105 const isDesktop = desktopTitle !== undefined
106 // claude -p、SDK 等既不在桌面 app、也没有界面的会话不需要标题
107 if (!isDesktop && (await $.session.surfaces()).length === 0) return 'skip: headless'
108
109 const current = (isDesktop ? desktopTitle : record.title) ?? undefined
110 const lockedSubject = record.subjectLocked && current ? subjectOf(current) : undefined
111
112 const reply = await askModel($, { today, current, lockedSubject })
113 if (reply.text === undefined) return 'model failed: ' + reply.failure
114 const nextTitle = resolveTitle(reply.text, { today, current, lockedSubject })
115 if (!nextTitle) return 'keep'
116
117 if (!isDesktop) {
118 record.title = nextTitle
119 record.pendingHookTitle = nextTitle
120 return 'pending (terminal): ' + nextTitle
121 }
122 if (await applyDesktopTitle($, nextTitle)) {
123 record.title = nextTitle
124 return 'set: ' + nextTitle
125 }
126 if (current && subjectOf(nextTitle) !== subjectOf(current)) {
127 record.subjectLocked = true
128 $.ui.toast('session-title:已保留你的标题主体,之后只补前缀和日期')
129 return 'rejected, subject locked: ' + nextTitle
130 }
131 record.gaveUp = true
132 return 'rejected, gave up: ' + nextTitle
133}
134
135async function retitleOnce($: Engine): Promise<void> {
136 if (isRetitling) return
137 isRetitling = true
138 const record: TitleRecord = { subjectLocked: false, gaveUp: false, ...(await loadRecord($)), updatedAt: 0 }
139 let outcome: string
140 try {
141 outcome = await retitle($, record)
142 } catch (err) {
143 outcome = 'error: ' + String(err)
144 } finally {
145 isRetitling = false
146 }
147 record.lastRun = new Date(await $.clock.now()).toISOString() + ' ' + outcome
148 await saveRecord($, record)
149}
150
151async function pruneOldRecords($: Engine): Promise<void> {
152 const now = await $.clock.now()
153 for (const key of await $.store.keys()) {
154 const record = (await $.store.get(key)) as TitleRecord | undefined
155 if (!record || now - record.updatedAt > RECORD_TTL_MS) await $.store.delete(key)
156 }
157}
158
159// 改标题是附加功能:自己出错时让原事件照常进行
160const passThrough = ($: Engine, e: any, next: any) => next(e)
161
162export const register: Register = (on) => {
163 on('session.start', async ($, e, next) => {
164 await pruneOldRecords($)
165 return next(e)
166 })
167
168 on('turn.complete', async ($, e, next) => {
169 const result = await next(e)
170 // 只看主对话正常答完的轮次;放到定时器里跑,不拖住这一轮的收尾
171 if (!e.agentId && e.reason === 'answer') $.clock.after(0, () => void retitleOnce($))
172 return result
173 })
174
175 on('classic.UserPromptSubmit', async ($, e, next) => {
176 const result = await next(e)
177 const stored = await loadRecord($)
178 if (!stored?.pendingHookTitle) return result
179 await saveRecord($, { ...stored, pendingHookTitle: undefined })
180 return { ...result, sessionTitle: stored.pendingHookTitle }
181 }).catch(passThrough)
182}
183hooks/title-logic.ts 95 lines1import type { SessionMessage } from 'claude-code'
2
3export const BANNED_PREFIXES = ['Rule', 'Test', '读票', '面试', '其它', '杂项', '综合']
4
5const TITLE_PATTERN = /^(\S+)|(\d{4})|(.+)$/
6const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
7// 桌面 app fork 会话时给标题追加的后缀
8const FORK_SUFFIX = /\s*[((]fork[))]\s*$/i
9
10export type ParsedTitle = { prefix: string; date: string; subject: string }
11
12export function parseTitle(title: string | undefined): ParsedTitle | undefined {
13 const [, prefix, date, subject] = title?.trim().match(TITLE_PATTERN) ?? []
14 if (!prefix || !date || !subject) return undefined
15 return { prefix, date, subject: subject.trim() }
16}
17
18export function stripForkSuffix(text: string): string {
19 return text.replace(FORK_SUFFIX, '')
20}
21
22/** 已格式化的标题取主体;其他标题(系统生成或手动起的)整体当主体。不含 fork 后缀 */
23export function subjectOf(title: string): string {
24 return stripForkSuffix(parseTitle(title)?.subject ?? title.trim())
25}
26
27export type ResolveContext = {
28 today: string
29 current: string | undefined
30 /** 用户拒绝过改主体时,主体必须保持这个值 */
31 lockedSubject?: string
32}
33
34/**
35 * 把模型回复变成要写入的标题;返回 undefined 表示不改。
36 * 模型偶尔会写半角分隔符、旧日期或改动锁定主体,这里统一兜底。
37 */
38export function resolveTitle(reply: string, ctx: ResolveContext): string | undefined {
39 // 模型先写一行「主线:…」再给结论,取最后一行;只回一行时也兼容
40 const lines = reply.trim().split('\n').map((l) => l.trim()).filter((l) => l !== '' && !/^主线[::]/.test(l))
41 const line = (lines.at(-1) ?? '').replace(/^[`"'「]+|[`"'」]+$/g, '')
42 const current = parseTitle(ctx.current)
43
44 if (line === 'KEEP' || line === '') {
45 // 模型说保持,但日期过期或带着 fork 后缀时,只换日期、去后缀
46 if (!current) return undefined
47 const title = `${current.prefix}|${ctx.today}|${stripForkSuffix(current.subject)}`
48 return title === ctx.current?.trim() ? undefined : title
49 }
50
51 const parsed = parseTitle(line.replace(/\s*[||]\s*/g, '|'))
52 if (!parsed || [...parsed.prefix].length !== 2 || BANNED_PREFIXES.includes(parsed.prefix)) return undefined
53
54 const subject = stripForkSuffix(ctx.lockedSubject ?? parsed.subject)
55 const title = `${parsed.prefix}|${ctx.today}|${subject}`
56 return title === ctx.current?.trim() ? undefined : title
57}
58
59const USER_MESSAGE_CHARS = 400
60// 最近几条用户消息多给一些篇幅:主线是否偏离主要看它们
61const RECENT_USER_MESSAGES = 6
62const RECENT_USER_MESSAGE_CHARS = 800
63const ASSISTANT_MESSAGE_CHARS = 300
64const RECENT_ASSISTANT_MESSAGES = 3
65const DIGEST_CHARS = 8000
66
67function clip(text: string, limit: number): string {
68 const flat = text.replace(SYSTEM_REMINDER, '').replace(/\s+/g, ' ').trim()
69 return flat.length > limit ? flat.slice(0, limit) + '…' : flat
70}
71
72/** 主线要看全部用户消息的走向,所以用户消息全收(各自截断,近期的多留一些),助手只留最近几条 */
73export function buildDigest(messages: readonly SessionMessage[]): string {
74 const userTexts = messages.filter((m) => m.role === 'user' && m.text.trim() !== '').map((m) => m.text)
75 const userLines = userTexts
76 .map((text, i) => {
77 const isRecent = i >= userTexts.length - RECENT_USER_MESSAGES
78 return { text: clip(text, isRecent ? RECENT_USER_MESSAGE_CHARS : USER_MESSAGE_CHARS), label: `用户 ${i + 1}${isRecent ? '(近期)' : ''}` }
79 })
80 .filter(({ text }) => text !== '')
81 .map(({ text, label }) => `${label}:${text}`)
82
83 const assistantLines = messages
84 .filter((m) => m.role === 'assistant' && m.text.trim() !== '')
85 .slice(-RECENT_ASSISTANT_MESSAGES)
86 .map((m) => `助手(近期):${clip(m.text, ASSISTANT_MESSAGE_CHARS)}`)
87
88 // 超长时优先保留开头(定主线)和结尾(看偏移),丢中间
89 let lines = userLines
90 while (lines.join('\n').length > DIGEST_CHARS && lines.length > 3 + RECENT_USER_MESSAGES) {
91 lines = [...lines.slice(0, 3), ...lines.slice(4)]
92 }
93 return [...lines, ...assistantLines].join('\n')
94}
95