SLOPSHOPPER

topic-nudge

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

newtoastpromptmodel
★ 2v0.1.0MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/topic-nudge
A shopper browsing a rack in a slop shop
README

topic-nudge

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.

How it works

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.

Cost

One Haiku call per prompt after the first, at most about 500 tokens.

Install

claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install topic-nudge@lbzepoqo

Restart Claude Code.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.ts 81 lines
1import { 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}
81
types/index.d.ts 11 lines
1// 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