SLOPSHOPPER

session-title

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

newtoastmodelprocesstimer
A shopper browsing a rack in a slop shop
README

English | 简体中文

session-title

Mods Code

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.

  • Checked every turn: the model first writes one line on what the session is working on now, then decides whether to keep or change the title. If the opening topic has been replaced by work that has gone on for several turns (say, from surveying mods to rewriting and publishing one), the subject is rewritten. A question, error or detour that lasts a turn or two does not count.
  • Stable by default: the title is kept when it is well formed, dated today and still names what the session is working on.
  • If you reject a subject change once, the subject is locked and only the prefix and date are adjusted from then on. If you reject that too, it stops trying for that session.
  • In the desktop app it sets the title with the app's own session tools; in a terminal it writes the title through a hook the next time you submit a prompt.
  • It calls Haiku, so it adds nothing to the main model's context, but the calls count toward your usage.
  • Records live in $.store (~/.claude/plugins/store/); records not updated for 30 days are removed.

Install

/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.

Source 2 files
hooks/register.ts 183 lines
1import 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}
183
hooks/title-logic.ts 95 lines
1import 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