Shows a toast when a prompt starts a different topic than the earlier requests in the session, so you can /clear first

A long session that drifts from one topic to the next carries the whole earlier conversation into every request: more tokens, and older context that can mislead the model. Starting unrelated work with /clear avoids both, but it is easy to forget once you are typing the next question.
This plugin notices when a prompt starts a different topic and reminds you with a toast:
New topic? /clear starts a fresh session, or say how it relates to the current work.
The plugin keeps your last 5 requests in the session, each cut to 300 characters. When you send a new one, it asks Haiku whether the new request continues the earlier work or starts a different topic. The call runs beside the turn, so it never delays the prompt, and the toast only informs: the prompt goes to Claude either way.
After a new topic, the plugin compares later prompts with the new one only, so you get one toast per switch. /clear and a new session start an empty history.
The first prompt of a session, slash commands, short replies such as "yes, go on", and prompts that a task, a scheduled trigger or an SDK host sent are not judged.
One Haiku call per prompt after the first, at most about 500 tokens.
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install topic-nudge@lbzepoqo
Restart Claude Code.
claude plugin validate .
claude plugin test .hooks/register.ts 81 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptSubmitInput, Register } from 'claude-code'
3
4import type { PromptHistory } from '../types'
5
6const EMPTY: PromptHistory = { sessionId: null, prompts: [] }
7const history = atom({ plugin: 'topic-nudge', key: 'history' } as const, EMPTY)
8
9// Shorter prompts ("yes", "go on") answer the model rather than ask for something new.
10const MIN_PROMPT_LENGTH = 15
11// The classifier sees this many earlier requests, each cut to PROMPT_ROOM characters.
12const KEEP_PROMPTS = 5
13const PROMPT_ROOM = 300
14
15export const SAME_TOPIC = 'continues the earlier work'
16export const NEW_TOPIC = 'starts a different topic'
17
18// A request the person typed, at the terminal or through Remote Control: not a slash command, a prompt
19// the engine, a task or an SDK host sent, or a prompt too short to judge.
20export function isTypedRequest(e: Pick<PromptSubmitInput, 'text' | 'origin' | 'command' | 'isMeta'>): boolean {
21 const text = e.text.trim()
22 return ['composer', 'bridge'].includes(e.origin.kind) && !e.command && !e.isMeta && !text.startsWith('/') && text.length >= MIN_PROMPT_LENGTH
23}
24
25// The requests to compare with: none after /clear or in another session, since each has its own id.
26export function earlierPrompts(stored: PromptHistory, sessionId: string): string[] {
27 return stored.sessionId === sessionId ? stored.prompts : []
28}
29
30export function remember(earlier: readonly string[], sessionId: string, prompt: string): PromptHistory {
31 return { sessionId, prompts: [...earlier, prompt.slice(0, PROMPT_ROOM)].slice(-KEEP_PROMPTS) }
32}
33
34// After a new topic the older requests no longer describe the work, so the next prompt is compared with the new one only.
35export function afterNewTopic(stored: PromptHistory, sessionId: string, prompt: string): PromptHistory {
36 if (stored.sessionId !== sessionId) return stored
37 const start = stored.prompts.lastIndexOf(prompt.slice(0, PROMPT_ROOM))
38 return start < 0 ? stored : { sessionId, prompts: stored.prompts.slice(start) }
39}
40
41export function classifierText(earlier: readonly string[], request: string): string {
42 return [
43 'Earlier requests from the user in this Claude Code session, oldest first:',
44 ...earlier.map(prompt => `- ${prompt}`),
45 '',
46 `New request from the user: ${request}`,
47 ].join('\n')
48}
49
50export const NUDGE = 'New topic? /clear starts a fresh session, or say how it relates to the current work.'
51
52async function check($: EngineInterface, earlier: readonly string[], sessionId: string, request: string) {
53 const label = await $.model.classify(classifierText(earlier, request.slice(0, PROMPT_ROOM)), [SAME_TOPIC, NEW_TOPIC], { model: 'haiku' })
54 if (label !== NEW_TOPIC) return
55 $.ui.toast(NUDGE, { timeoutMs: 10_000 })
56 await update($, history, stored => afterNewTopic(stored, sessionId, request))
57}
58
59export const register: Register = on => {
60 on('prompt.submit', async ($, e, next) => {
61 if (!isTypedRequest(e)) return next(e)
62 const request = e.text.trim()
63 try {
64 const sessionId = await $.session.id()
65 const earlier = earlierPrompts(await read($, history), sessionId)
66 await update($, history, stored => remember(earlierPrompts(stored, sessionId), sessionId, request))
67 // The check runs beside the turn, so a slow or failed model call never holds the prompt.
68 if (earlier.length > 0) void check($, earlier, sessionId, request).catch(() => undefined)
69 } catch {
70 // The nudge is a convenience: a failed read or write must never block the prompt.
71 }
72 return next(e)
73 })
74
75 // /clear ends the session too; the session id check covers a state that outlives it.
76 on('session.end', async ($, e, next) => {
77 await update($, history, () => EMPTY).catch(() => undefined)
78 return next(e)
79 })
80}
81types/index.d.ts 11 lines1// The person's recent requests in one session, newest last.
2export type PromptHistory = { sessionId: string | null; prompts: string[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'topic-nudge': {
7 history: PromptHistory
8 }
9 }
10}
11