SLOPSHOPPER

next-prompt

Next-prompt prediction: after each main-thread turn, a fork of the session's own model predicts your next message and shows it as the prompt box's suggestion…

newcommandpromptmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-prompt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prediction-stats ⎿ next-prompt: No predictions logged yet: a shown prediction counts once you send your next prompt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

next-prompt

Next-prompt prediction for Claude Code, built on mods.

After each main-thread turn that ends with an answer, the plugin makes one $.model.fork: the session's own model, reusing its prompt cache, with no tools, and nothing written to the transcript. The fork reads the conversation, your recent typed prompts in this project and the short messages you send often, and writes the message you are likely to send next. The plugin shows it as the prompt box's dim suggestion; press Tab to take it. A reply of NONE (or an empty or overlong one) shows nothing and counts as an abstention. Subagent turns do not fork.

Claude Code's own suggestion

In the terminal the plugin hides Claude Code's own prompt suggestion, so only its prediction shows. In the desktop app that hook does not run (anthropics/claude-code#99876), so set "promptSuggestionEnabled": false in your settings to keep the two from competing.

/prediction-stats

Prints, over the logged outcomes: how many shown suggestions were taken (the prompt you sent starts with the suggestion), how many were near-taken (edit distance within 30% of the suggestion's length), the mean characters kept, the abstentions, the median fork time, and the share of fork input tokens read from cache. It answers in Chinese or English, following the settings language, then LC_ALL / LANG.

What it keeps

In the plugin store:

  • The last 300 prompts you typed (in the terminal, through Remote Control, or in the desktop app; not slash commands), each with its session's directory, 300 characters each.
  • The last 500 outcomes: each shown suggestion with the prompt you sent after it, 300 characters each, plus the fork time, token counts and edit distance.
  • The count of abstentions.

A -p run neither forks nor records.

Cost

One fork per main-thread turn, billed to your session; most of its input is read from the prompt cache.

Options

None.

Source 4 files
hooks/predict-hooks.ts 207 lines
1import type { EngineInterface, HookFailure, Register } from 'claude-code'
2import { pickLang } from './i18n'
3import type { Lang } from './i18n'
4import { strings } from './predict-i18n'
5import { NO_PREDICTION, editDistance, predictionPrompt, promptSamples } from './predict-prompt'
6import type { TypedPrompt } from './predict-prompt'
7
8const TYPED_PROMPTS_KEY = 'typedPrompts'
9// Enough history for habits to repeat; 300 entries of 300 characters stay far under the store's 4 MiB.
10const TYPED_PROMPTS_KEPT = 300
11const TYPED_PROMPT_CHARS = 300
12// Prompts the person typed: at a terminal, through Remote Control, or in the desktop app (an SDK host).
13const TYPED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
14// A reply this long ignored the prompt's length rule, so it is not a message the person would send.
15const PREDICTION_MAX_CHARS = 600
16const PREDICTION_LOG_KEY = 'predictionLog'
17const PREDICTION_LOG_KEPT = 500
18const PREDICTION_LOG_CHARS = 300
19// An outcome whose edit distance is at most this share of the suggestion's length counts as near-taken.
20const NEAR_TAKEN_SHARE = 0.3
21const PREDICTION_ABSTAINED_KEY = 'predictionAbstained'
22const STATS_COMMAND = 'prediction-stats'
23
24/**
25 * One shown suggestion and the typed prompt after it: `at` epoch ms, `forkMs` the fork's time,
26 * `chars` the suggestion's length, `inputTokens` the fork's whole input (uncached, cache read and cache write), `cachedTokens` the part
27 * read from cache. `editDistance` is between the suggestion and the typed prompt cut to its length; `retainedChars` the suggestion's
28 * length less that distance.
29 */
30type PredictionOutcome = {
31  at: number
32  forkMs: number
33  chars: number
34  isAccepted: boolean
35  inputTokens: number
36  cachedTokens: number
37  suggestion: string
38  actual: string
39  editDistance: number
40  retainedChars: number
41}
42
43// The suggestion in the prompt box now; any submitted prompt ends it, and a reload drops it unlogged.
44let shownPrediction: { text: string; forkMs: number; inputTokens: number; cachedTokens: number } | null = null
45// The desktop app's last next-step box: whether it asked a question, and the prompts its reply buttons send.
46let box: { asked: boolean; replies: string[] } | null = null
47
48// Lives here, not in i18n.ts: validate only follows $ into functions declared in the same file.
49async function resolveLang($: EngineInterface, option: unknown): Promise<Lang> {
50  const settings = await $.settings.read()
51  return pickLang(option, settings.language, (await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
52}
53
54function logFailure($: EngineInterface, event: string, error: HookFailure): void {
55  $.ui.log(`next-prompt: ${event} hook failed (${error.kind}): ${error.message ?? 'no message'}`, { to: 'debug' })
56}
57
58async function typedPrompts($: EngineInterface): Promise<TypedPrompt[]> {
59  const stored = await $.store.get(TYPED_PROMPTS_KEY)
60  return Array.isArray(stored)
61    ? stored.filter((p): p is TypedPrompt => typeof p?.text === 'string' && typeof p?.cwd === 'string')
62    : []
63}
64
65/**
66 * Asks a fork of the main thread for the person's next message and proposes it as the prompt box's suggestion;
67 * a reply that is NONE, empty or too long counts an abstention.
68 */
69async function suggestNext($: EngineInterface): Promise<void> {
70  // A plain -p run has no prompt box to show it in, so the fork would be paid for nothing.
71  if ((await $.session.surfaces()).length === 0) return
72  const samples = promptSamples(await typedPrompts($), await $.session.cwd())
73  const started = await $.clock.now()
74  const reply = await $.model.fork({ prompt: predictionPrompt(samples) })
75  const forkMs = (await $.clock.now()) - started
76  if (!reply.isAnswered) return
77  const text = reply.text.trim().replace(/^["“「]+|["”」]+$/g, '').trim()
78  if (text === '' || text === NO_PREDICTION || text.length > PREDICTION_MAX_CHARS) {
79    const abstained = await $.store.get(PREDICTION_ABSTAINED_KEY)
80    await $.store.set(PREDICTION_ABSTAINED_KEY, (typeof abstained === 'number' ? abstained : 0) + 1)
81    return
82  }
83  const { input_tokens, cache_read_input_tokens, cache_creation_input_tokens } = reply.usage
84  const inputTokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
85  if ((await $.prompt.suggest({ text })).isShown) shownPrediction = { text, forkMs, inputTokens, cachedTokens: cache_read_input_tokens }
86}
87
88async function predictionLog($: EngineInterface): Promise<PredictionOutcome[]> {
89  const stored = await $.store.get(PREDICTION_LOG_KEY)
90  return Array.isArray(stored) ? (stored as PredictionOutcome[]) : []
91}
92
93async function predictionStats($: EngineInterface, language: unknown): Promise<string> {
94  const t = strings[await resolveLang($, language)]
95  const log = await predictionLog($)
96  if (log.length === 0) return t.predictionStatsEmpty
97  const ms = log.map(o => o.forkMs).sort((a, b) => a - b)
98  const median = ms.length % 2 === 1 ? (ms[(ms.length - 1) / 2] ?? 0) : ((ms[ms.length / 2 - 1] ?? 0) + (ms[ms.length / 2] ?? 0)) / 2
99  const taken = log.filter(o => o.isAccepted).length
100  // Entries logged before token counts were recorded lack them.
101  const counted = log.filter(o => typeof o.inputTokens === 'number' && typeof o.cachedTokens === 'number')
102  const input = counted.reduce((sum, o) => sum + o.inputTokens, 0)
103  const cached = counted.reduce((sum, o) => sum + o.cachedTokens, 0)
104  const cachePercent = input === 0 ? null : Math.round((100 * cached) / input)
105  // Entries logged before edit distances were recorded lack them, and their turns counted no abstentions.
106  const edited = log.filter(o => typeof o.editDistance === 'number' && typeof o.retainedChars === 'number')
107  const near = edited.filter(o => o.editDistance <= NEAR_TAKEN_SHARE * o.chars).length
108  const edits = edited.length === 0 ? null : {
109    near,
110    counted: edited.length,
111    nearPercent: Math.round((100 * near) / edited.length),
112    meanRetained: (edited.reduce((sum, o) => sum + o.retainedChars, 0) / edited.length).toFixed(1),
113  }
114  const stored = await $.store.get(PREDICTION_ABSTAINED_KEY)
115  const abstained = typeof stored === 'number' ? stored : 0
116  const decisions = abstained + edited.length
117  const abstainPercent = decisions === 0 ? null : Math.round((100 * abstained) / decisions)
118  return t.predictionStats(taken, log.length, Math.round((100 * taken) / log.length), (median / 1000).toFixed(1), cachePercent, edits, abstained, abstainPercent)
119}
120
121export const register: Register = (on, options) => {
122  // A matcher on every hook: in the source tree register.tsx holds the matcher-less one on each of these events, and the engine refuses a second.
123  // The fork answers only after the turn has ended, so it runs on a timer: a dispatch's own work stops with it.
124  on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
125    const done = await next(e)
126    if (e.agentId === undefined && e.reason === 'answer' && box?.asked !== true) {
127      $.clock.after(0, () => void suggestNext($).catch(error => $.ui.log(`next-prompt: prediction failed: ${String(error)}`, { to: 'debug' })))
128    }
129    if (e.agentId === undefined && box !== null) box = { ...box, asked: false }
130    return done
131  }).catch(($, e, next) => {
132    logFailure($, 'turn.complete', next.error)
133    return next(e)
134  })
135
136  // A box that asks a question already offers the next step, so a prediction would repeat its buttons.
137  on('classic.PostToolUse', { tool_name: /^mcp__ccd_turn__wrap_up$/ }, async ($, e, next) => {
138    const input = e.tool_input
139    if (e.agent_id === undefined && typeof input === 'object' && input !== null) {
140      const { ask, actions = [] } = input as { ask?: unknown; actions?: unknown }
141      if (Array.isArray(actions)) {
142        // A button with `action` runs an app action instead of sending a prompt.
143        const replies = actions.flatMap((a: unknown) => {
144          if (typeof a !== 'object' || a === null || 'action' in a) return []
145          const { message, label } = a as { message?: unknown; label?: unknown }
146          const text = message ?? label
147          return typeof text === 'string' && text.trim() !== '' ? [text.trim()] : []
148        })
149        box = { asked: typeof ask === 'string' && ask !== '' && actions.length > 0, replies }
150      }
151    }
152    return next(e)
153  }).catch(($, e, next) => {
154    logFailure($, 'classic.PostToolUse', next.error)
155    return next(e)
156  })
157
158  // The plugin's prediction replaces the engine's own; a desktop session never runs this hook (anthropics/claude-code#99876).
159  on('prompt.suggest', { origin: { kind: 'suggestion' } }, () => ({ isShown: false }))
160
161  on('prompt.submit', { wait: [true, false] }, async ($, e, next) => {
162    const entered = await next(e)
163    const shown = shownPrediction
164    shownPrediction = null
165    const replies = box?.replies ?? []
166    box = null
167    // A box's button text is Claude's, not the person's typing.
168    const isReply = replies.includes(e.text.trim())
169    // A -p run is an SDK host too, so only a session something draws on holds typed prompts.
170    if (!('drop' in entered) && !isReply && TYPED_ORIGINS.has(e.origin.kind) && (await $.session.surfaces()).length > 0) {
171      if (shown !== null) {
172        const { text, ...measured } = shown
173        const distance = editDistance(text, e.text.slice(0, text.length))
174        const outcome: PredictionOutcome = {
175          at: await $.clock.now(),
176          ...measured,
177          chars: text.length,
178          isAccepted: e.text.startsWith(text),
179          suggestion: text.slice(0, PREDICTION_LOG_CHARS),
180          actual: e.text.slice(0, PREDICTION_LOG_CHARS),
181          editDistance: distance,
182          retainedChars: Math.max(0, text.length - distance),
183        }
184        await $.store.set(PREDICTION_LOG_KEY, [...(await predictionLog($)), outcome].slice(-PREDICTION_LOG_KEPT))
185      }
186      if (!e.text.startsWith('/')) {
187        const typed = [...(await typedPrompts($)), { text: e.text.slice(0, TYPED_PROMPT_CHARS), cwd: await $.session.cwd() }]
188        await $.store.set(TYPED_PROMPTS_KEY, typed.slice(-TYPED_PROMPTS_KEPT))
189      }
190    }
191    return entered
192  }).catch(($, e, next) => {
193    logFailure($, 'prompt.submit', next.error)
194    return next(e)
195  })
196
197  on('session.start', { cwd: /^/ }, async ($, e, next) => {
198    await $.command.register({ name: STATS_COMMAND, description: 'Show how often next-prompt predictions were taken and the median fork time' })
199    return next(e)
200  }).catch(($, e, next) => {
201    logFailure($, 'session.start', next.error)
202    return next(e)
203  })
204
205  on('command.run', { command: STATS_COMMAND }, async $ => ({ text: await predictionStats($, options.language) }))
206}
207
hooks/i18n.ts 21 lines
1export type Lang = 'zh-CN' | 'en'
2
3const CHINESE = /^zh\b|chinese|中文|汉语|漢語|简体|繁體|mandarin/i
4
5/** `language` userConfig → UI language; `auto` follows settings `language`, then the locale, else English. */
6export function pickLang(option: unknown, settingsLanguage: unknown, locale: string | undefined): Lang {
7  if (option === 'zh-CN' || option === 'en') return option
8  if (typeof settingsLanguage === 'string' && settingsLanguage.trim() !== '') return CHINESE.test(settingsLanguage.trim()) ? 'zh-CN' : 'en'
9  return locale !== undefined && /^zh/i.test(locale) ? 'zh-CN' : 'en'
10}
11
12const zh = {
13  idleCompacted: '空闲将满缓存时效,已自动压缩上下文',
14}
15
16const en: typeof zh = {
17  idleCompacted: 'Idle near cache expiry: context compacted',
18}
19
20export const strings: Record<Lang, typeof zh> = { 'zh-CN': zh, en }
21
hooks/predict-i18n.ts 34 lines
1import type { Lang } from './i18n'
2
3/** Near-taken outcomes among the `counted` that hold an edit distance, and the mean characters kept per outcome. */
4type EditFigures = { near: number; counted: number; nearPercent: number; meanRetained: string }
5
6const zh = {
7  predictionStats: (
8    taken: number,
9    shown: number,
10    percent: number,
11    medianSeconds: string,
12    cachePercent: number | null,
13    edits: EditFigures | null,
14    abstained: number,
15    abstainPercent: number | null,
16  ) =>
17    `下一步预测:最近显示的 ${shown} 条里采纳了 ${taken} 条(${percent}%);fork 耗时中位数 ${medianSeconds} 秒` +
18    (cachePercent === null ? '' : `;fork 输入 token 有 ${cachePercent}% 读自缓存`) +
19    (edits === null ? '' : `;${edits.counted} 条里 ${edits.near} 条接近采纳(${edits.nearPercent}%,编辑距离不超过长度的 30%),平均每条保留 ${edits.meanRetained} 字`) +
20    (abstainPercent === null ? '' : `;放弃显示 ${abstained} 次(占全部决定的 ${abstainPercent}%)`),
21  predictionStatsEmpty: '还没有预测记录:显示过的预测在你发出下一条后才计入',
22}
23
24const en: typeof zh = {
25  predictionStats: (taken, shown, percent, medianSeconds, cachePercent, edits, abstained, abstainPercent) =>
26    `Next-prompt predictions: ${taken} of the last ${shown} shown were taken (${percent}%); median fork time ${medianSeconds} s` +
27    (cachePercent === null ? '' : `; ${cachePercent}% of fork input tokens read from cache`) +
28    (edits === null ? '' : `; ${edits.near} of ${edits.counted} near-taken (${edits.nearPercent}%, edit distance within 30% of the length), mean ${edits.meanRetained} chars kept`) +
29    (abstainPercent === null ? '' : `; ${abstained} abstained (${abstainPercent}% of decisions)`),
30  predictionStatsEmpty: 'No predictions logged yet: a shown prediction counts once you send your next prompt',
31}
32
33export const strings: Record<Lang, typeof zh> = { 'zh-CN': zh, en }
34
hooks/predict-prompt.ts 77 lines
1/** The fork's reply when it has no prediction worth showing. */
2export const NO_PREDICTION = 'NONE'
3
4/** A prompt the person typed, and the directory of the session it was typed in. */
5export type TypedPrompt = { text: string; cwd: string }
6
7export type PromptSamples = {
8  /** This project's typed prompts, oldest first; the latest few from anywhere when the project has none. */
9  recent: string[]
10  /** Short messages the person sends again and again, most frequent first. */
11  habits: string[]
12}
13
14const PROJECT_KEPT = 12
15const FALLBACK_KEPT = 6
16const HABIT_MAX_CHARS = 40
17const HABIT_MIN_COUNT = 3
18const HABITS_KEPT = 8
19
20/** Picks the samples the prediction prompt shows from `history` (oldest first) for a session in `cwd`. */
21export function promptSamples(history: readonly TypedPrompt[], cwd: string): PromptSamples {
22  const here = history.filter(p => p.cwd === cwd).map(p => p.text)
23  const recent = here.length > 0 ? here.slice(-PROJECT_KEPT) : history.slice(-FALLBACK_KEPT).map(p => p.text)
24  const counts = new Map<string, number>()
25  for (const { text } of history) {
26    const key = text.trim().replace(/[\s.!?\u3002\uff01\uff1f]+$/u, '')
27    if (key !== '' && key.length <= HABIT_MAX_CHARS) counts.set(key, (counts.get(key) ?? 0) + 1)
28  }
29  const habits = [...counts].filter(([, n]) => n >= HABIT_MIN_COUNT).sort((a, b) => b[1] - a[1]).slice(0, HABITS_KEPT).map(([text]) => text)
30  return { recent, habits }
31}
32
33const listed = (texts: readonly string[]) => (texts.length === 0 ? '(none yet)' : texts.map(text => `<message>${text}</message>`).join('\n'))
34
35/** The question the prediction fork asks after a main-thread turn. */
36export function predictionPrompt(samples: PromptSamples): string {
37  return `[NEXT-MESSAGE PREDICTION. This is not a message from the user. Do not answer it, do not continue your work, do not call tools.]
38
39Write the next message the user will send in this conversation. It goes into their prompt box as a suggestion. The goal is a message they send with Tab and Enter because it says what they were about to ask for, as fully as they would write it.
40
41First work out where the user is taking the task:
42- The overall goal: what they set out to do in this conversation, and how it has changed since.
43- The progress: what is done and verified, what is still open, and what your last answer left unverified, uncertain or broken.
44- Their habits in this project: the steps they take at this point (for example review, run the gates, merge, update the docs), from this conversation and from their messages below.
45
46Then write the message:
47- If your last answer ended with questions or choices, answer them in a few words, the way this user decides; they often do not take your recommendation. That is the start of the message, not the whole of it.
48- The main part is the one next step that moves the task forward, with at most one follow-up after it; do not chain a list of steps. It may be something your answer did not list: an open problem, an unverified claim, the next item of the plan, or a question the user wants answered first.
49- Write it as a task request someone can carry out and check: name the concrete files, components, findings and numbers from this conversation, the result they want back, and the scope (what to do now, what not yet).
50- Every detail comes from this conversation. Do not add constraints or steps the user has not asked for in it.
51- Do not report what the user has done or seen (installed, uploaded, restarted, configured, tested on the device, a result they got) unless the conversation already shows it. Write the request, not a report of its outcome.
52- Never ask for a destructive or outward-facing action (push, deploy, publish, delete, send) that the user has not already asked for.
53- Their language and voice, without filler: no sentence-final particles such as \u5427, \u5462, \u554a or \u54e6, and no politeness padding. Keep only words that carry a requirement. One or two sentences, at most 50 words or 120 Chinese characters. No preamble, no quotation marks.
54
55Messages this user wrote earlier in this project, oldest first. Use them for voice and habits; their content may be out of date:
56${listed(samples.recent)}
57
58Short messages this user sends often:
59${listed(samples.habits)}
60
61Reply with the message alone. If you cannot tell where the user is going, reply with exactly: ${NO_PREDICTION}`
62}
63
64/** Levenshtein distance over UTF-16 code units. */
65export function editDistance(a: string, b: string): number {
66  let previous = Array.from({ length: b.length + 1 }, (_, j) => j)
67  for (let i = 1; i <= a.length; i += 1) {
68    const current = [i]
69    for (let j = 1; j <= b.length; j += 1) {
70      const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
71      current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution))
72    }
73    previous = current
74  }
75  return previous[b.length] ?? 0
76}
77